diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 875cc58..23f616b 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -247,6 +247,9 @@ func main() { GetDriveGenerationStatuses: func() map[string]api.DriveGenerationStatuses { return app.driveGenerationStatuses() }, + GetPreviewGenerationVideoIDs: func() map[string]bool { + return app.previewGenerationVideoIDs() + }, OnTeaserEnabledChanged: func(driveID string, enabled bool) { // 从关到开时立刻补扫该盘 pending 预览视频,行为对齐旧的"全局开关从关到开"。 // 关闭分支不需要做事 —— 入队前会重新查 catalog,新的 enqueue 自然停。 @@ -656,6 +659,23 @@ func (a *App) driveGenerationStatuses() map[string]api.DriveGenerationStatuses { return out } +func (a *App) previewGenerationVideoIDs() map[string]bool { + a.mu.Lock() + previewWorkers := make([]*preview.Worker, 0, len(a.workers)) + for _, worker := range a.workers { + previewWorkers = append(previewWorkers, worker) + } + a.mu.Unlock() + + out := make(map[string]bool) + for _, worker := range previewWorkers { + for _, id := range worker.ActiveVideoIDs() { + out[id] = true + } + } + return out +} + func (a *App) updateCrawlerUploadProgress(progress spider91migrate.UploadProgress) { driveID := strings.TrimSpace(progress.DriveID) if driveID == "" { diff --git a/backend/internal/api/admin.go b/backend/internal/api/admin.go index cd48ebe..3f5e8c7 100644 --- a/backend/internal/api/admin.go +++ b/backend/internal/api/admin.go @@ -65,9 +65,10 @@ type AdminServer struct { // 处理完候选列表后任务自然结束。 OnStartDriveTranscode func(driveID string) (bool, string) // OnStopDriveTranscode 手动停止某盘正在进行的转码任务。返回是否有任务被停。 - OnStopDriveTranscode func(driveID string) bool - OnDeleteVideo func(ctx context.Context, videoID string, deleteSource bool) (DeleteVideoResult, error) - GetDriveGenerationStatuses func() map[string]DriveGenerationStatuses + OnStopDriveTranscode func(driveID string) bool + OnDeleteVideo func(ctx context.Context, videoID string, deleteSource bool) (DeleteVideoResult, error) + GetDriveGenerationStatuses func() map[string]DriveGenerationStatuses + GetPreviewGenerationVideoIDs func() map[string]bool // OnTeaserEnabledChanged 在 per-drive 预览视频开关被切换后调用。 // enabled=true 时上层应该重新把 pending 预览视频入队(类似旧的全局开关从关到开); // enabled=false 时通常不用做事 —— worker 入队前会再次查 catalog,自然停止。 @@ -1931,6 +1932,14 @@ func (a *AdminServer) handleAdminListVideos(w http.ResponseWriter, r *http.Reque writeErr(w, http.StatusInternalServerError, err) return } + if a.GetPreviewGenerationVideoIDs != nil { + generating := a.GetPreviewGenerationVideoIDs() + for _, item := range items { + if item != nil && generating[item.ID] { + item.PreviewStatus = "generating" + } + } + } writeJSON(w, http.StatusOK, map[string]any{ "items": items, "total": total, diff --git a/backend/internal/api/admin_test.go b/backend/internal/api/admin_test.go index ca6f7d6..912db15 100644 --- a/backend/internal/api/admin_test.go +++ b/backend/internal/api/admin_test.go @@ -2504,6 +2504,80 @@ func TestHandleAdminListVideosPaginates(t *testing.T) { } } +func TestHandleAdminListVideosMarksActivePreviewGeneration(t *testing.T) { + ctx := context.Background() + cat, err := catalog.Open(t.TempDir() + "/catalog.db") + if err != nil { + t.Fatalf("open catalog: %v", err) + } + t.Cleanup(func() { + if err := cat.Close(); err != nil { + t.Fatalf("close catalog: %v", err) + } + }) + + now := time.Now() + for _, v := range []*catalog.Video{ + { + ID: "active-video", + DriveID: "OneDrive", + FileID: "active-file", + Title: "Active video", + PreviewStatus: "ready", + PublishedAt: now, + CreatedAt: now, + UpdatedAt: now, + }, + { + ID: "idle-video", + DriveID: "OneDrive", + FileID: "idle-file", + Title: "Idle video", + PreviewStatus: "ready", + PublishedAt: now.Add(-time.Hour), + CreatedAt: now, + UpdatedAt: now, + }, + } { + if err := cat.UpsertVideo(ctx, v); err != nil { + t.Fatalf("seed video %s: %v", v.ID, err) + } + } + + req := httptest.NewRequest(http.MethodGet, "/admin/api/videos?driveId=OneDrive", nil) + rr := httptest.NewRecorder() + (&AdminServer{ + Catalog: cat, + GetPreviewGenerationVideoIDs: func() map[string]bool { + return map[string]bool{"active-video": true} + }, + }).handleAdminListVideos(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String()) + } + var got struct { + Items []catalog.Video `json:"items"` + Total int `json:"total"` + } + if err := json.NewDecoder(rr.Body).Decode(&got); err != nil { + t.Fatalf("decode: %v", err) + } + if got.Total != 2 || len(got.Items) != 2 { + t.Fatalf("response total/items = %d/%d, want 2/2", got.Total, len(got.Items)) + } + statusByID := map[string]string{} + for _, item := range got.Items { + statusByID[item.ID] = item.PreviewStatus + } + if statusByID["active-video"] != "generating" { + t.Fatalf("active status = %q, want generating", statusByID["active-video"]) + } + if statusByID["idle-video"] != "ready" { + t.Fatalf("idle status = %q, want ready", statusByID["idle-video"]) + } +} + func TestHandleRegenAllPreviewsInvokesHook(t *testing.T) { called := false server := &AdminServer{ diff --git a/backend/internal/preview/ffmpeg.go b/backend/internal/preview/ffmpeg.go index 17f9437..98bb7ec 100644 --- a/backend/internal/preview/ffmpeg.go +++ b/backend/internal/preview/ffmpeg.go @@ -1114,6 +1114,19 @@ func (q *videoQueue) release(v *catalog.Video) { q.mu.Unlock() } +func (q *videoQueue) idsSnapshot() []string { + q.mu.Lock() + defer q.mu.Unlock() + if len(q.ids) == 0 { + return nil + } + out := make([]string, 0, len(q.ids)) + for id := range q.ids { + out = append(out, id) + } + return out +} + func (q *videoQueue) lengthExcluding(currentID string) int { q.mu.Lock() defer q.mu.Unlock() @@ -1241,6 +1254,13 @@ func (w *Worker) Status() TaskStatus { return taskStatus(&w.activity, &w.rateLimit, w.queue.lengthExcluding(currentID)) } +func (w *Worker) ActiveVideoIDs() []string { + if w == nil { + return nil + } + return w.queue.idsSnapshot() +} + func (w *ThumbWorker) Status() TaskStatus { if w == nil { return TaskStatus{State: "idle"} diff --git a/src/admin/VideosPage.tsx b/src/admin/VideosPage.tsx index a2b3c0b..c467f52 100644 --- a/src/admin/VideosPage.tsx +++ b/src/admin/VideosPage.tsx @@ -21,9 +21,17 @@ import { formatBytes } from "./storageFormat"; const DESKTOP_VIDEOS_PAGE_SIZE = 50; const MOBILE_VIDEOS_PAGE_SIZE = 20; const VIDEOS_MOBILE_QUERY = "(max-width: 640px)"; +const REGEN_PREVIEW_STATUS = "generating"; +const REGEN_PREVIEW_POLL_INTERVAL_MS = 2000; +const REGEN_PREVIEW_TRACK_TIMEOUT_MS = 30 * 60 * 1000; type TabKey = "current" | "blacklist"; +type RegenPreviewState = { + expiresAt: number; + originalUpdatedAt: number; +}; + const TABS: { key: TabKey; label: string }[] = [ { key: "current", label: "当前视频" }, { key: "blacklist", label: "拉黑视频" }, @@ -121,6 +129,7 @@ function CurrentVideosTab({ onStatsChanged }: { onStatsChanged: () => void }) { const [deleteTarget, setDeleteTarget] = useState(null); const [deleting, setDeleting] = useState(false); const [deleteSource, setDeleteSource] = useState(false); + const [regenPreviewById, setRegenPreviewById] = useState>({}); const pageSize = useVideosPageSize(); const { show } = useToast(); @@ -147,6 +156,19 @@ function CurrentVideosTab({ onStatsChanged }: { onStatsChanged: () => void }) { } } + async function refreshListOnly() { + try { + const r = await api.listVideos({ driveId, page, size: pageSize, keyword: searchKeyword }); + setList(r.items ?? []); + setTotal(r.total ?? 0); + } catch { + // Polling is only used to clear optimistic preview-generation state. + } + } + + const trackedRegenCount = Object.keys(regenPreviewById).length; + const hasGeneratingPreview = list.some((v) => v.previewStatus === REGEN_PREVIEW_STATUS); + useEffect(() => { refresh(); }, [driveId, page, searchKeyword, pageSize]); @@ -164,6 +186,33 @@ function CurrentVideosTab({ onStatsChanged }: { onStatsChanged: () => void }) { return () => window.clearTimeout(timer); }, [keyword]); + useEffect(() => { + if (trackedRegenCount === 0 && !hasGeneratingPreview) return; + const timer = window.setInterval(() => { + refreshListOnly(); + }, REGEN_PREVIEW_POLL_INTERVAL_MS); + return () => window.clearInterval(timer); + }, [trackedRegenCount, hasGeneratingPreview, driveId, page, pageSize, searchKeyword]); + + useEffect(() => { + if (trackedRegenCount === 0) return; + const now = Date.now(); + setRegenPreviewById((current) => { + const next = { ...current }; + let changed = false; + const byId = new Map(list.map((v) => [v.id, v])); + for (const [id, state] of Object.entries(current)) { + const video = byId.get(id); + const updatedAt = videoUpdatedAtMs(video); + if (!video || now >= state.expiresAt || updatedAt > state.originalUpdatedAt) { + delete next[id]; + changed = true; + } + } + return changed ? next : current; + }); + }, [list, trackedRegenCount]); + const driveNameMap = new Map(drives.map((d) => [d.id, d.name || d.id])); const listItems = list; @@ -177,6 +226,7 @@ function CurrentVideosTab({ onStatsChanged }: { onStatsChanged: () => void }) { async function handleRegen(v: api.AdminVideo) { try { await api.regenPreview(v.id); + trackRegeneratingPreview([v]); show("已触发预览视频重生", "success"); } catch (e) { show(e instanceof Error ? e.message : "触发失败", "error"); @@ -196,13 +246,20 @@ function CurrentVideosTab({ onStatsChanged }: { onStatsChanged: () => void }) { async function confirmBatchRegen() { const ids = [...selectedIds]; + const videoById = new Map(listItems.map((v) => [v.id, v])); setBatchRegening(true); let success = 0; try { const results = await Promise.allSettled(ids.map((id) => api.regenPreview(id))); - for (const r of results) { - if (r.status === "fulfilled") success++; - } + const acceptedVideos: api.AdminVideo[] = []; + results.forEach((r, index) => { + if (r.status === "fulfilled") { + const video = videoById.get(ids[index]); + if (video) acceptedVideos.push(video); + success++; + } + }); + trackRegeneratingPreview(acceptedVideos); show( `批量触发完成,成功 ${success} / ${ids.length} 个`, success === ids.length ? "success" : "info" @@ -214,6 +271,25 @@ function CurrentVideosTab({ onStatsChanged }: { onStatsChanged: () => void }) { } } + function trackRegeneratingPreview(videos: api.AdminVideo[]) { + if (videos.length === 0) return; + const startedAt = Date.now(); + setRegenPreviewById((current) => { + const next = { ...current }; + for (const v of videos) { + next[v.id] = { + expiresAt: startedAt + REGEN_PREVIEW_TRACK_TIMEOUT_MS, + originalUpdatedAt: videoUpdatedAtMs(v), + }; + } + return next; + }); + } + + function isPreviewGenerating(v: api.AdminVideo) { + return !!regenPreviewById[v.id] || v.previewStatus === REGEN_PREVIEW_STATUS; + } + async function confirmDeleteVideo() { if (!deleteTarget) return; const target = deleteTarget; @@ -398,7 +474,7 @@ function CurrentVideosTab({ onStatsChanged }: { onStatsChanged: () => void }) { {v.author || } {formatDur(v.durationSeconds)} - + {driveNameMap.get(v.driveId) ?? v.driveId} @@ -407,8 +483,14 @@ function CurrentVideosTab({ onStatsChanged }: { onStatsChanged: () => void }) { {" "} - {" "}