mirror of
https://github.com/nianzhibai/91.git
synced 2026-06-15 00:44:30 +08:00
Improve admin generation status and cooldowns
This commit is contained in:
@@ -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`,避免连续请求触发更严重限流。
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user