mirror of
https://github.com/nianzhibai/91.git
synced 2026-06-15 00:44:30 +08:00
Update homepage rotation and preview handling
This commit is contained in:
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
## 当前功能
|
## 当前功能
|
||||||
|
|
||||||
- 前台需要登录后访问,支持首页、列表页、搜索、分类/标签筛选、分页、详情播放和相关推荐。
|
- 前台需要登录后访问,支持首页、列表页、搜索、分类/标签筛选、分页、详情播放和相关推荐。首页“今日排行”按点赞热度排序并每 2 小时轮换一组,默认展示 10 个;“最新视频”按发布时间倒序展示最新 10 个。
|
||||||
- 视频卡片支持封面、画质、时长、点赞/点踩、移动端点按预览;列表页会记住筛选、分页和滚动位置。
|
- 视频卡片支持封面、画质、时长、点赞/点踩、移动端点按预览;列表页会记住筛选、分页和滚动位置。
|
||||||
- 播放页会在视频信息中显示来源网盘类型,并提供点赞、标签编辑和 **不再展示**。不再展示是全局隐藏:写入数据库后,该视频不会再出现在首页、列表、相关推荐中,详情接口也会返回 404。
|
- 播放页会在视频信息中显示来源网盘类型,并提供点赞、标签编辑和 **不再展示**。不再展示是全局隐藏:写入数据库后,该视频不会再出现在首页、列表、相关推荐中,详情接口也会返回 404。
|
||||||
- 管理后台支持网盘管理、视频管理、标签管理和运行时 Teaser 生成开关。
|
- 管理后台支持网盘管理、视频管理、标签管理和运行时 Teaser 生成开关。
|
||||||
|
|||||||
@@ -153,6 +153,83 @@ func TestRegisterPreviewWorkersGenerateThumbnailsBeforePreviews(t *testing.T) {
|
|||||||
t.Fatalf("generation did not finish, events=%#v", gen.Events())
|
t.Fatalf("generation did not finish, events=%#v", gen.Events())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFailedThumbnailsDoNotBlockPreviewGeneration(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
cat, err := catalog.Open(t.TempDir() + "/catalog.db")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open catalog: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if err := cat.Close(); err != nil {
|
||||||
|
t.Fatalf("close catalog: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
video := &catalog.Video{
|
||||||
|
ID: "video-failed-thumb",
|
||||||
|
DriveID: "drive-id",
|
||||||
|
FileID: "file-1",
|
||||||
|
Title: "Clip With Failed Thumb",
|
||||||
|
PreviewStatus: "pending",
|
||||||
|
PublishedAt: now,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
if err := cat.UpsertVideo(ctx, video); err != nil {
|
||||||
|
t.Fatalf("seed video: %v", err)
|
||||||
|
}
|
||||||
|
if err := cat.UpdateVideoMeta(ctx, video.ID, catalog.VideoMetaPatch{ThumbnailStatus: "failed"}); err != nil {
|
||||||
|
t.Fatalf("mark thumbnail failed: %v", err)
|
||||||
|
}
|
||||||
|
missing, err := cat.CountVideosNeedingThumbnail(ctx, "drive-id")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("count missing thumbnails: %v", err)
|
||||||
|
}
|
||||||
|
if missing != 0 {
|
||||||
|
t.Fatalf("missing thumbnails = %d, want failed thumbnails excluded", missing)
|
||||||
|
}
|
||||||
|
|
||||||
|
app := &App{
|
||||||
|
cat: cat,
|
||||||
|
workers: make(map[string]*preview.Worker),
|
||||||
|
thumbWorkers: make(map[string]*preview.ThumbWorker),
|
||||||
|
previewEnabled: true,
|
||||||
|
}
|
||||||
|
gen := &serverFakeTeaserGenerator{}
|
||||||
|
drv := &serverFakeDrive{}
|
||||||
|
worker := preview.NewWorker(gen, cat, drv, "")
|
||||||
|
thumbWorker := preview.NewThumbWorker(gen, cat, drv)
|
||||||
|
go worker.Run(ctx)
|
||||||
|
go thumbWorker.Run(ctx)
|
||||||
|
|
||||||
|
app.registerPreviewWorkers(ctx, "drive-id", worker, thumbWorker, func() {})
|
||||||
|
|
||||||
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
got, err := cat.GetVideo(ctx, video.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get video: %v", err)
|
||||||
|
}
|
||||||
|
if got.PreviewStatus == "ready" {
|
||||||
|
events := gen.Events()
|
||||||
|
if len(events) != 1 || events[0] != "preview:"+video.ID {
|
||||||
|
t.Fatalf("events = %#v, want preview only", events)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := cat.GetVideo(ctx, video.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get video after timeout: %v", err)
|
||||||
|
}
|
||||||
|
t.Fatalf("preview status = %q, want ready; events=%#v", got.PreviewStatus, gen.Events())
|
||||||
|
}
|
||||||
|
|
||||||
func TestRegenFailedPreviewsQueuesOnlyFailedVideosForDrive(t *testing.T) {
|
func TestRegenFailedPreviewsQueuesOnlyFailedVideosForDrive(t *testing.T) {
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|||||||
@@ -52,12 +52,18 @@ type Server struct {
|
|||||||
LocalDir string
|
LocalDir string
|
||||||
UploadDir string
|
UploadDir string
|
||||||
FFmpegPath string
|
FFmpegPath string
|
||||||
|
Now func() time.Time
|
||||||
OnVideoUploaded func(*catalog.Video)
|
OnVideoUploaded func(*catalog.Video)
|
||||||
|
|
||||||
transcodeMu sync.Mutex
|
transcodeMu sync.Mutex
|
||||||
transcodeJobs map[string]bool
|
transcodeJobs map[string]bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
homePageSize = 10
|
||||||
|
homeWindowDuration = 2 * time.Hour
|
||||||
|
)
|
||||||
|
|
||||||
// VideoDTO 是返回给前端的视频对象,字段名跟前端 VideoItem 对齐
|
// VideoDTO 是返回给前端的视频对象,字段名跟前端 VideoItem 对齐
|
||||||
type VideoDTO struct {
|
type VideoDTO struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
@@ -134,16 +140,45 @@ func (s *Server) RegisterRoutes(r chi.Router, a *auth.Authenticator) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
|
||||||
items, _, err := s.Catalog.ListVideos(r.Context(), catalog.ListParams{
|
items, total, err := s.Catalog.ListVideos(r.Context(), catalog.ListParams{
|
||||||
Sort: "hot", Page: 1, PageSize: 24,
|
Sort: "hot", Page: 1, PageSize: homePageSize,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeErr(w, http.StatusInternalServerError, err)
|
writeErr(w, http.StatusInternalServerError, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
page := homeWindowPage(s.now(), total, homePageSize)
|
||||||
|
if page > 1 {
|
||||||
|
items, _, err = s.Catalog.ListVideos(r.Context(), catalog.ListParams{
|
||||||
|
Sort: "hot", Page: page, PageSize: homePageSize,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
writeJSON(w, http.StatusOK, mapVideos(items))
|
writeJSON(w, http.StatusOK, mapVideos(items))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) now() time.Time {
|
||||||
|
if s.Now != nil {
|
||||||
|
return s.Now()
|
||||||
|
}
|
||||||
|
return time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
func homeWindowPage(now time.Time, total, pageSize int) int {
|
||||||
|
if pageSize <= 0 || total <= pageSize {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
pageCount := (total + pageSize - 1) / pageSize
|
||||||
|
if pageCount <= 1 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
window := now.Unix() / int64(homeWindowDuration/time.Second)
|
||||||
|
return int(window%int64(pageCount)) + 1
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleList(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleList(w http.ResponseWriter, r *http.Request) {
|
||||||
q := r.URL.Query()
|
q := r.URL.Query()
|
||||||
page, _ := strconv.Atoi(q.Get("page"))
|
page, _ := strconv.Atoi(q.Get("page"))
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -83,6 +84,78 @@ func TestPreviewURLFallsBackWithoutUpdatedAt(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHomeWindowPageChangesEveryTwoHours(t *testing.T) {
|
||||||
|
total := homePageSize * 3
|
||||||
|
if got := homeWindowPage(time.Unix(0, 0), total, homePageSize); got != 1 {
|
||||||
|
t.Fatalf("window page at epoch = %d, want 1", got)
|
||||||
|
}
|
||||||
|
if got := homeWindowPage(time.Unix(int64(homeWindowDuration/time.Second)-1, 0), total, homePageSize); got != 1 {
|
||||||
|
t.Fatalf("window page before boundary = %d, want 1", got)
|
||||||
|
}
|
||||||
|
if got := homeWindowPage(time.Unix(int64(homeWindowDuration/time.Second), 0), total, homePageSize); got != 2 {
|
||||||
|
t.Fatalf("window page at first boundary = %d, want 2", got)
|
||||||
|
}
|
||||||
|
if got := homeWindowPage(time.Unix(int64(3*homeWindowDuration/time.Second), 0), total, homePageSize); got != 1 {
|
||||||
|
t.Fatalf("window page after cycle = %d, want 1", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleHomeRotatesHotVideosByTwoHourWindow(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
cat, err := catalog.Open(t.TempDir() + "/catalog.db")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open catalog: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if err := cat.Close(); err != nil {
|
||||||
|
t.Fatalf("close catalog: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
base := time.Unix(1_700_000_000, 0)
|
||||||
|
for i := 0; i < homePageSize+1; i++ {
|
||||||
|
id := "video-" + strconv.Itoa(i)
|
||||||
|
if err := cat.UpsertVideo(ctx, &catalog.Video{
|
||||||
|
ID: id,
|
||||||
|
DriveID: "drive",
|
||||||
|
FileID: "file-" + strconv.Itoa(i),
|
||||||
|
Title: "Video " + strconv.Itoa(i),
|
||||||
|
Likes: homePageSize + 1 - i,
|
||||||
|
PublishedAt: base.Add(time.Duration(i) * time.Second),
|
||||||
|
CreatedAt: base,
|
||||||
|
UpdatedAt: base,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("seed video %s: %v", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
firstWindow := requestHomeIDs(t, &Server{
|
||||||
|
Catalog: cat,
|
||||||
|
Now: func() time.Time { return time.Unix(0, 0) },
|
||||||
|
})
|
||||||
|
sameWindow := requestHomeIDs(t, &Server{
|
||||||
|
Catalog: cat,
|
||||||
|
Now: func() time.Time { return time.Unix(int64(homeWindowDuration/time.Second)-1, 0) },
|
||||||
|
})
|
||||||
|
nextWindow := requestHomeIDs(t, &Server{
|
||||||
|
Catalog: cat,
|
||||||
|
Now: func() time.Time { return time.Unix(int64(homeWindowDuration/time.Second), 0) },
|
||||||
|
})
|
||||||
|
|
||||||
|
if strings.Join(firstWindow, ",") != strings.Join(sameWindow, ",") {
|
||||||
|
t.Fatalf("same two-hour window changed videos: first=%v same=%v", firstWindow, sameWindow)
|
||||||
|
}
|
||||||
|
if strings.Join(firstWindow, ",") == strings.Join(nextWindow, ",") {
|
||||||
|
t.Fatalf("next two-hour window did not change videos: %v", nextWindow)
|
||||||
|
}
|
||||||
|
if len(firstWindow) != homePageSize {
|
||||||
|
t.Fatalf("first window item count = %d, want %d", len(firstWindow), homePageSize)
|
||||||
|
}
|
||||||
|
if len(nextWindow) != 1 {
|
||||||
|
t.Fatalf("next window item count = %d, want final page with 1 item", len(nextWindow))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestHandleUploadVideoSavesFileVideoTagsAndQueuesPreview(t *testing.T) {
|
func TestHandleUploadVideoSavesFileVideoTagsAndQueuesPreview(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
cat, err := catalog.Open(t.TempDir() + "/catalog.db")
|
cat, err := catalog.Open(t.TempDir() + "/catalog.db")
|
||||||
@@ -652,6 +725,25 @@ func requestWithRouteParam(method, target, key, value string, body *strings.Read
|
|||||||
return req
|
return req
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func requestHomeIDs(t *testing.T, server *Server) []string {
|
||||||
|
t.Helper()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/home", nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
server.handleHome(rr, req)
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("home status = %d, body = %s", rr.Code, rr.Body.String())
|
||||||
|
}
|
||||||
|
var videos []VideoDTO
|
||||||
|
if err := json.NewDecoder(rr.Body).Decode(&videos); err != nil {
|
||||||
|
t.Fatalf("decode home response: %v", err)
|
||||||
|
}
|
||||||
|
ids := make([]string, 0, len(videos))
|
||||||
|
for _, v := range videos {
|
||||||
|
ids = append(ids, v.ID)
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
func multipartUploadRequest(t *testing.T, fields map[string]string, fileName, fileContent string) *http.Request {
|
func multipartUploadRequest(t *testing.T, fields map[string]string, fileName, fileContent string) *http.Request {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
var body bytes.Buffer
|
var body bytes.Buffer
|
||||||
|
|||||||
@@ -325,7 +325,8 @@ func (c *Catalog) ListVideosByPreviewStatus(ctx context.Context, driveID, status
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListVideosNeedingThumbnail returns videos that do not have any cover URL yet.
|
// ListVideosNeedingThumbnail returns videos that still need a thumbnail attempt.
|
||||||
|
// Failed thumbnails are reported separately and should not block teaser generation.
|
||||||
func (c *Catalog) ListVideosNeedingThumbnail(ctx context.Context, driveID string, limit int) ([]*Video, error) {
|
func (c *Catalog) ListVideosNeedingThumbnail(ctx context.Context, driveID string, limit int) ([]*Video, error) {
|
||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
limit = 10000
|
limit = 10000
|
||||||
@@ -334,6 +335,7 @@ func (c *Catalog) ListVideosNeedingThumbnail(ctx context.Context, driveID string
|
|||||||
`SELECT `+allVideoCols+` FROM videos
|
`SELECT `+allVideoCols+` FROM videos
|
||||||
WHERE drive_id = ?
|
WHERE drive_id = ?
|
||||||
AND COALESCE(thumbnail_url, '') = ''
|
AND COALESCE(thumbnail_url, '') = ''
|
||||||
|
AND COALESCE(thumbnail_status, 'pending') != 'failed'
|
||||||
AND COALESCE(hidden, 0) = 0
|
AND COALESCE(hidden, 0) = 0
|
||||||
AND `+uniqueVideoWhereSQL+`
|
AND `+uniqueVideoWhereSQL+`
|
||||||
ORDER BY created_at ASC
|
ORDER BY created_at ASC
|
||||||
@@ -360,6 +362,7 @@ func (c *Catalog) CountVideosNeedingThumbnail(ctx context.Context, driveID strin
|
|||||||
`SELECT COUNT(*) FROM videos
|
`SELECT COUNT(*) FROM videos
|
||||||
WHERE drive_id = ?
|
WHERE drive_id = ?
|
||||||
AND COALESCE(thumbnail_url, '') = ''
|
AND COALESCE(thumbnail_url, '') = ''
|
||||||
|
AND COALESCE(thumbnail_status, 'pending') != 'failed'
|
||||||
AND COALESCE(hidden, 0) = 0
|
AND COALESCE(hidden, 0) = 0
|
||||||
AND `+uniqueVideoWhereSQL,
|
AND `+uniqueVideoWhereSQL,
|
||||||
driveID).Scan(&count)
|
driveID).Scan(&count)
|
||||||
|
|||||||
@@ -170,12 +170,20 @@ func (d *Driver) Stat(ctx context.Context, fileID string) (*drives.Entry, error)
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (d *Driver) StreamURL(ctx context.Context, fileID string) (*drives.StreamLink, error) {
|
func (d *Driver) StreamURL(ctx context.Context, fileID string) (*drives.StreamLink, error) {
|
||||||
|
return d.streamURLWithUA(ctx, fileID, d.ua)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Driver) StreamURLWithHeader(ctx context.Context, fileID string, header http.Header) (*drives.StreamLink, error) {
|
||||||
|
return d.streamURLWithUA(ctx, fileID, header.Get("User-Agent"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Driver) streamURLWithUA(ctx context.Context, fileID string, ua string) (*drives.StreamLink, error) {
|
||||||
// 需要先拿到 pickCode
|
// 需要先拿到 pickCode
|
||||||
f, err := d.client.GetFile(fileID)
|
f, err := d.client.GetFile(fileID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("115 get file: %w", err)
|
return nil, fmt.Errorf("115 get file: %w", err)
|
||||||
}
|
}
|
||||||
info, ua, err := d.downloadInfo(f.PickCode)
|
info, ua, err := d.downloadInfo(f.PickCode, ua)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("115 download url: %w", err)
|
return nil, fmt.Errorf("115 download url: %w", err)
|
||||||
}
|
}
|
||||||
@@ -201,12 +209,16 @@ func (d *Driver) StreamURL(ctx context.Context, fileID string) (*drives.StreamLi
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Driver) downloadInfo(pickCode string) (*sdk.DownloadInfo, string, error) {
|
func (d *Driver) downloadInfo(pickCode string, ua string) (*sdk.DownloadInfo, string, error) {
|
||||||
info, err := d.client.DownloadWithUA(pickCode, d.ua)
|
ua = strings.TrimSpace(ua)
|
||||||
|
if ua == "" {
|
||||||
|
ua = d.ua
|
||||||
|
}
|
||||||
|
info, err := d.client.DownloadWithUA(pickCode, ua)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
return info, d.ua, nil
|
return info, ua, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Driver) Upload(ctx context.Context, parentID, name string, r io.Reader, size int64) (string, error) {
|
func (d *Driver) Upload(ctx context.Context, parentID, name string, r io.Reader, size int64) (string, error) {
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ type Generator struct {
|
|||||||
cfg Config
|
cfg Config
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const teaserSegmentTimeout = 90 * time.Second
|
||||||
|
|
||||||
type ThumbnailGenerator interface {
|
type ThumbnailGenerator interface {
|
||||||
Probe(ctx context.Context, link *drives.StreamLink) (float64, error)
|
Probe(ctx context.Context, link *drives.StreamLink) (float64, error)
|
||||||
GenerateThumbnail(ctx context.Context, link *drives.StreamLink, videoID string, duration float64) (string, error)
|
GenerateThumbnail(ctx context.Context, link *drives.StreamLink, videoID string, duration float64) (string, error)
|
||||||
@@ -194,27 +196,88 @@ func pickSegmentStarts(duration float64, n int, eachSec float64) []float64 {
|
|||||||
return starts
|
return starts
|
||||||
}
|
}
|
||||||
|
|
||||||
// pickThumbnailOffset 选封面抽帧的时间点(秒)。独立于 teaser。
|
func teaserCandidateStarts(duration float64, primary []float64, eachSec float64) []float64 {
|
||||||
func pickThumbnailOffset() float64 {
|
out := make([]float64, 0, len(primary)+8)
|
||||||
return 5
|
for _, s := range primary {
|
||||||
|
out = appendUniqueStart(out, s, eachSec)
|
||||||
|
}
|
||||||
|
|
||||||
|
if duration <= 0 {
|
||||||
|
for _, s := range []float64{0, 3, 30, 60} {
|
||||||
|
out = appendUniqueStart(out, s, eachSec)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
usable := duration - eachSec - 1
|
||||||
|
if usable < 0 {
|
||||||
|
usable = 0
|
||||||
|
}
|
||||||
|
for _, pct := range []float64{0.03, 0.08, 0.12, 0.25, 0.40, 0.55, 0.70, 0.90} {
|
||||||
|
s := duration * pct
|
||||||
|
if s > usable {
|
||||||
|
s = usable
|
||||||
|
}
|
||||||
|
out = appendUniqueStart(out, s, eachSec)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendUniqueStart(starts []float64, start, eachSec float64) []float64 {
|
||||||
|
if start < 0 {
|
||||||
|
start = 0
|
||||||
|
}
|
||||||
|
minGap := math.Max(1, eachSec*1.5)
|
||||||
|
for _, existing := range starts {
|
||||||
|
if math.Abs(existing-start) < minGap {
|
||||||
|
return starts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return append(starts, start)
|
||||||
|
}
|
||||||
|
|
||||||
|
// thumbnailOffsets 选封面抽帧的时间点(秒)。独立于 teaser。
|
||||||
|
func thumbnailOffsets() []float64 {
|
||||||
|
return []float64{5, 1, 0}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 封面 ---
|
// --- 封面 ---
|
||||||
|
|
||||||
// GenerateThumbnail 抽一张 jpg 封面。封面统一从第 5 秒抽帧,避免为封面单独探时长。
|
// GenerateThumbnail 抽一张 jpg 封面。默认从第 5 秒抽帧,失败时回退到更早时间点。
|
||||||
func (g *Generator) GenerateThumbnail(ctx context.Context, link *drives.StreamLink, videoID string, duration float64) (string, error) {
|
func (g *Generator) GenerateThumbnail(ctx context.Context, link *drives.StreamLink, videoID string, duration float64) (string, error) {
|
||||||
dir := filepath.Join(g.cfg.LocalDir, "thumbs")
|
dir := filepath.Join(g.cfg.LocalDir, "thumbs")
|
||||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
dst := filepath.Join(dir, videoID+".jpg")
|
dst := filepath.Join(dir, videoID+".jpg")
|
||||||
offset := pickThumbnailOffset()
|
|
||||||
|
|
||||||
|
var lastErr error
|
||||||
|
offsets := thumbnailOffsets()
|
||||||
|
for i, offset := range offsets {
|
||||||
|
if i > 0 {
|
||||||
|
_ = os.Remove(dst)
|
||||||
|
}
|
||||||
|
if err := g.generateThumbnailAtOffset(ctx, link, dst, offset); err != nil {
|
||||||
|
lastErr = err
|
||||||
|
if !thumbnailOffsetFallbackAllowed(err) {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return dst, nil
|
||||||
|
}
|
||||||
|
if lastErr != nil {
|
||||||
|
return "", lastErr
|
||||||
|
}
|
||||||
|
return "", errors.New("thumbnail generation did not run")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *Generator) generateThumbnailAtOffset(ctx context.Context, link *drives.StreamLink, dst string, offset float64) error {
|
||||||
ctx2, cancel := context.WithTimeout(ctx, 60*time.Second)
|
ctx2, cancel := context.WithTimeout(ctx, 60*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
ffmpegLink, cleanup, err := prepareFFmpegLink(ctx2, link)
|
ffmpegLink, cleanup, err := prepareFFmpegLink(ctx2, link)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return err
|
||||||
}
|
}
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
@@ -236,13 +299,23 @@ func (g *Generator) GenerateThumbnail(ctx context.Context, link *drives.StreamLi
|
|||||||
out, err := cmd.CombinedOutput()
|
out, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
os.Remove(dst)
|
os.Remove(dst)
|
||||||
return "", ffmpegCommandError("ffmpeg thumb", err, out)
|
return ffmpegCommandError("ffmpeg thumb", err, out)
|
||||||
}
|
}
|
||||||
if info, statErr := os.Stat(dst); statErr != nil || info.Size() == 0 {
|
if info, statErr := os.Stat(dst); statErr != nil || info.Size() == 0 {
|
||||||
os.Remove(dst)
|
os.Remove(dst)
|
||||||
return "", fmt.Errorf("ffmpeg thumb produced empty file, stderr: %s", string(out))
|
return fmt.Errorf("ffmpeg thumb produced empty file, stderr: %s", string(out))
|
||||||
}
|
}
|
||||||
return dst, nil
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func thumbnailOffsetFallbackAllowed(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
text := strings.ToLower(err.Error())
|
||||||
|
return strings.Contains(text, "produced empty file") ||
|
||||||
|
strings.Contains(text, "signal: killed") ||
|
||||||
|
strings.Contains(text, "context deadline exceeded")
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 时长 ---
|
// --- 时长 ---
|
||||||
@@ -442,13 +515,35 @@ func (g *Generator) generateSequential(ctx context.Context, duration float64, li
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
for i, start := range starts {
|
candidates := teaserCandidateStarts(duration, starts, eachSec)
|
||||||
|
targetSegments := len(starts)
|
||||||
|
var lastErr error
|
||||||
|
for i, start := range candidates {
|
||||||
|
if len(segmentPaths) >= targetSegments {
|
||||||
|
break
|
||||||
|
}
|
||||||
seg, err := g.generateSingleSegment(ctx2, i, start, eachSec, linkForInput)
|
seg, err := g.generateSingleSegment(ctx2, i, start, eachSec, linkForInput)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
if !teaserSegmentFallbackAllowed(err) {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
lastErr = err
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
segmentPaths = append(segmentPaths, seg)
|
segmentPaths = append(segmentPaths, seg)
|
||||||
}
|
}
|
||||||
|
if len(segmentPaths) == 0 {
|
||||||
|
if lastErr != nil {
|
||||||
|
return "", lastErr
|
||||||
|
}
|
||||||
|
return "", errors.New("no usable teaser segment")
|
||||||
|
}
|
||||||
|
if len(segmentPaths) < targetSegments {
|
||||||
|
if lastErr != nil {
|
||||||
|
return "", fmt.Errorf("only generated %d/%d teaser segments: %w", len(segmentPaths), targetSegments, lastErr)
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("only generated %d/%d teaser segments", len(segmentPaths), targetSegments)
|
||||||
|
}
|
||||||
|
|
||||||
if len(segmentPaths) == 1 {
|
if len(segmentPaths) == 1 {
|
||||||
success = true
|
success = true
|
||||||
@@ -513,6 +608,9 @@ func (g *Generator) generateSequential(ctx context.Context, duration float64, li
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (g *Generator) generateSingleSegment(ctx context.Context, index int, start, eachSec float64, linkForInput func(int) (*drives.StreamLink, error)) (string, error) {
|
func (g *Generator) generateSingleSegment(ctx context.Context, index int, start, eachSec float64, linkForInput func(int) (*drives.StreamLink, error)) (string, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, teaserSegmentTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
link, err := linkForInput(index)
|
link, err := linkForInput(index)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -570,6 +668,31 @@ func (g *Generator) generateSingleSegment(ctx context.Context, index int, start,
|
|||||||
return segPath, nil
|
return segPath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func teaserSegmentFallbackAllowed(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if _, ok := drives.RateLimitRetryAfter(err); ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
text := strings.ToLower(err.Error())
|
||||||
|
if strings.Contains(text, "server returned 403") ||
|
||||||
|
strings.Contains(text, "403 forbidden") ||
|
||||||
|
strings.Contains(text, "server returned 405") ||
|
||||||
|
strings.Contains(text, "405 method") ||
|
||||||
|
strings.Contains(text, "access denied") ||
|
||||||
|
strings.Contains(text, "request has been blocked") ||
|
||||||
|
strings.Contains(text, "访问被阻断") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.Contains(text, "generated teaser has no video stream") ||
|
||||||
|
strings.Contains(text, "generated teaser has invalid duration") ||
|
||||||
|
strings.Contains(text, "generated teaser is empty") ||
|
||||||
|
strings.Contains(text, "produced empty file") ||
|
||||||
|
strings.Contains(text, "ffmpeg segment:") ||
|
||||||
|
strings.Contains(text, "ffprobe teaser:")
|
||||||
|
}
|
||||||
|
|
||||||
type localMediaProbe struct {
|
type localMediaProbe struct {
|
||||||
Streams []struct {
|
Streams []struct {
|
||||||
CodecType string `json:"codec_type"`
|
CodecType string `json:"codec_type"`
|
||||||
@@ -838,6 +961,7 @@ type Worker struct {
|
|||||||
Catalog *catalog.Catalog
|
Catalog *catalog.Catalog
|
||||||
Drive drives.Drive
|
Drive drives.Drive
|
||||||
ch chan *catalog.Video
|
ch chan *catalog.Video
|
||||||
|
queue videoQueue
|
||||||
|
|
||||||
RateLimitCooldown time.Duration
|
RateLimitCooldown time.Duration
|
||||||
BeforeTask func(context.Context) bool
|
BeforeTask func(context.Context) bool
|
||||||
@@ -858,10 +982,14 @@ func (w *Worker) Enqueue(v *catalog.Video) bool {
|
|||||||
if v == nil {
|
if v == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if !w.queue.reserve(v) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
select {
|
select {
|
||||||
case w.ch <- v:
|
case w.ch <- v:
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
|
w.queue.release(v)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -870,10 +998,14 @@ func (w *Worker) EnqueueBlocking(ctx context.Context, v *catalog.Video) bool {
|
|||||||
if v == nil {
|
if v == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if !w.queue.reserve(v) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
select {
|
select {
|
||||||
case w.ch <- v:
|
case w.ch <- v:
|
||||||
return true
|
return true
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
|
w.queue.release(v)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -883,6 +1015,7 @@ type ThumbWorker struct {
|
|||||||
Catalog *catalog.Catalog
|
Catalog *catalog.Catalog
|
||||||
Drive drives.Drive
|
Drive drives.Drive
|
||||||
ch chan *catalog.Video
|
ch chan *catalog.Video
|
||||||
|
queue videoQueue
|
||||||
|
|
||||||
RateLimitCooldown time.Duration
|
RateLimitCooldown time.Duration
|
||||||
rateLimit rateLimitState
|
rateLimit rateLimitState
|
||||||
@@ -915,6 +1048,54 @@ type taskActivity struct {
|
|||||||
currentTitle string
|
currentTitle string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type videoQueue struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
ids map[string]struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *videoQueue) reserve(v *catalog.Video) bool {
|
||||||
|
if v == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if v.ID == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
q.mu.Lock()
|
||||||
|
defer q.mu.Unlock()
|
||||||
|
if q.ids == nil {
|
||||||
|
q.ids = make(map[string]struct{})
|
||||||
|
}
|
||||||
|
if _, ok := q.ids[v.ID]; ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
q.ids[v.ID] = struct{}{}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *videoQueue) release(v *catalog.Video) {
|
||||||
|
if v == nil || v.ID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
q.mu.Lock()
|
||||||
|
delete(q.ids, v.ID)
|
||||||
|
q.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *videoQueue) lengthExcluding(currentID string) int {
|
||||||
|
q.mu.Lock()
|
||||||
|
defer q.mu.Unlock()
|
||||||
|
n := len(q.ids)
|
||||||
|
if currentID != "" {
|
||||||
|
if _, ok := q.ids[currentID]; ok {
|
||||||
|
n--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if n < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
func (a *taskActivity) start(v *catalog.Video) {
|
func (a *taskActivity) start(v *catalog.Video) {
|
||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
defer a.mu.Unlock()
|
defer a.mu.Unlock()
|
||||||
@@ -991,10 +1172,14 @@ func (w *ThumbWorker) Enqueue(v *catalog.Video) bool {
|
|||||||
if v == nil {
|
if v == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if !w.queue.reserve(v) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
select {
|
select {
|
||||||
case w.ch <- v:
|
case w.ch <- v:
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
|
w.queue.release(v)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1003,10 +1188,14 @@ func (w *ThumbWorker) EnqueueBlocking(ctx context.Context, v *catalog.Video) boo
|
|||||||
if v == nil {
|
if v == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if !w.queue.reserve(v) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
select {
|
select {
|
||||||
case w.ch <- v:
|
case w.ch <- v:
|
||||||
return true
|
return true
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
|
w.queue.release(v)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1015,14 +1204,16 @@ func (w *Worker) Status() TaskStatus {
|
|||||||
if w == nil {
|
if w == nil {
|
||||||
return TaskStatus{State: "idle"}
|
return TaskStatus{State: "idle"}
|
||||||
}
|
}
|
||||||
return taskStatus(&w.activity, &w.rateLimit, len(w.ch))
|
currentID, _ := w.activity.current()
|
||||||
|
return taskStatus(&w.activity, &w.rateLimit, w.queue.lengthExcluding(currentID))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *ThumbWorker) Status() TaskStatus {
|
func (w *ThumbWorker) Status() TaskStatus {
|
||||||
if w == nil {
|
if w == nil {
|
||||||
return TaskStatus{State: "idle"}
|
return TaskStatus{State: "idle"}
|
||||||
}
|
}
|
||||||
return taskStatus(&w.activity, &w.rateLimit, len(w.ch))
|
currentID, _ := w.activity.current()
|
||||||
|
return taskStatus(&w.activity, &w.rateLimit, w.queue.lengthExcluding(currentID))
|
||||||
}
|
}
|
||||||
|
|
||||||
func taskStatus(activity *taskActivity, rateLimit *rateLimitState, queueLength int) TaskStatus {
|
func taskStatus(activity *taskActivity, rateLimit *rateLimitState, queueLength int) TaskStatus {
|
||||||
@@ -1085,6 +1276,7 @@ func (w *ThumbWorker) Run(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (w *Worker) processQueued(ctx context.Context, v *catalog.Video) {
|
func (w *Worker) processQueued(ctx context.Context, v *catalog.Video) {
|
||||||
|
defer w.queue.release(v)
|
||||||
if w.BeforeTask != nil && !w.BeforeTask(ctx) {
|
if w.BeforeTask != nil && !w.BeforeTask(ctx) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1098,6 +1290,7 @@ func (w *Worker) processQueued(ctx context.Context, v *catalog.Video) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (w *ThumbWorker) processQueued(ctx context.Context, v *catalog.Video) {
|
func (w *ThumbWorker) processQueued(ctx context.Context, v *catalog.Video) {
|
||||||
|
defer w.queue.release(v)
|
||||||
w.activity.start(v)
|
w.activity.start(v)
|
||||||
defer w.activity.done()
|
defer w.activity.done()
|
||||||
if !waitForRateLimitCooldown(ctx, &w.rateLimit, "thumb", w.Drive) {
|
if !waitForRateLimitCooldown(ctx, &w.rateLimit, "thumb", w.Drive) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package preview
|
package preview
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"math"
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -94,6 +95,69 @@ func TestTinyVideoPreviewPlanUsesWholeVideoAsSingleSegment(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTeaserCandidateStartsKeepPrimaryAndAddFallbacks(t *testing.T) {
|
||||||
|
primary := []float64{10.2, 64.65, 119.1, 173.55}
|
||||||
|
got := teaserCandidateStarts(204, primary, 3)
|
||||||
|
if len(got) <= len(primary) {
|
||||||
|
t.Fatalf("candidate starts = %#v, want fallback starts after primary", got)
|
||||||
|
}
|
||||||
|
for i, want := range primary {
|
||||||
|
if math.Abs(got[i]-want) > 0.001 {
|
||||||
|
t.Fatalf("candidate[%d] = %.2f, want primary %.2f first", i, got[i], want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTeaserSegmentFallbackAllowedForBadSegmentOutput(t *testing.T) {
|
||||||
|
for _, err := range []error{
|
||||||
|
errors.New("generated teaser has no video stream"),
|
||||||
|
errors.New("ffmpeg segment: signal: killed, stderr: "),
|
||||||
|
errors.New("ffmpeg segment produced empty file, stderr: "),
|
||||||
|
} {
|
||||||
|
if !teaserSegmentFallbackAllowed(err) {
|
||||||
|
t.Fatalf("teaserSegmentFallbackAllowed(%v) = false, want true", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if teaserSegmentFallbackAllowed(errors.New("server returned 403 forbidden")) {
|
||||||
|
t.Fatal("403 errors should not trigger teaser segment fallback")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTeaserSegmentFallbackRequiresPlannedSegmentCount(t *testing.T) {
|
||||||
|
err := errors.New("only generated 2/4 teaser segments: generated teaser has no video stream")
|
||||||
|
if !strings.Contains(err.Error(), "2/4") {
|
||||||
|
t.Fatalf("error = %v, want generated/planned segment count", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestThumbnailOffsetsUseFiveSecondsWithEarlyFallbacks(t *testing.T) {
|
||||||
|
got := thumbnailOffsets()
|
||||||
|
want := []float64{5, 1, 0}
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("offsets = %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if got[i] != want[i] {
|
||||||
|
t.Fatalf("offset[%d] = %.2f, want %.2f", i, got[i], want[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestThumbnailOffsetFallbackAllowedForEmptyOutputAndTimeouts(t *testing.T) {
|
||||||
|
for _, err := range []error{
|
||||||
|
errors.New("ffmpeg thumb produced empty file, stderr: "),
|
||||||
|
errors.New("ffmpeg thumb: signal: killed, stderr: "),
|
||||||
|
context.DeadlineExceeded,
|
||||||
|
} {
|
||||||
|
if !thumbnailOffsetFallbackAllowed(err) {
|
||||||
|
t.Fatalf("thumbnailOffsetFallbackAllowed(%v) = false, want true", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if thumbnailOffsetFallbackAllowed(errors.New("server returned 403 forbidden")) {
|
||||||
|
t.Fatal("403 errors should not trigger thumbnail offset fallback")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestFFmpeg429OutputBecomesRateLimitError(t *testing.T) {
|
func TestFFmpeg429OutputBecomesRateLimitError(t *testing.T) {
|
||||||
err := ffmpegCommandError("ffmpeg", errors.New("exit status 8"), []byte("Server returned 429 Too Many Requests"))
|
err := ffmpegCommandError("ffmpeg", errors.New("exit status 8"), []byte("Server returned 429 Too Many Requests"))
|
||||||
var rateLimit *drives.RateLimitError
|
var rateLimit *drives.RateLimitError
|
||||||
|
|||||||
@@ -109,6 +109,74 @@ func TestPreviewWorkerGeneratesTeaserWithoutReplacingExistingThumbnail(t *testin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPreviewWorkerDeduplicatesQueuedVideos(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
cat, video := seedPreviewTestVideo(t, "preview-dedupe-video")
|
||||||
|
|
||||||
|
gen := &fakeTeaserGenerator{}
|
||||||
|
drv := &previewFakeDrive{}
|
||||||
|
worker := NewWorker(gen, cat, drv, "")
|
||||||
|
|
||||||
|
if !worker.EnqueueBlocking(ctx, video) {
|
||||||
|
t.Fatal("first enqueue returned false, want true")
|
||||||
|
}
|
||||||
|
if !worker.EnqueueBlocking(ctx, video) {
|
||||||
|
t.Fatal("duplicate enqueue returned false, want idempotent success")
|
||||||
|
}
|
||||||
|
if got := worker.Status().QueueLength; got != 1 {
|
||||||
|
t.Fatalf("queue length = %d, want 1 unique video", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
queued := <-worker.ch
|
||||||
|
if !worker.Enqueue(video) {
|
||||||
|
t.Fatal("enqueue while the same video is reserved returned false, want idempotent success")
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-worker.ch:
|
||||||
|
t.Fatal("duplicate enqueue added another queued video")
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
worker.processQueued(ctx, queued)
|
||||||
|
if !worker.Enqueue(video) {
|
||||||
|
t.Fatal("enqueue after processing returned false, want true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestThumbWorkerDeduplicatesQueuedVideos(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
cat, video := seedPreviewTestVideo(t, "thumb-dedupe-video")
|
||||||
|
|
||||||
|
gen := &fakeThumbGenerator{}
|
||||||
|
drv := &previewFakeDrive{}
|
||||||
|
worker := NewThumbWorker(gen, cat, drv)
|
||||||
|
|
||||||
|
if !worker.Enqueue(video) {
|
||||||
|
t.Fatal("first enqueue returned false, want true")
|
||||||
|
}
|
||||||
|
if !worker.Enqueue(video) {
|
||||||
|
t.Fatal("duplicate enqueue returned false, want idempotent success")
|
||||||
|
}
|
||||||
|
if got := worker.Status().QueueLength; got != 1 {
|
||||||
|
t.Fatalf("queue length = %d, want 1 unique video", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
queued := <-worker.ch
|
||||||
|
if !worker.Enqueue(video) {
|
||||||
|
t.Fatal("enqueue while the same thumbnail is reserved returned false, want idempotent success")
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-worker.ch:
|
||||||
|
t.Fatal("duplicate enqueue added another queued thumbnail")
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
worker.processQueued(ctx, queued)
|
||||||
|
if !worker.Enqueue(video) {
|
||||||
|
t.Fatal("enqueue after release returned false, want true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestPreviewWorkerRemovesPreviousLocalTeaserAfterNewTeaserIsReady(t *testing.T) {
|
func TestPreviewWorkerRemovesPreviousLocalTeaserAfterNewTeaserIsReady(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
cat, video := seedPreviewTestVideo(t, "preview-cleanup-video")
|
cat, video := seedPreviewTestVideo(t, "preview-cleanup-video")
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ import (
|
|||||||
"github.com/video-site/backend/internal/drives"
|
"github.com/video-site/backend/internal/drives"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type streamURLWithHeader interface {
|
||||||
|
StreamURLWithHeader(ctx context.Context, fileID string, header http.Header) (*drives.StreamLink, error)
|
||||||
|
}
|
||||||
|
|
||||||
// Registry 管理多个 Drive 实例
|
// Registry 管理多个 Drive 实例
|
||||||
type Registry struct {
|
type Registry struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
@@ -53,7 +57,7 @@ func (r *Registry) Remove(id string) {
|
|||||||
// Proxy 根据 driveID + fileID 反向代理到真实网盘直链
|
// Proxy 根据 driveID + fileID 反向代理到真实网盘直链
|
||||||
type Proxy struct {
|
type Proxy struct {
|
||||||
Registry *Registry
|
Registry *Registry
|
||||||
// linkCache key: driveID + "/" + fileID,value: cachedLink
|
// linkCache key: driveID + "/" + fileID (+ User-Agent for UA-bound links)
|
||||||
cacheMu sync.Mutex
|
cacheMu sync.Mutex
|
||||||
cache map[string]cachedLink
|
cache map[string]cachedLink
|
||||||
http *http.Client
|
http *http.Client
|
||||||
@@ -74,8 +78,8 @@ func New(r *Registry) *Proxy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Proxy) getLink(ctx context.Context, driveID, fileID string) (*drives.StreamLink, error) {
|
func (p *Proxy) getLink(ctx context.Context, d drives.Drive, driveID, fileID string, header http.Header) (*drives.StreamLink, error) {
|
||||||
key := driveID + "/" + fileID
|
key := linkCacheKey(d, driveID, fileID, header)
|
||||||
|
|
||||||
p.cacheMu.Lock()
|
p.cacheMu.Lock()
|
||||||
if c, ok := p.cache[key]; ok {
|
if c, ok := p.cache[key]; ok {
|
||||||
@@ -87,11 +91,15 @@ func (p *Proxy) getLink(ctx context.Context, driveID, fileID string) (*drives.St
|
|||||||
}
|
}
|
||||||
p.cacheMu.Unlock()
|
p.cacheMu.Unlock()
|
||||||
|
|
||||||
d, ok := p.Registry.Get(driveID)
|
var (
|
||||||
if !ok {
|
link *drives.StreamLink
|
||||||
return nil, errDriveNotFound
|
err error
|
||||||
|
)
|
||||||
|
if h, ok := d.(streamURLWithHeader); ok {
|
||||||
|
link, err = h.StreamURLWithHeader(ctx, fileID, header)
|
||||||
|
} else {
|
||||||
|
link, err = d.StreamURL(ctx, fileID)
|
||||||
}
|
}
|
||||||
link, err := d.StreamURL(ctx, fileID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -101,15 +109,43 @@ func (p *Proxy) getLink(ctx context.Context, driveID, fileID string) (*drives.St
|
|||||||
return link, nil
|
return link, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func linkCacheKey(d drives.Drive, driveID, fileID string, header http.Header) string {
|
||||||
|
key := driveID + "/" + fileID
|
||||||
|
if _, ok := d.(streamURLWithHeader); ok {
|
||||||
|
key += "|ua=" + header.Get("User-Agent")
|
||||||
|
}
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
func (p *Proxy) ServeStream(w http.ResponseWriter, r *http.Request, driveID, fileID string) {
|
func (p *Proxy) ServeStream(w http.ResponseWriter, r *http.Request, driveID, fileID string) {
|
||||||
link, err := p.getLink(r.Context(), driveID, fileID)
|
d, ok := p.Registry.Get(driveID)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, errDriveNotFound.Error(), errDriveNotFound.Code)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
link, err := p.getLink(r.Context(), d, driveID, fileID, r.Header)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusBadGateway)
|
http.Error(w, err.Error(), http.StatusBadGateway)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if shouldRedirect(d) {
|
||||||
|
redirect(w, r, link)
|
||||||
|
return
|
||||||
|
}
|
||||||
p.serve(w, r, link)
|
p.serve(w, r, link)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func shouldRedirect(d drives.Drive) bool {
|
||||||
|
return d.Kind() == "p115"
|
||||||
|
}
|
||||||
|
|
||||||
|
func redirect(w http.ResponseWriter, r *http.Request, link *drives.StreamLink) {
|
||||||
|
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||||
|
w.Header().Set("Cache-Control", "max-age=0, no-cache, no-store, must-revalidate")
|
||||||
|
http.Redirect(w, r, link.URL, http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
func (p *Proxy) serve(w http.ResponseWriter, r *http.Request, link *drives.StreamLink) {
|
func (p *Proxy) serve(w http.ResponseWriter, r *http.Request, link *drives.StreamLink) {
|
||||||
// 构造上游请求
|
// 构造上游请求
|
||||||
u, err := url.Parse(link.URL)
|
u, err := url.Parse(link.URL)
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package proxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/video-site/backend/internal/drives"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestServeStreamRedirectsP115WithRequestUserAgent(t *testing.T) {
|
||||||
|
reg := NewRegistry()
|
||||||
|
drv := &proxyFakeDrive{kind: "p115"}
|
||||||
|
reg.Set("115", drv)
|
||||||
|
|
||||||
|
p := New(reg)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/p/stream/115/file-1", nil)
|
||||||
|
req.Header.Set("User-Agent", "Browser-A")
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
|
||||||
|
p.ServeStream(rr, req, "115", "file-1")
|
||||||
|
|
||||||
|
if rr.Code != http.StatusFound {
|
||||||
|
t.Fatalf("status = %d, want %d", rr.Code, http.StatusFound)
|
||||||
|
}
|
||||||
|
if got := rr.Header().Get("Location"); got != "https://cdn.example/file-1?ua=Browser-A" {
|
||||||
|
t.Fatalf("Location = %q", got)
|
||||||
|
}
|
||||||
|
if got := drv.calls[0].ua; got != "Browser-A" {
|
||||||
|
t.Fatalf("link UA = %q, want request UA", got)
|
||||||
|
}
|
||||||
|
if got := rr.Header().Get("Referrer-Policy"); got != "no-referrer" {
|
||||||
|
t.Fatalf("Referrer-Policy = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServeStreamP115CacheIsUserAgentScoped(t *testing.T) {
|
||||||
|
reg := NewRegistry()
|
||||||
|
drv := &proxyFakeDrive{kind: "p115"}
|
||||||
|
reg.Set("115", drv)
|
||||||
|
|
||||||
|
p := New(reg)
|
||||||
|
|
||||||
|
requestP115(t, p, "115", "file-1", "Browser-A")
|
||||||
|
requestP115(t, p, "115", "file-1", "Browser-B")
|
||||||
|
requestP115(t, p, "115", "file-1", "Browser-A")
|
||||||
|
|
||||||
|
if len(drv.calls) != 2 {
|
||||||
|
t.Fatalf("link calls = %d, want 2", len(drv.calls))
|
||||||
|
}
|
||||||
|
if drv.calls[0].ua != "Browser-A" || drv.calls[1].ua != "Browser-B" {
|
||||||
|
t.Fatalf("link UAs = %#v", drv.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestP115(t *testing.T, p *Proxy, driveID, fileID, ua string) {
|
||||||
|
t.Helper()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/p/stream/"+driveID+"/"+fileID, nil)
|
||||||
|
req.Header.Set("User-Agent", ua)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
p.ServeStream(rr, req, driveID, fileID)
|
||||||
|
if rr.Code != http.StatusFound {
|
||||||
|
t.Fatalf("status = %d, want %d", rr.Code, http.StatusFound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type proxyFakeDrive struct {
|
||||||
|
kind string
|
||||||
|
calls []proxyFakeCall
|
||||||
|
}
|
||||||
|
|
||||||
|
type proxyFakeCall struct {
|
||||||
|
fileID string
|
||||||
|
ua string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *proxyFakeDrive) Kind() string { return d.kind }
|
||||||
|
func (d *proxyFakeDrive) ID() string { return "fake" }
|
||||||
|
func (d *proxyFakeDrive) Init(context.Context) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (d *proxyFakeDrive) List(context.Context, string) ([]drives.Entry, error) {
|
||||||
|
return nil, drives.ErrNotSupported
|
||||||
|
}
|
||||||
|
func (d *proxyFakeDrive) Stat(context.Context, string) (*drives.Entry, error) {
|
||||||
|
return nil, drives.ErrNotSupported
|
||||||
|
}
|
||||||
|
func (d *proxyFakeDrive) StreamURL(ctx context.Context, fileID string) (*drives.StreamLink, error) {
|
||||||
|
return d.StreamURLWithHeader(ctx, fileID, nil)
|
||||||
|
}
|
||||||
|
func (d *proxyFakeDrive) StreamURLWithHeader(_ context.Context, fileID string, header http.Header) (*drives.StreamLink, error) {
|
||||||
|
ua := header.Get("User-Agent")
|
||||||
|
d.calls = append(d.calls, proxyFakeCall{fileID: fileID, ua: ua})
|
||||||
|
return &drives.StreamLink{
|
||||||
|
URL: "https://cdn.example/" + fileID + "?ua=" + ua,
|
||||||
|
Headers: http.Header{"User-Agent": {ua}},
|
||||||
|
Expires: time.Now().Add(time.Minute),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
func (d *proxyFakeDrive) Upload(context.Context, string, string, io.Reader, int64) (string, error) {
|
||||||
|
return "", drives.ErrNotSupported
|
||||||
|
}
|
||||||
|
func (d *proxyFakeDrive) EnsureDir(context.Context, string) (string, error) {
|
||||||
|
return "", drives.ErrNotSupported
|
||||||
|
}
|
||||||
|
func (d *proxyFakeDrive) RootID() string { return "0" }
|
||||||
Generated
+1773
File diff suppressed because it is too large
Load Diff
+12
-11
@@ -5,20 +5,25 @@ import { SearchPanel } from "@/components/SearchPanel";
|
|||||||
import { TagCloud } from "@/components/TagCloud";
|
import { TagCloud } from "@/components/TagCloud";
|
||||||
import { SectionHeader } from "@/components/SectionHeader";
|
import { SectionHeader } from "@/components/SectionHeader";
|
||||||
import { VideoGrid } from "@/components/VideoGrid";
|
import { VideoGrid } from "@/components/VideoGrid";
|
||||||
import { fetchHomeVideos } from "@/data/videos";
|
import { fetchHomeVideos, fetchListing } from "@/data/videos";
|
||||||
import type { VideoItem } from "@/types";
|
import type { VideoItem } from "@/types";
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const [videos, setVideos] = useState<VideoItem[]>([]);
|
const [rankingVideos, setRankingVideos] = useState<VideoItem[]>([]);
|
||||||
|
const [latestVideos, setLatestVideos] = useState<VideoItem[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.title = "首页 · 视频聚合站";
|
document.title = "首页 · 视频聚合站";
|
||||||
let active = true;
|
let active = true;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
fetchHomeVideos().then((items) => {
|
Promise.all([
|
||||||
|
fetchHomeVideos(),
|
||||||
|
fetchListing(1, 10, { sort: "latest" }),
|
||||||
|
]).then(([rankingItems, latestResult]) => {
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
setVideos(items);
|
setRankingVideos(rankingItems);
|
||||||
|
setLatestVideos(latestResult.items);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
@@ -35,17 +40,13 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="container page-section">
|
<div className="container page-section">
|
||||||
<SectionHeader title="今日排行" extra={`精选 ${videos.length} 个作品`} />
|
<SectionHeader title="今日排行" extra={`精选 ${rankingVideos.length} 个作品`} />
|
||||||
<VideoGrid videos={videos} loading={loading} skeletonCount={12} />
|
<VideoGrid videos={rankingVideos} loading={loading} skeletonCount={12} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="container page-section">
|
<div className="container page-section">
|
||||||
<SectionHeader title="最新视频" />
|
<SectionHeader title="最新视频" />
|
||||||
<VideoGrid
|
<VideoGrid videos={latestVideos} loading={loading} skeletonCount={12} />
|
||||||
videos={loading ? [] : videos.slice().reverse()}
|
|
||||||
loading={loading}
|
|
||||||
skeletonCount={12}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ set -euo pipefail
|
|||||||
|
|
||||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|
||||||
|
export HOME="${HOME:-/root}"
|
||||||
|
export XDG_CACHE_HOME="${XDG_CACHE_HOME:-$HOME/.cache}"
|
||||||
|
export GOCACHE="${GOCACHE:-/tmp/video-site-91/go-build}"
|
||||||
|
|
||||||
FRONTEND_HOST="${FRONTEND_HOST:-0.0.0.0}"
|
FRONTEND_HOST="${FRONTEND_HOST:-0.0.0.0}"
|
||||||
FRONTEND_PORT="${FRONTEND_PORT:-9191}"
|
FRONTEND_PORT="${FRONTEND_PORT:-9191}"
|
||||||
FRONTEND_MODE="${FRONTEND_MODE:-preview}"
|
FRONTEND_MODE="${FRONTEND_MODE:-preview}"
|
||||||
@@ -101,7 +105,7 @@ start_backend() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
need_cmd go
|
need_cmd go
|
||||||
mkdir -p "$LOG_DIR"
|
mkdir -p "$LOG_DIR" "$GOCACHE"
|
||||||
echo "starting backend on 127.0.0.1:$BACKEND_PORT"
|
echo "starting backend on 127.0.0.1:$BACKEND_PORT"
|
||||||
(
|
(
|
||||||
cd "$ROOT_DIR/backend"
|
cd "$ROOT_DIR/backend"
|
||||||
|
|||||||
Reference in New Issue
Block a user