diff --git a/README.md b/README.md index 4b29b81..b370793 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,7 @@ git add vendor/ # 入库 - 取流优先使用移动端下载接口,失败再回退到原 chrome 下载接口。 - 生成 teaser 时不再让 ffmpeg 同时打开多个 115 直链;每个 3 秒片段会单独取链、单独生成本地小片段,最后在本地 concat。 - ffmpeg 访问 115 CDN 时会经过进程内本地代理转发 Range 请求,避免直接暴露签名 URL,并统一处理必要请求头。 -- 如果 115 返回 403 / 405 / WAF 阻断 / `moov atom not found` / `partial file` 等疑似临时风控错误,当前网盘的 teaser worker 会进入默认 30 分钟冷却,当前任务保持 `pending`,避免继续请求导致更多失败。 +- 如果 115 返回 403 / 405 / WAF 阻断 / `moov atom not found` / `partial file` 等疑似临时风控错误,当前网盘的封面/teaser worker 会进入默认 5 分钟冷却,当前任务保持 `pending`,避免继续请求导致更多失败。 管理后台的“重生失败 teaser”会把 `failed` 重置为 `pending` 并入队。一次性重生大量 115 视频仍可能触发上游风控;建议点一次后观察日志,如果出现 `transient media source error until=...`,等待冷却结束再继续,不要反复点击。 @@ -134,11 +134,12 @@ OneDrive 当前采用 OpenList 在线 API 的续期方式,不要求用户提 ## Teaser 和封面生成策略 -- 封面:根据视频时长从 20% 或 30% 位置抽一帧 jpg +- 封面:固定从第 5 秒抽一帧 jpg,不再为封面单独探测视频时长 - Teaser:每段固定 3 秒;30 秒以下最多 3 段,30 秒及以上固定 4 段;长视频在 20% 到 80% 区间均匀取段 - 生成的封面和 teaser 都只保存在本地 `backend/data/previews/`,不会回写到网盘;旧数据中的 `preview_file_id` 会被忽略 - 极短视频会按可容纳的完整 3 秒片段数自动降级 - 首次失败的任务标 `preview_status = failed`,不再自动重试;管理后台可手动重新生成 +- 封面或 teaser 生成遇到明确频率限制(如 429)时,对应 worker 固定冷却 5 分钟。 - 服务启动或网盘重新挂载时,如果 Teaser 开关已开启,会自动把历史 `pending` 任务重新入队,避免重启后停在“待生成”。 - 115 使用顺序分段生成:每段独立取链、独立转码,最后本地拼接,避免同一 115 CDN 链接被多输入并发读取。 - OneDrive 直链生成 teaser 时可能触发 Microsoft 429 限流;后端会识别这类错误并让当前网盘进入冷却期,保留任务为 `pending`,避免连续请求触发更严重限流。 diff --git a/backend/internal/api/admin.go b/backend/internal/api/admin.go index 87a571d..3a44bac 100644 --- a/backend/internal/api/admin.go +++ b/backend/internal/api/admin.go @@ -127,6 +127,11 @@ func (a *AdminServer) handleListDrives(w http.ResponseWriter, r *http.Request) { writeErr(w, http.StatusInternalServerError, err) return } + thumbnailCounts, err := a.Catalog.CountThumbnailsByDrive(r.Context()) + if err != nil { + writeErr(w, http.StatusInternalServerError, err) + return + } generationStatuses := map[string]DriveGenerationStatuses{} if a.GetDriveGenerationStatuses != nil { generationStatuses = a.GetDriveGenerationStatuses() @@ -143,6 +148,9 @@ func (a *AdminServer) handleListDrives(w http.ResponseWriter, r *http.Request) { HasCredential bool `json:"hasCredential"` ThumbnailGenerationStatus GenerationStatus `json:"thumbnailGenerationStatus"` PreviewGenerationStatus GenerationStatus `json:"previewGenerationStatus"` + ThumbnailReadyCount int `json:"thumbnailReadyCount"` + ThumbnailPendingCount int `json:"thumbnailPendingCount"` + ThumbnailFailedCount int `json:"thumbnailFailedCount"` TeaserReadyCount int `json:"teaserReadyCount"` TeaserPendingCount int `json:"teaserPendingCount"` TeaserFailedCount int `json:"teaserFailedCount"` @@ -150,6 +158,7 @@ func (a *AdminServer) handleListDrives(w http.ResponseWriter, r *http.Request) { list := make([]out, 0, len(drives)) for _, d := range drives { counts := teaserCounts[d.ID] + thumbCounts := thumbnailCounts[d.ID] generation := generationStatuses[d.ID] if generation.Thumbnail.State == "" { generation.Thumbnail.State = "idle" @@ -164,6 +173,9 @@ func (a *AdminServer) handleListDrives(w http.ResponseWriter, r *http.Request) { HasCredential: len(d.Credentials) > 0, ThumbnailGenerationStatus: generation.Thumbnail, PreviewGenerationStatus: generation.Preview, + ThumbnailReadyCount: thumbCounts.Ready, + ThumbnailPendingCount: thumbCounts.Pending, + ThumbnailFailedCount: thumbCounts.Failed, TeaserReadyCount: counts.Ready, TeaserPendingCount: counts.Pending, TeaserFailedCount: counts.Failed, diff --git a/backend/internal/api/admin_test.go b/backend/internal/api/admin_test.go index 06ae34f..9d02d3d 100644 --- a/backend/internal/api/admin_test.go +++ b/backend/internal/api/admin_test.go @@ -146,17 +146,20 @@ func TestHandleListDrivesIncludesTeaserCounts(t *testing.T) { now := time.Now() videos := []*catalog.Video{ - {ID: "od-ready-1", DriveID: "OneDrive", FileID: "od-file-1", Title: "OD Ready 1", PreviewStatus: "ready", PublishedAt: now, CreatedAt: now, UpdatedAt: now}, + {ID: "od-ready-1", DriveID: "OneDrive", FileID: "od-file-1", Title: "OD Ready 1", ThumbnailURL: "/p/thumb/od-ready-1", PreviewStatus: "ready", PublishedAt: now, CreatedAt: now, UpdatedAt: now}, {ID: "od-ready-2", DriveID: "OneDrive", FileID: "od-file-2", Title: "OD Ready 2", PreviewStatus: "ready", PublishedAt: now, CreatedAt: now, UpdatedAt: now}, {ID: "od-pending", DriveID: "OneDrive", FileID: "od-file-3", Title: "OD Pending", PreviewStatus: "pending", PublishedAt: now, CreatedAt: now, UpdatedAt: now}, {ID: "pp-pending", DriveID: "PikPak", FileID: "pp-file-1", Title: "PP Pending", PreviewStatus: "pending", PublishedAt: now, CreatedAt: now, UpdatedAt: now}, - {ID: "pp-failed", DriveID: "PikPak", FileID: "pp-file-2", Title: "PP Failed", PreviewStatus: "failed", PublishedAt: now, CreatedAt: now, UpdatedAt: now}, + {ID: "pp-failed", DriveID: "PikPak", FileID: "pp-file-2", Title: "PP Failed", ThumbnailURL: "/p/thumb/pp-failed", PreviewStatus: "failed", PublishedAt: now, CreatedAt: now, UpdatedAt: now}, } for _, v := range videos { if err := cat.UpsertVideo(ctx, v); err != nil { t.Fatalf("seed video %s: %v", v.ID, err) } } + if err := cat.UpdateVideoMeta(ctx, "od-ready-2", catalog.VideoMetaPatch{ThumbnailStatus: "failed"}); err != nil { + t.Fatalf("mark thumbnail failed: %v", err) + } req := httptest.NewRequest(http.MethodGet, "/admin/api/drives", nil) rr := httptest.NewRecorder() @@ -179,6 +182,9 @@ func TestHandleListDrivesIncludesTeaserCounts(t *testing.T) { ID string `json:"id"` ThumbnailGenerationStatus GenerationStatus `json:"thumbnailGenerationStatus"` PreviewGenerationStatus GenerationStatus `json:"previewGenerationStatus"` + ThumbnailReadyCount int `json:"thumbnailReadyCount"` + ThumbnailPendingCount int `json:"thumbnailPendingCount"` + ThumbnailFailedCount int `json:"thumbnailFailedCount"` TeaserReadyCount int `json:"teaserReadyCount"` TeaserPendingCount int `json:"teaserPendingCount"` TeaserFailedCount int `json:"teaserFailedCount"` @@ -187,36 +193,51 @@ func TestHandleListDrivesIncludesTeaserCounts(t *testing.T) { t.Fatalf("decode: %v", err) } byID := map[string]struct { - Ready int - Pending int - Failed int - Thumbnail GenerationStatus - Preview GenerationStatus + TeaserReady int + TeaserPending int + TeaserFailed int + ThumbnailReady int + ThumbnailPending int + ThumbnailFailed int + Thumbnail GenerationStatus + Preview GenerationStatus }{} for _, d := range got { byID[d.ID] = struct { - Ready int - Pending int - Failed int - Thumbnail GenerationStatus - Preview GenerationStatus + TeaserReady int + TeaserPending int + TeaserFailed int + ThumbnailReady int + ThumbnailPending int + ThumbnailFailed int + Thumbnail GenerationStatus + Preview GenerationStatus }{ - Ready: d.TeaserReadyCount, - Pending: d.TeaserPendingCount, - Failed: d.TeaserFailedCount, - Thumbnail: d.ThumbnailGenerationStatus, - Preview: d.PreviewGenerationStatus, + TeaserReady: d.TeaserReadyCount, + TeaserPending: d.TeaserPendingCount, + TeaserFailed: d.TeaserFailedCount, + ThumbnailReady: d.ThumbnailReadyCount, + ThumbnailPending: d.ThumbnailPendingCount, + ThumbnailFailed: d.ThumbnailFailedCount, + Thumbnail: d.ThumbnailGenerationStatus, + Preview: d.PreviewGenerationStatus, } } - if byID["OneDrive"].Ready != 2 || byID["OneDrive"].Pending != 1 || byID["OneDrive"].Failed != 0 { + if byID["OneDrive"].TeaserReady != 2 || byID["OneDrive"].TeaserPending != 1 || byID["OneDrive"].TeaserFailed != 0 { t.Fatalf("OneDrive counts = %#v, want ready=2 pending=1 failed=0", byID["OneDrive"]) } + if byID["OneDrive"].ThumbnailReady != 1 || byID["OneDrive"].ThumbnailPending != 1 || byID["OneDrive"].ThumbnailFailed != 1 { + t.Fatalf("OneDrive thumbnail counts = %#v, want ready=1 pending=1 failed=1", byID["OneDrive"]) + } if byID["OneDrive"].Thumbnail.State != "cooling" || byID["OneDrive"].Preview.State != "generating" { t.Fatalf("OneDrive generation statuses = %#v, want thumbnail cooling and preview generating", byID["OneDrive"]) } - if byID["PikPak"].Ready != 0 || byID["PikPak"].Pending != 1 || byID["PikPak"].Failed != 1 { + if byID["PikPak"].TeaserReady != 0 || byID["PikPak"].TeaserPending != 1 || byID["PikPak"].TeaserFailed != 1 { t.Fatalf("PikPak counts = %#v, want ready=0 pending=1 failed=1", byID["PikPak"]) } + if byID["PikPak"].ThumbnailReady != 1 || byID["PikPak"].ThumbnailPending != 1 || byID["PikPak"].ThumbnailFailed != 0 { + t.Fatalf("PikPak thumbnail counts = %#v, want ready=1 pending=1 failed=0", byID["PikPak"]) + } if byID["PikPak"].Thumbnail.State != "idle" || byID["PikPak"].Preview.State != "idle" { t.Fatalf("PikPak generation statuses = %#v, want idle defaults", byID["PikPak"]) } diff --git a/backend/internal/catalog/catalog.go b/backend/internal/catalog/catalog.go index 8e9375f..cfc9100 100644 --- a/backend/internal/catalog/catalog.go +++ b/backend/internal/catalog/catalog.go @@ -187,6 +187,7 @@ func (c *Catalog) IncrementLike(ctx context.Context, id string) (int, error) { // VideoMetaPatch 轻量更新视频元数据(仅非零值字段会被写入) type VideoMetaPatch struct { ThumbnailURL string + ThumbnailStatus string DurationSeconds int Category string ContentHash string @@ -202,6 +203,10 @@ func (c *Catalog) UpdateVideoMeta(ctx context.Context, id string, p VideoMetaPat parts = append(parts, "thumbnail_url = ?") args = append(args, p.ThumbnailURL) } + if p.ThumbnailStatus != "" { + parts = append(parts, "thumbnail_status = ?") + args = append(args, nullableStatus(p.ThumbnailStatus)) + } if p.DurationSeconds > 0 { parts = append(parts, "duration_seconds = ?") args = append(args, p.DurationSeconds) @@ -523,6 +528,12 @@ type DriveTeaserCounts struct { Failed int } +type DriveThumbnailCounts struct { + Ready int + Pending int + Failed int +} + func (c *Catalog) CountTeasersByDrive(ctx context.Context) (map[string]DriveTeaserCounts, error) { rows, err := c.db.QueryContext(ctx, `SELECT drive_id, @@ -553,6 +564,38 @@ func (c *Catalog) CountTeasersByDrive(ctx context.Context) (map[string]DriveTeas return out, nil } +func (c *Catalog) CountThumbnailsByDrive(ctx context.Context) (map[string]DriveThumbnailCounts, error) { + rows, err := c.db.QueryContext(ctx, + `SELECT drive_id, + COUNT(CASE WHEN COALESCE(thumbnail_url, '') != '' THEN 1 END) AS ready_count, + COUNT(CASE WHEN COALESCE(thumbnail_url, '') = '' + AND COALESCE(thumbnail_status, 'pending') != 'failed' THEN 1 END) AS pending_count, + COUNT(CASE WHEN COALESCE(thumbnail_url, '') = '' + AND COALESCE(thumbnail_status, 'pending') = 'failed' THEN 1 END) AS failed_count + FROM videos + WHERE COALESCE(hidden, 0) = 0 + AND `+uniqueVideoWhereSQL+` + GROUP BY drive_id`) + if err != nil { + return nil, err + } + defer rows.Close() + + out := make(map[string]DriveThumbnailCounts) + for rows.Next() { + var driveID string + var counts DriveThumbnailCounts + if err := rows.Scan(&driveID, &counts.Ready, &counts.Pending, &counts.Failed); err != nil { + return nil, err + } + out[driveID] = counts + } + if err := rows.Err(); err != nil { + return nil, err + } + return out, nil +} + type LocalMediaRef struct { DriveID string VideoID string diff --git a/backend/internal/catalog/schema.sql b/backend/internal/catalog/schema.sql index 7d5ce69..1019898 100644 --- a/backend/internal/catalog/schema.sql +++ b/backend/internal/catalog/schema.sql @@ -14,6 +14,7 @@ CREATE TABLE IF NOT EXISTS videos ( ext TEXT, quality TEXT, -- HD / SD thumbnail_url TEXT, + thumbnail_status TEXT DEFAULT 'pending', -- pending / ready / failed preview_file_id TEXT, -- deprecated: 旧版回写网盘后的 teaser file id preview_local TEXT, -- 本地 teaser 路径(兜底) preview_status TEXT DEFAULT 'pending', -- pending / ready / failed diff --git a/backend/internal/catalog/tags.go b/backend/internal/catalog/tags.go index 4ccc7f6..282b3f8 100644 --- a/backend/internal/catalog/tags.go +++ b/backend/internal/catalog/tags.go @@ -48,6 +48,9 @@ func (c *Catalog) migrate(ctx context.Context) error { if err := c.addColumnIfMissing(ctx, "videos", "hidden", "INTEGER DEFAULT 0"); err != nil { return err } + if err := c.addColumnIfMissing(ctx, "videos", "thumbnail_status", "TEXT DEFAULT 'pending'"); err != nil { + return err + } if _, err := c.db.ExecContext(ctx, `CREATE INDEX IF NOT EXISTS idx_videos_content_hash ON videos(content_hash)`); err != nil { return err } diff --git a/backend/internal/drives/p115/driver.go b/backend/internal/drives/p115/driver.go index ef9164c..1b991bc 100644 --- a/backend/internal/drives/p115/driver.go +++ b/backend/internal/drives/p115/driver.go @@ -85,7 +85,7 @@ func (d *Driver) listWithRetry(ctx context.Context, dirID string) (*[]sdk.File, d.listMu.Lock() defer d.listMu.Unlock() - cooldowns := []time.Duration{30 * time.Minute, 60 * time.Minute, 120 * time.Minute} + cooldowns := []time.Duration{30 * time.Minute, 30 * time.Minute, 30 * time.Minute} var lastErr error for attempt := 0; ; attempt++ { if err := d.waitForListSlotLocked(ctx); err != nil { diff --git a/backend/internal/preview/ffmpeg.go b/backend/internal/preview/ffmpeg.go index a466028..9247649 100644 --- a/backend/internal/preview/ffmpeg.go +++ b/backend/internal/preview/ffmpeg.go @@ -195,27 +195,20 @@ func pickSegmentStarts(duration float64, n int, eachSec float64) []float64 { } // pickThumbnailOffset 选封面抽帧的时间点(秒)。独立于 teaser。 -func pickThumbnailOffset(duration float64) float64 { - if duration <= 0 { - return 5 - } - // 短视频从 30% 抽;长视频从 20% 抽,避开片头 - if duration < 60 { - return math.Max(1, duration*0.3) - } - return math.Max(5, math.Min(duration*0.2, 120)) +func pickThumbnailOffset() float64 { + return 5 } // --- 封面 --- -// GenerateThumbnail 抽一张 jpg 封面。偏移点由 duration 决定(独立于 teaser)。 +// GenerateThumbnail 抽一张 jpg 封面。封面统一从第 5 秒抽帧,避免为封面单独探时长。 func (g *Generator) GenerateThumbnail(ctx context.Context, link *drives.StreamLink, videoID string, duration float64) (string, error) { dir := filepath.Join(g.cfg.LocalDir, "thumbs") if err := os.MkdirAll(dir, 0o755); err != nil { return "", err } dst := filepath.Join(dir, videoID+".jpg") - offset := pickThumbnailOffset(duration) + offset := pickThumbnailOffset() ctx2, cancel := context.WithTimeout(ctx, 60*time.Second) defer cancel() @@ -897,9 +890,10 @@ type ThumbWorker struct { } const ( - defaultRateLimitCooldown = 30 * time.Minute - maxPreviewTeaserSizeBytes int64 = 5 * 1024 * 1024 * 1024 - previewStatusSkipped = "skipped" + defaultTransientMediaCooldown = 5 * time.Minute + defaultGenerationRateLimitCooldown = 5 * time.Minute + maxPreviewTeaserSizeBytes int64 = 5 * 1024 * 1024 * 1024 + previewStatusSkipped = "skipped" ) type rateLimitState struct { @@ -961,7 +955,7 @@ func (s *rateLimitState) active(now time.Time) (time.Time, bool, bool) { func (s *rateLimitState) pause(now time.Time, d time.Duration) time.Time { if d <= 0 { - d = defaultRateLimitCooldown + d = defaultTransientMediaCooldown } until := now.Add(d) s.mu.Lock() @@ -1149,14 +1143,11 @@ func (w *Worker) skipIfRateLimited(v *catalog.Video) bool { } func (w *Worker) pauseForRateLimit(err error, step, title string) bool { - retryAfter, ok := drives.RateLimitRetryAfter(err) + _, ok := drives.RateLimitRetryAfter(err) if !ok { return false } - if retryAfter <= 0 { - retryAfter = w.RateLimitCooldown - } - until := w.rateLimit.pause(time.Now(), retryAfter) + until := w.rateLimit.pause(time.Now(), defaultGenerationRateLimitCooldown) log.Printf("[preview] drive=%s rate-limited until=%s step=%s video=%s: %v", w.Drive.ID(), until.Format(time.RFC3339), step, title, err) return true } @@ -1185,14 +1176,11 @@ func (w *ThumbWorker) skipIfRateLimited(v *catalog.Video) bool { } func (w *ThumbWorker) pauseForRateLimit(err error, step, title string) bool { - retryAfter, ok := drives.RateLimitRetryAfter(err) + _, ok := drives.RateLimitRetryAfter(err) if !ok { return false } - if retryAfter <= 0 { - retryAfter = w.RateLimitCooldown - } - until := w.rateLimit.pause(time.Now(), retryAfter) + until := w.rateLimit.pause(time.Now(), defaultGenerationRateLimitCooldown) log.Printf("[thumb] drive=%s rate-limited until=%s step=%s video=%s: %v", w.Drive.ID(), until.Format(time.RFC3339), step, title, err) return true } @@ -1231,9 +1219,11 @@ func (w *ThumbWorker) process(ctx context.Context, v *catalog.Video) { } if current, err := w.Catalog.GetVideo(ctx, v.ID); err == nil { if current.ThumbnailURL != "" { + _ = w.Catalog.UpdateVideoMeta(ctx, v.ID, catalog.VideoMetaPatch{ThumbnailStatus: "ready"}) return } } + _ = w.Catalog.UpdateVideoMeta(ctx, v.ID, catalog.VideoMetaPatch{ThumbnailStatus: "pending"}) link, err := w.Drive.StreamURL(ctx, v.FileID) if err != nil { if localLink, ok := localPreviewLink(v); ok { @@ -1243,6 +1233,7 @@ func (w *ThumbWorker) process(ctx context.Context, v *catalog.Video) { return } log.Printf("[thumb] streamURL %s: %v", v.Title, err) + _ = w.Catalog.UpdateVideoMeta(ctx, v.ID, catalog.VideoMetaPatch{ThumbnailStatus: "failed"}) return } } @@ -1257,40 +1248,23 @@ func (w *ThumbWorker) process(ctx context.Context, v *catalog.Video) { return } log.Printf("[thumb] generate %s: %v", v.Title, err) + _ = w.Catalog.UpdateVideoMeta(ctx, v.ID, catalog.VideoMetaPatch{ThumbnailStatus: "failed"}) return } } func (w *ThumbWorker) generateThumbnailFromLink(ctx context.Context, v *catalog.Video, link *drives.StreamLink) error { - duration := thumbnailDurationHint(v, link) - if duration <= 0 { - if dur, err := w.Gen.Probe(ctx, link); err == nil && dur > 0 { - duration = dur - _ = w.Catalog.UpdateVideoMeta(ctx, v.ID, catalog.VideoMetaPatch{ - DurationSeconds: int(dur), - }) - } else if err != nil { - return err - } - } - - if _, err := w.Gen.GenerateThumbnail(ctx, link, v.ID, duration); err != nil { + if _, err := w.Gen.GenerateThumbnail(ctx, link, v.ID, 0); err != nil { return err } _ = w.Catalog.UpdateVideoMeta(ctx, v.ID, catalog.VideoMetaPatch{ - ThumbnailURL: "/p/thumb/" + v.ID, + ThumbnailURL: "/p/thumb/" + v.ID, + ThumbnailStatus: "ready", }) log.Printf("[thumb] ready %s", v.Title) return nil } -func thumbnailDurationHint(v *catalog.Video, link *drives.StreamLink) float64 { - if link != nil && v.PreviewLocal != "" && filepath.Clean(link.URL) == filepath.Clean(v.PreviewLocal) { - return 0 - } - return float64(v.DurationSeconds) -} - func localPreviewLink(v *catalog.Video) (*drives.StreamLink, bool) { if v.PreviewLocal == "" { return nil, false diff --git a/backend/internal/preview/worker_test.go b/backend/internal/preview/worker_test.go index 253c9a7..7336247 100644 --- a/backend/internal/preview/worker_test.go +++ b/backend/internal/preview/worker_test.go @@ -33,14 +33,17 @@ func TestThumbWorkerUpdatesThumbnailWithoutChangingPreviewStatus(t *testing.T) { if got.PreviewStatus != "pending" { t.Fatalf("preview status = %q, want pending", got.PreviewStatus) } - if got.DurationSeconds != 42 { - t.Fatalf("duration = %d, want probed duration", got.DurationSeconds) + if got.DurationSeconds != 0 { + t.Fatalf("duration = %d, want unchanged", got.DurationSeconds) } if gen.thumbnailVideoID != video.ID { t.Fatalf("thumbnail video id = %q, want %q", gen.thumbnailVideoID, video.ID) } - if gen.thumbnailDuration != 42 { - t.Fatalf("thumbnail duration = %.1f, want 42", gen.thumbnailDuration) + if gen.thumbnailDuration != 0 { + t.Fatalf("thumbnail duration = %.1f, want fixed-offset thumbnail generation", gen.thumbnailDuration) + } + if gen.probeCalls != 0 { + t.Fatalf("probe calls = %d, want 0 for thumbnail generation", gen.probeCalls) } if drv.streamFileID != video.FileID { t.Fatalf("stream file id = %q, want %q", drv.streamFileID, video.FileID) @@ -251,6 +254,7 @@ func TestPreviewWorkerRateLimitLeavesCurrentPendingAndSkipsNextVideo(t *testing. drv := &previewFakeDrive{} worker := NewWorker(gen, cat, drv, "") + before := time.Now() worker.process(ctx, first) gotFirst, err := cat.GetVideo(ctx, first.ID) if err != nil { @@ -262,6 +266,7 @@ func TestPreviewWorkerRateLimitLeavesCurrentPendingAndSkipsNextVideo(t *testing. if gen.generateCalls != 1 { t.Fatalf("generate calls = %d, want 1", gen.generateCalls) } + assertCooldownAround(t, worker.Status().CooldownUntil, before, 5*time.Minute) gen.generateErr = nil worker.process(ctx, &second) @@ -277,6 +282,33 @@ func TestPreviewWorkerRateLimitLeavesCurrentPendingAndSkipsNextVideo(t *testing. } } +func TestThumbWorkerRateLimitCoolsDownFiveMinutes(t *testing.T) { + ctx := context.Background() + cat, video := seedPreviewTestVideo(t, "thumb-rate-limit") + + gen := &fakeThumbGenerator{ + generateErr: &drives.RateLimitError{ + Provider: "media source", + RetryAfter: 2 * time.Hour, + Err: errors.New("429 Too Many Requests"), + }, + } + drv := &previewFakeDrive{} + worker := NewThumbWorker(gen, cat, drv) + + before := time.Now() + worker.process(ctx, video) + + got, err := cat.GetVideo(ctx, video.ID) + if err != nil { + t.Fatalf("get video: %v", err) + } + if got.ThumbnailURL != "" { + t.Fatalf("thumbnail = %q, want unchanged after rate limit", got.ThumbnailURL) + } + assertCooldownAround(t, worker.Status().CooldownUntil, before, 5*time.Minute) +} + func TestPreviewWorkerP115TransientErrorKeepsVideoPending(t *testing.T) { ctx := context.Background() cat, video := seedPreviewTestVideo(t, "preview-p115-transient") @@ -301,6 +333,18 @@ func TestPreviewWorkerP115TransientErrorKeepsVideoPending(t *testing.T) { } } +func assertCooldownAround(t *testing.T, until time.Time, before time.Time, want time.Duration) { + t.Helper() + if until.IsZero() { + t.Fatal("cooldown is zero, want active cooldown") + } + min := before.Add(want - time.Second) + max := time.Now().Add(want + time.Second) + if until.Before(min) || until.After(max) { + t.Fatalf("cooldown until = %s, want around %s from now", until.Format(time.RFC3339Nano), want) + } +} + func TestPreviewWorkerRefreshesP115LinksPerTeaserInput(t *testing.T) { ctx := context.Background() cat, video := seedPreviewTestVideo(t, "preview-p115-refresh") @@ -356,9 +400,12 @@ type fakeThumbGenerator struct { thumbnailVideoID string thumbnailDuration float64 thumbnailURL string + probeCalls int + generateErr error } func (g *fakeThumbGenerator) Probe(context.Context, *drives.StreamLink) (float64, error) { + g.probeCalls++ return 42, nil } @@ -368,6 +415,9 @@ func (g *fakeThumbGenerator) GenerateThumbnail(_ context.Context, link *drives.S if link != nil { g.thumbnailURL = link.URL } + if g.generateErr != nil { + return "", g.generateErr + } return "/tmp/" + videoID + ".jpg", nil } diff --git a/src/admin/DrivesPage.tsx b/src/admin/DrivesPage.tsx index 6f28481..d1eb44f 100644 --- a/src/admin/DrivesPage.tsx +++ b/src/admin/DrivesPage.tsx @@ -184,6 +184,7 @@ export function DrivesPage() {