fix thumbnail status and frontend serving

This commit is contained in:
nianzhibai
2026-05-31 17:40:16 +08:00
parent cd3b3c6976
commit 4ba964b7e2
13 changed files with 280 additions and 105 deletions
+37
View File
@@ -67,3 +67,40 @@ func TestFrontendHandlerDoesNotSwallowBackendRoutes(t *testing.T) {
}
}
}
func TestResolveFrontendDirFallsBackToParentDist(t *testing.T) {
workspace := t.TempDir()
backendDir := filepath.Join(workspace, "backend")
distDir := filepath.Join(workspace, "dist")
if err := os.MkdirAll(backendDir, 0o755); err != nil {
t.Fatalf("mkdir backend: %v", err)
}
if err := os.MkdirAll(distDir, 0o755); err != nil {
t.Fatalf("mkdir dist: %v", err)
}
if err := os.WriteFile(filepath.Join(distDir, "index.html"), []byte("<html>app</html>"), 0o644); err != nil {
t.Fatalf("write index: %v", err)
}
oldWD, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}
t.Cleanup(func() {
if err := os.Chdir(oldWD); err != nil {
t.Fatalf("restore wd: %v", err)
}
})
t.Setenv("VIDEO_FRONTEND_DIR", "")
if err := os.Chdir(backendDir); err != nil {
t.Fatalf("chdir backend: %v", err)
}
got, ok := resolveFrontendDir()
if !ok {
t.Fatal("resolveFrontendDir ok = false, want true")
}
if got != "../dist" {
t.Fatalf("frontend dir = %q, want ../dist", got)
}
}
+22 -10
View File
@@ -1787,22 +1787,34 @@ func corsMiddleware(allowedOrigins []string) func(http.Handler) http.Handler {
}
func mountFrontend(r chi.Router) {
dir := strings.TrimSpace(os.Getenv("VIDEO_FRONTEND_DIR"))
if dir == "" {
dir = "./dist"
}
info, err := os.Stat(dir)
if err != nil || !info.IsDir() {
return
}
indexPath := filepath.Join(dir, "index.html")
if st, err := os.Stat(indexPath); err != nil || st.IsDir() {
dir, ok := resolveFrontendDir()
if !ok {
return
}
log.Printf("serving frontend from %s", dir)
r.NotFound(frontendHandler(dir))
}
func resolveFrontendDir() (string, bool) {
candidates := []string{}
if dir := strings.TrimSpace(os.Getenv("VIDEO_FRONTEND_DIR")); dir != "" {
candidates = append(candidates, dir)
} else {
candidates = append(candidates, "./dist", "../dist")
}
for _, dir := range candidates {
info, err := os.Stat(dir)
if err != nil || !info.IsDir() {
continue
}
indexPath := filepath.Join(dir, "index.html")
if st, err := os.Stat(indexPath); err == nil && !st.IsDir() {
return dir, true
}
}
return "", false
}
func frontendHandler(dir string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
+31 -29
View File
@@ -393,19 +393,20 @@ func (a *AdminServer) handleListDrives(w http.ResponseWriter, r *http.Request) {
SkipDirIDs []string `json:"skipDirIds"`
// LastCrawlAt 是 spider91 上次成功爬取的 unix 秒(来自 credentials.last_crawl_at)。
// 其它 kind 留 0;前端用它显示"上次抓取: N 小时前"。
LastCrawlAt int64 `json:"lastCrawlAt,omitempty"`
ThumbnailGenerationStatus GenerationStatus `json:"thumbnailGenerationStatus"`
PreviewGenerationStatus GenerationStatus `json:"previewGenerationStatus"`
FingerprintGenerationStatus GenerationStatus `json:"fingerprintGenerationStatus"`
ThumbnailReadyCount int `json:"thumbnailReadyCount"`
ThumbnailPendingCount int `json:"thumbnailPendingCount"`
ThumbnailFailedCount int `json:"thumbnailFailedCount"`
TeaserReadyCount int `json:"teaserReadyCount"`
TeaserPendingCount int `json:"teaserPendingCount"`
TeaserFailedCount int `json:"teaserFailedCount"`
FingerprintReadyCount int `json:"fingerprintReadyCount"`
FingerprintPendingCount int `json:"fingerprintPendingCount"`
FingerprintFailedCount int `json:"fingerprintFailedCount"`
LastCrawlAt int64 `json:"lastCrawlAt,omitempty"`
ThumbnailGenerationStatus GenerationStatus `json:"thumbnailGenerationStatus"`
PreviewGenerationStatus GenerationStatus `json:"previewGenerationStatus"`
FingerprintGenerationStatus GenerationStatus `json:"fingerprintGenerationStatus"`
ThumbnailReadyCount int `json:"thumbnailReadyCount"`
ThumbnailPendingCount int `json:"thumbnailPendingCount"`
ThumbnailFailedCount int `json:"thumbnailFailedCount"`
ThumbnailDurationPendingCount int `json:"thumbnailDurationPendingCount"`
TeaserReadyCount int `json:"teaserReadyCount"`
TeaserPendingCount int `json:"teaserPendingCount"`
TeaserFailedCount int `json:"teaserFailedCount"`
FingerprintReadyCount int `json:"fingerprintReadyCount"`
FingerprintPendingCount int `json:"fingerprintPendingCount"`
FingerprintFailedCount int `json:"fingerprintFailedCount"`
}
list := make([]out, 0, len(drives))
for _, d := range drives {
@@ -447,22 +448,23 @@ func (a *AdminServer) handleListDrives(w http.ResponseWriter, r *http.Request) {
ID: d.ID, Kind: d.Kind, Name: d.Name,
RootID: d.RootID, ScanRootID: d.ScanRootID,
Status: d.Status, LastError: d.LastError,
HasCredential: hasCred,
TeaserEnabled: d.TeaserEnabled,
SkipDirIDs: append([]string{}, d.SkipDirIDs...),
LastCrawlAt: lastCrawlAt,
ThumbnailGenerationStatus: generation.Thumbnail,
PreviewGenerationStatus: generation.Preview,
FingerprintGenerationStatus: generation.Fingerprint,
ThumbnailReadyCount: thumbCounts.Ready,
ThumbnailPendingCount: thumbCounts.Pending,
ThumbnailFailedCount: thumbCounts.Failed,
TeaserReadyCount: counts.Ready,
TeaserPendingCount: counts.Pending,
TeaserFailedCount: counts.Failed,
FingerprintReadyCount: fingerprintCount.Ready,
FingerprintPendingCount: fingerprintCount.Pending,
FingerprintFailedCount: fingerprintCount.Failed,
HasCredential: hasCred,
TeaserEnabled: d.TeaserEnabled,
SkipDirIDs: append([]string{}, d.SkipDirIDs...),
LastCrawlAt: lastCrawlAt,
ThumbnailGenerationStatus: generation.Thumbnail,
PreviewGenerationStatus: generation.Preview,
FingerprintGenerationStatus: generation.Fingerprint,
ThumbnailReadyCount: thumbCounts.Ready,
ThumbnailPendingCount: thumbCounts.Pending,
ThumbnailFailedCount: thumbCounts.Failed,
ThumbnailDurationPendingCount: thumbCounts.DurationPending,
TeaserReadyCount: counts.Ready,
TeaserPendingCount: counts.Pending,
TeaserFailedCount: counts.Failed,
FingerprintReadyCount: fingerprintCount.Ready,
FingerprintPendingCount: fingerprintCount.Pending,
FingerprintFailedCount: fingerprintCount.Failed,
})
}
writeJSON(w, http.StatusOK, list)
+56 -49
View File
@@ -502,64 +502,68 @@ func TestHandleListDrivesIncludesTeaserCounts(t *testing.T) {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
var got []struct {
ID string `json:"id"`
ThumbnailGenerationStatus GenerationStatus `json:"thumbnailGenerationStatus"`
PreviewGenerationStatus GenerationStatus `json:"previewGenerationStatus"`
FingerprintGenerationStatus GenerationStatus `json:"fingerprintGenerationStatus"`
ThumbnailReadyCount int `json:"thumbnailReadyCount"`
ThumbnailPendingCount int `json:"thumbnailPendingCount"`
ThumbnailFailedCount int `json:"thumbnailFailedCount"`
TeaserReadyCount int `json:"teaserReadyCount"`
TeaserPendingCount int `json:"teaserPendingCount"`
TeaserFailedCount int `json:"teaserFailedCount"`
FingerprintReadyCount int `json:"fingerprintReadyCount"`
FingerprintPendingCount int `json:"fingerprintPendingCount"`
FingerprintFailedCount int `json:"fingerprintFailedCount"`
ID string `json:"id"`
ThumbnailGenerationStatus GenerationStatus `json:"thumbnailGenerationStatus"`
PreviewGenerationStatus GenerationStatus `json:"previewGenerationStatus"`
FingerprintGenerationStatus GenerationStatus `json:"fingerprintGenerationStatus"`
ThumbnailReadyCount int `json:"thumbnailReadyCount"`
ThumbnailPendingCount int `json:"thumbnailPendingCount"`
ThumbnailFailedCount int `json:"thumbnailFailedCount"`
ThumbnailDurationPendingCount int `json:"thumbnailDurationPendingCount"`
TeaserReadyCount int `json:"teaserReadyCount"`
TeaserPendingCount int `json:"teaserPendingCount"`
TeaserFailedCount int `json:"teaserFailedCount"`
FingerprintReadyCount int `json:"fingerprintReadyCount"`
FingerprintPendingCount int `json:"fingerprintPendingCount"`
FingerprintFailedCount int `json:"fingerprintFailedCount"`
}
if err := json.NewDecoder(rr.Body).Decode(&got); err != nil {
t.Fatalf("decode: %v", err)
}
byID := map[string]struct {
TeaserReady int
TeaserPending int
TeaserFailed int
ThumbnailReady int
ThumbnailPending int
ThumbnailFailed int
FingerprintReady int
FingerprintPending int
FingerprintFailed int
Thumbnail GenerationStatus
Preview GenerationStatus
Fingerprint GenerationStatus
TeaserReady int
TeaserPending int
TeaserFailed int
ThumbnailReady int
ThumbnailPending int
ThumbnailFailed int
ThumbnailDurationPending int
FingerprintReady int
FingerprintPending int
FingerprintFailed int
Thumbnail GenerationStatus
Preview GenerationStatus
Fingerprint GenerationStatus
}{}
for _, d := range got {
byID[d.ID] = struct {
TeaserReady int
TeaserPending int
TeaserFailed int
ThumbnailReady int
ThumbnailPending int
ThumbnailFailed int
FingerprintReady int
FingerprintPending int
FingerprintFailed int
Thumbnail GenerationStatus
Preview GenerationStatus
Fingerprint GenerationStatus
TeaserReady int
TeaserPending int
TeaserFailed int
ThumbnailReady int
ThumbnailPending int
ThumbnailFailed int
ThumbnailDurationPending int
FingerprintReady int
FingerprintPending int
FingerprintFailed int
Thumbnail GenerationStatus
Preview GenerationStatus
Fingerprint GenerationStatus
}{
TeaserReady: d.TeaserReadyCount,
TeaserPending: d.TeaserPendingCount,
TeaserFailed: d.TeaserFailedCount,
ThumbnailReady: d.ThumbnailReadyCount,
ThumbnailPending: d.ThumbnailPendingCount,
ThumbnailFailed: d.ThumbnailFailedCount,
FingerprintReady: d.FingerprintReadyCount,
FingerprintPending: d.FingerprintPendingCount,
FingerprintFailed: d.FingerprintFailedCount,
Thumbnail: d.ThumbnailGenerationStatus,
Preview: d.PreviewGenerationStatus,
Fingerprint: d.FingerprintGenerationStatus,
TeaserReady: d.TeaserReadyCount,
TeaserPending: d.TeaserPendingCount,
TeaserFailed: d.TeaserFailedCount,
ThumbnailReady: d.ThumbnailReadyCount,
ThumbnailPending: d.ThumbnailPendingCount,
ThumbnailFailed: d.ThumbnailFailedCount,
ThumbnailDurationPending: d.ThumbnailDurationPendingCount,
FingerprintReady: d.FingerprintReadyCount,
FingerprintPending: d.FingerprintPendingCount,
FingerprintFailed: d.FingerprintFailedCount,
Thumbnail: d.ThumbnailGenerationStatus,
Preview: d.PreviewGenerationStatus,
Fingerprint: d.FingerprintGenerationStatus,
}
}
if byID["OneDrive"].TeaserReady != 2 || byID["OneDrive"].TeaserPending != 1 || byID["OneDrive"].TeaserFailed != 0 {
@@ -568,6 +572,9 @@ func TestHandleListDrivesIncludesTeaserCounts(t *testing.T) {
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"].ThumbnailDurationPending != 1 {
t.Fatalf("OneDrive thumbnail duration pending = %#v, want 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"])
}
+10 -6
View File
@@ -1124,9 +1124,10 @@ type DriveTeaserCounts struct {
}
type DriveThumbnailCounts struct {
Ready int
Pending int
Failed int
Ready int
Pending int
Failed int
DurationPending int
}
type DriveFingerprintCounts struct {
@@ -1170,9 +1171,12 @@ func (c *Catalog) CountThumbnailsByDrive(ctx context.Context) (map[string]DriveT
`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,
AND COALESCE(thumbnail_status, 'pending') NOT IN ('failed', 'skipped') THEN 1 END) AS pending_count,
COUNT(CASE WHEN COALESCE(thumbnail_url, '') = ''
AND COALESCE(thumbnail_status, 'pending') = 'failed' THEN 1 END) AS failed_count
AND COALESCE(thumbnail_status, 'pending') = 'failed' THEN 1 END) AS failed_count,
COUNT(CASE WHEN COALESCE(thumbnail_url, '') != ''
AND COALESCE(duration_seconds, 0) <= 0
AND COALESCE(thumbnail_status, 'pending') NOT IN ('failed', 'skipped') THEN 1 END) AS duration_pending_count
FROM videos
WHERE COALESCE(hidden, 0) = 0
AND `+uniqueVideoWhereSQL+`
@@ -1186,7 +1190,7 @@ func (c *Catalog) CountThumbnailsByDrive(ctx context.Context) (map[string]DriveT
for rows.Next() {
var driveID string
var counts DriveThumbnailCounts
if err := rows.Scan(&driveID, &counts.Ready, &counts.Pending, &counts.Failed); err != nil {
if err := rows.Scan(&driveID, &counts.Ready, &counts.Pending, &counts.Failed, &counts.DurationPending); err != nil {
return nil, err
}
out[driveID] = counts
+1 -1
View File
@@ -17,7 +17,7 @@ CREATE TABLE IF NOT EXISTS videos (
ext TEXT,
quality TEXT, -- HD / SD
thumbnail_url TEXT,
thumbnail_status TEXT DEFAULT 'pending', -- pending / ready / failed
thumbnail_status TEXT DEFAULT 'pending', -- pending / ready / failed / skipped
thumbnail_failures INTEGER DEFAULT 0, -- consecutive transient thumbnail generation failures
preview_file_id TEXT, -- deprecated: 旧版回写网盘后的 teaser file id
preview_local TEXT, -- 本地 teaser 路径(兜底)
+3 -2
View File
@@ -209,8 +209,9 @@ func (c *Catalog) resetDriveTeaserEnabledToDefaultOnce(ctx context.Context) erro
// - 管理员凭直觉认知字段名时会被误导
//
// 修正策略:
// - thumbnail_url 非空 + status 非 'ready' + status 非 'failed' → 改成 'ready'
// - thumbnail_url 非空 + status 非 'ready' + status 非 'failed' + status 非 'skipped' → 改成 'ready'
// - status='failed' 不动(这是 worker 显式标的失败,要保留以便管理员手动重生)
// - status='skipped' 不动(已有封面但时长探测不可用,避免重启后重复排队)
//
// 幂等保证:marker setting 写过就不再跑,避免每次重启都 update 一遍。
func (c *Catalog) reconcileThumbnailStatusOnce(ctx context.Context) error {
@@ -227,7 +228,7 @@ UPDATE videos
SET thumbnail_status = 'ready',
updated_at = ?
WHERE COALESCE(thumbnail_url, '') != ''
AND COALESCE(thumbnail_status, 'pending') NOT IN ('ready', 'failed')
AND COALESCE(thumbnail_status, 'pending') NOT IN ('ready', 'failed', 'skipped')
`, time.Now().UnixMilli())
if err != nil {
return fmt.Errorf("reconcile thumbnail_status: %w", err)
+32 -5
View File
@@ -90,6 +90,32 @@ func TestListVideosNeedingThumbnailIncludesExistingThumbnailMissingDuration(t *t
if count != 2 {
t.Fatalf("count = %d, want 2", count)
}
counts, err := cat.CountThumbnailsByDrive(ctx)
if err != nil {
t.Fatalf("count thumbnails by drive: %v", err)
}
if got := counts["drive"]; got.Ready != 2 || got.Pending != 1 || got.Failed != 1 || got.DurationPending != 1 {
t.Fatalf("thumbnail counts = %#v, want ready=2 pending=1 failed=1 durationPending=1", got)
}
if err := cat.UpdateVideoMeta(ctx, "duration-only", VideoMetaPatch{ThumbnailStatus: "skipped"}); err != nil {
t.Fatalf("mark duration-only skipped: %v", err)
}
count, err = cat.CountVideosNeedingThumbnail(ctx, "drive")
if err != nil {
t.Fatalf("count videos needing thumbnail after skip: %v", err)
}
if count != 1 {
t.Fatalf("count after skip = %d, want 1", count)
}
counts, err = cat.CountThumbnailsByDrive(ctx)
if err != nil {
t.Fatalf("count thumbnails by drive after skip: %v", err)
}
if got := counts["drive"]; got.Ready != 2 || got.Pending != 1 || got.Failed != 1 || got.DurationPending != 0 {
t.Fatalf("thumbnail counts after skip = %#v, want ready=2 pending=1 failed=1 durationPending=0", got)
}
}
func TestCreateTagAndClassifyAddsTagToMatchingExistingVideos(t *testing.T) {
@@ -1111,11 +1137,12 @@ func TestReconcileThumbnailStatusOnce(t *testing.T) {
id, url, status string
wantStatus string
}{
{"v-pending-url", "/p/thumb/v-pending-url", "pending", "ready"}, // 主要修复目标
{"v-empty-url-pending", "", "pending", "pending"}, // 没 url 不动
{"v-failed-with-url", "/p/thumb/v-failed-with-url", "failed", "failed"}, // 显式失败保留
{"v-empty-url-failed", "", "failed", "failed"}, // 失败 + 没 url 也保留
{"v-already-ready", "/p/thumb/v-already-ready", "ready", "ready"}, // 幂等
{"v-pending-url", "/p/thumb/v-pending-url", "pending", "ready"}, // 主要修复目标
{"v-empty-url-pending", "", "pending", "pending"}, // 没 url 不动
{"v-failed-with-url", "/p/thumb/v-failed-with-url", "failed", "failed"}, // 显式失败保留
{"v-empty-url-failed", "", "failed", "failed"}, // 失败 + 没 url 也保留
{"v-skipped-with-url", "/p/thumb/v-skipped-with-url", "skipped", "skipped"}, // 已跳过的时长补全保留
{"v-already-ready", "/p/thumb/v-already-ready", "ready", "ready"}, // 幂等
}
for _, c := range cases {
if err := cat.UpsertVideo(ctx, &Video{
+18 -2
View File
@@ -1,6 +1,7 @@
package preview
import (
"bytes"
"context"
"encoding/json"
"errors"
@@ -345,9 +346,15 @@ func (g *Generator) Probe(ctx context.Context, link *drives.StreamLink) (float64
args = append(args, ffmpegLink.URL)
cmd := exec.CommandContext(ctx2, g.cfg.FFprobePath, args...)
out, err := cmd.CombinedOutput()
var stderr bytes.Buffer
cmd.Stderr = &stderr
out, err := cmd.Output()
if err != nil {
return 0, ffmpegCommandError("ffprobe", err, out)
errOut := stderr.Bytes()
if len(errOut) == 0 {
errOut = out
}
return 0, ffmpegCommandError("ffprobe", err, errOut)
}
raw := strings.TrimSpace(string(out))
if raw == "" || raw == "N/A" {
@@ -1522,6 +1529,7 @@ func (w *ThumbWorker) process(ctx context.Context, v *catalog.Video) bool {
}
}
if current.ThumbnailURL != "" {
durationBackfillFailed := false
if current.DurationSeconds <= 0 {
link, err := w.streamLink(ctx, current)
if err != nil {
@@ -1529,10 +1537,18 @@ func (w *ThumbWorker) process(ctx context.Context, v *catalog.Video) bool {
return true
}
log.Printf("[thumb] probe streamURL %s: %v", current.Title, err)
durationBackfillFailed = true
} else if w.probeDuration(ctx, current, link) {
return true
} else if current.DurationSeconds <= 0 {
durationBackfillFailed = true
}
}
if durationBackfillFailed {
log.Printf("[thumb] skip duration backfill %s: thumbnail already exists but duration could not be probed", current.Title)
_ = w.Catalog.UpdateVideoMeta(ctx, current.ID, catalog.VideoMetaPatch{ThumbnailStatus: "skipped"})
return false
}
_ = w.Catalog.UpdateVideoMeta(ctx, current.ID, catalog.VideoMetaPatch{ThumbnailStatus: "ready"})
return false
}
+20
View File
@@ -5,6 +5,8 @@ import (
"errors"
"math"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
@@ -95,6 +97,24 @@ func TestTinyVideoPreviewPlanUsesWholeVideoAsSingleSegment(t *testing.T) {
}
}
func TestProbeIgnoresStderrWarnings(t *testing.T) {
dir := t.TempDir()
ffprobePath := filepath.Join(dir, "ffprobe")
script := "#!/bin/sh\nprintf '%s\\n' 'h264 warning' >&2\nprintf '%s\\n' '364.800000'\n"
if err := os.WriteFile(ffprobePath, []byte(script), 0o755); err != nil {
t.Fatalf("write ffprobe stub: %v", err)
}
gen := New(Config{FFprobePath: ffprobePath})
got, err := gen.Probe(context.Background(), &drives.StreamLink{URL: filepath.Join(dir, "video.mp4")})
if err != nil {
t.Fatalf("probe: %v", err)
}
if got != 364.8 {
t.Fatalf("duration = %v, want 364.8", got)
}
}
func TestTeaserCandidateStartsKeepPrimaryAndAddFallbacks(t *testing.T) {
primary := []float64{10.2, 64.65, 119.1, 173.55}
got := teaserCandidateStarts(204, primary, 3)
+40
View File
@@ -89,6 +89,46 @@ func TestThumbWorkerBackfillsDurationWhenThumbnailAlreadyExists(t *testing.T) {
}
}
func TestThumbWorkerSkipsDurationBackfillWhenExistingThumbnailCannotBeProbed(t *testing.T) {
ctx := context.Background()
cat, video := seedPreviewTestVideo(t, "thumb-worker-existing-thumbnail-probe-fails")
video.ThumbnailURL = "/p/thumb/" + video.ID
if err := cat.UpsertVideo(ctx, video); err != nil {
t.Fatalf("update video: %v", err)
}
gen := &fakeThumbGenerator{probeErr: errors.New("invalid media")}
drv := &previewFakeDrive{}
worker := NewThumbWorker(gen, cat, drv)
worker.process(ctx, video)
got, err := cat.GetVideo(ctx, video.ID)
if err != nil {
t.Fatalf("get video: %v", err)
}
if got.ThumbnailURL != "/p/thumb/"+video.ID {
t.Fatalf("thumbnail = %q, want unchanged existing thumbnail", got.ThumbnailURL)
}
if got.DurationSeconds != 0 {
t.Fatalf("duration = %d, want still unknown", got.DurationSeconds)
}
skipped, err := cat.ListVideosByThumbnailStatus(ctx, video.DriveID, "skipped", 0)
if err != nil {
t.Fatalf("list skipped thumbnails: %v", err)
}
if len(skipped) != 1 || skipped[0].ID != video.ID {
t.Fatalf("skipped thumbnails = %#v, want only %s", skipped, video.ID)
}
missing, err := cat.CountVideosNeedingThumbnail(ctx, video.DriveID)
if err != nil {
t.Fatalf("count videos needing thumbnail: %v", err)
}
if missing != 0 {
t.Fatalf("missing thumbnails = %d, want 0 after duration backfill is skipped", missing)
}
}
func TestThumbWorkerFallsBackToLocalPreviewWhenDriveStreamFails(t *testing.T) {
ctx := context.Background()
cat, video := seedPreviewTestVideo(t, "thumb-worker-local-preview")
+9 -1
View File
@@ -498,6 +498,7 @@ export function DrivesPage() {
ready={d.thumbnailReadyCount}
pending={d.thumbnailPendingCount}
failed={d.thumbnailFailedCount}
durationPending={d.thumbnailDurationPendingCount}
/>
</div>
</div>
@@ -757,10 +758,12 @@ function GenerationCounts({
ready,
pending,
failed,
durationPending,
}: {
ready?: number;
pending?: number;
failed?: number;
durationPending?: number;
}) {
return (
<div className="admin-generation-counts">
@@ -773,6 +776,11 @@ function GenerationCounts({
<span className="admin-drive-teaser__metric is-failed">
{failed ?? 0}
</span>
{(durationPending ?? 0) > 0 && (
<span className="admin-drive-teaser__metric">
{durationPending}
</span>
)}
</div>
);
}
@@ -788,7 +796,7 @@ function GenerationStatusLine({
const queueLength = status?.queueLength ?? 0;
const detail = generationDetail(status);
const title = generationTitle(status, detail);
const countText = queueLength > 0 ? `${label === "封面" ? "剩余" : "队列"} ${queueLength}` : "";
const countText = queueLength > 0 ? `${label === "封面" ? "待处理" : "队列"} ${queueLength}` : "";
return (
<div className="admin-generation-row" title={title}>
+1
View File
@@ -99,6 +99,7 @@ export type AdminDrive = {
thumbnailReadyCount: number;
thumbnailPendingCount: number;
thumbnailFailedCount: number;
thumbnailDurationPendingCount: number;
teaserReadyCount: number;
teaserPendingCount: number;
teaserFailedCount: number;