Improve admin generation status and cooldowns

This commit is contained in:
Codex
2026-05-17 13:43:42 +08:00
parent 7057927f1d
commit 658a7ac086
12 changed files with 260 additions and 82 deletions
+3 -2
View File
@@ -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`,避免连续请求触发更严重限流。
+12
View File
@@ -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,
+40 -19
View File
@@ -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"])
}
+43
View File
@@ -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
+1
View File
@@ -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
+3
View File
@@ -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
}
+1 -1
View File
@@ -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 {
+20 -46
View File
@@ -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
+54 -4
View File
@@ -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
}
+26 -6
View File
@@ -184,6 +184,7 @@ export function DrivesPage() {
<th></th>
<th></th>
<th></th>
<th></th>
<th>Teaser</th>
<th className="is-actions"></th>
</tr>
@@ -206,8 +207,19 @@ export function DrivesPage() {
<td data-label="本地占用">
<StorageCell usage={storage?.drives[d.id]} />
</td>
<td data-label="封面">
<GenerationCounts
ready={d.thumbnailReadyCount}
pending={d.thumbnailPendingCount}
failed={d.thumbnailFailedCount}
/>
</td>
<td data-label="Teaser">
<TeaserCounts drive={d} />
<GenerationCounts
ready={d.teaserReadyCount}
pending={d.teaserPendingCount}
failed={d.teaserFailedCount}
/>
</td>
<td className="is-actions" data-label="操作">
<button className="admin-btn" onClick={() => handleRescan(d)}>
@@ -297,17 +309,25 @@ function StorageCell({ usage }: { usage?: api.DriveStorageUsage }) {
);
}
function TeaserCounts({ drive }: { drive: api.AdminDrive }) {
function GenerationCounts({
ready,
pending,
failed,
}: {
ready?: number;
pending?: number;
failed?: number;
}) {
return (
<div className="admin-teaser-counts">
<div className="admin-generation-counts">
<span className="admin-drive-teaser__metric is-ready">
{drive.teaserReadyCount ?? 0}
{ready ?? 0}
</span>
<span className="admin-drive-teaser__metric is-pending">
{drive.teaserPendingCount ?? 0}
{pending ?? 0}
</span>
<span className="admin-drive-teaser__metric is-failed">
{drive.teaserFailedCount ?? 0}
{failed ?? 0}
</span>
</div>
);
+3
View File
@@ -63,6 +63,9 @@ export type AdminDrive = {
hasCredential: boolean;
thumbnailGenerationStatus?: DriveGenerationStatus;
previewGenerationStatus?: DriveGenerationStatus;
thumbnailReadyCount: number;
thumbnailPendingCount: number;
thumbnailFailedCount: number;
teaserReadyCount: number;
teaserPendingCount: number;
teaserFailedCount: number;
+54 -4
View File
@@ -293,7 +293,8 @@
color: var(--color-danger);
}
.admin-teaser-counts {
.admin-teaser-counts,
.admin-generation-counts {
display: flex;
flex-wrap: wrap;
gap: 8px;
@@ -473,7 +474,8 @@
.admin-table.admin-drives-table .admin-status,
.admin-table.admin-drives-table .admin-storage-cell,
.admin-table.admin-drives-table .admin-generation-statuses,
.admin-table.admin-drives-table .admin-teaser-counts {
.admin-table.admin-drives-table .admin-teaser-counts,
.admin-table.admin-drives-table .admin-generation-counts {
justify-self: start;
}
}
@@ -750,13 +752,27 @@
@media (max-width: 768px) {
.admin-shell {
grid-template-columns: 1fr;
grid-template-columns: minmax(0, 1fr);
width: 100%;
max-width: 100vw;
overflow-x: hidden;
}
.admin-sidebar {
flex-direction: row;
display: block;
min-width: 0;
width: 100%;
max-width: 100vw;
box-sizing: border-box;
padding: var(--space-2);
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
-webkit-overflow-scrolling: touch;
}
.admin-sidebar::-webkit-scrollbar {
display: none;
}
.admin-sidebar__brand,
@@ -765,17 +781,30 @@
}
.admin-nav {
width: max-content;
min-width: 0;
display: flex;
flex-direction: row;
flex-wrap: nowrap;
gap: 4px;
margin-top: 0;
}
.admin-nav__link {
flex: 0 0 auto;
justify-content: center;
gap: 6px;
white-space: nowrap;
text-align: center;
padding: 8px 12px;
border-left: 0;
border-bottom: 2px solid transparent;
}
.admin-nav__link svg {
flex: 0 0 auto;
}
.admin-nav__link.is-active {
border-left-color: transparent;
border-bottom-color: var(--color-accent);
@@ -783,6 +812,8 @@
.admin-main {
padding: var(--space-3);
min-width: 0;
max-width: 100vw;
overflow-x: hidden;
}
@@ -790,6 +821,25 @@
align-items: stretch;
}
.admin-page__header > div {
min-width: 0;
width: 100%;
max-width: 100%;
flex-wrap: wrap;
}
.admin-page__header > div > * {
min-width: 0;
flex: 1 1 160px;
}
.admin-page__header select,
.admin-page__header input {
min-width: 0 !important;
width: 100%;
box-sizing: border-box;
}
.admin-page__header .admin-btn {
justify-content: center;
}