fix: show active preview generation status

This commit is contained in:
nianzhibai
2026-06-14 18:22:04 +08:00
parent bb83277d62
commit f9351324c6
7 changed files with 238 additions and 9 deletions
+20
View File
@@ -247,6 +247,9 @@ func main() {
GetDriveGenerationStatuses: func() map[string]api.DriveGenerationStatuses { GetDriveGenerationStatuses: func() map[string]api.DriveGenerationStatuses {
return app.driveGenerationStatuses() return app.driveGenerationStatuses()
}, },
GetPreviewGenerationVideoIDs: func() map[string]bool {
return app.previewGenerationVideoIDs()
},
OnTeaserEnabledChanged: func(driveID string, enabled bool) { OnTeaserEnabledChanged: func(driveID string, enabled bool) {
// 从关到开时立刻补扫该盘 pending 预览视频,行为对齐旧的"全局开关从关到开"。 // 从关到开时立刻补扫该盘 pending 预览视频,行为对齐旧的"全局开关从关到开"。
// 关闭分支不需要做事 —— 入队前会重新查 catalog,新的 enqueue 自然停。 // 关闭分支不需要做事 —— 入队前会重新查 catalog,新的 enqueue 自然停。
@@ -656,6 +659,23 @@ func (a *App) driveGenerationStatuses() map[string]api.DriveGenerationStatuses {
return out 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) { func (a *App) updateCrawlerUploadProgress(progress spider91migrate.UploadProgress) {
driveID := strings.TrimSpace(progress.DriveID) driveID := strings.TrimSpace(progress.DriveID)
if driveID == "" { if driveID == "" {
+12 -3
View File
@@ -65,9 +65,10 @@ type AdminServer struct {
// 处理完候选列表后任务自然结束。 // 处理完候选列表后任务自然结束。
OnStartDriveTranscode func(driveID string) (bool, string) OnStartDriveTranscode func(driveID string) (bool, string)
// OnStopDriveTranscode 手动停止某盘正在进行的转码任务。返回是否有任务被停。 // OnStopDriveTranscode 手动停止某盘正在进行的转码任务。返回是否有任务被停。
OnStopDriveTranscode func(driveID string) bool OnStopDriveTranscode func(driveID string) bool
OnDeleteVideo func(ctx context.Context, videoID string, deleteSource bool) (DeleteVideoResult, error) OnDeleteVideo func(ctx context.Context, videoID string, deleteSource bool) (DeleteVideoResult, error)
GetDriveGenerationStatuses func() map[string]DriveGenerationStatuses GetDriveGenerationStatuses func() map[string]DriveGenerationStatuses
GetPreviewGenerationVideoIDs func() map[string]bool
// OnTeaserEnabledChanged 在 per-drive 预览视频开关被切换后调用。 // OnTeaserEnabledChanged 在 per-drive 预览视频开关被切换后调用。
// enabled=true 时上层应该重新把 pending 预览视频入队(类似旧的全局开关从关到开); // enabled=true 时上层应该重新把 pending 预览视频入队(类似旧的全局开关从关到开);
// enabled=false 时通常不用做事 —— worker 入队前会再次查 catalog,自然停止。 // enabled=false 时通常不用做事 —— worker 入队前会再次查 catalog,自然停止。
@@ -1931,6 +1932,14 @@ func (a *AdminServer) handleAdminListVideos(w http.ResponseWriter, r *http.Reque
writeErr(w, http.StatusInternalServerError, err) writeErr(w, http.StatusInternalServerError, err)
return 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{ writeJSON(w, http.StatusOK, map[string]any{
"items": items, "items": items,
"total": total, "total": total,
+74
View File
@@ -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) { func TestHandleRegenAllPreviewsInvokesHook(t *testing.T) {
called := false called := false
server := &AdminServer{ server := &AdminServer{
+20
View File
@@ -1114,6 +1114,19 @@ func (q *videoQueue) release(v *catalog.Video) {
q.mu.Unlock() 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 { func (q *videoQueue) lengthExcluding(currentID string) int {
q.mu.Lock() q.mu.Lock()
defer q.mu.Unlock() defer q.mu.Unlock()
@@ -1241,6 +1254,13 @@ func (w *Worker) Status() TaskStatus {
return taskStatus(&w.activity, &w.rateLimit, w.queue.lengthExcluding(currentID)) 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 { func (w *ThumbWorker) Status() TaskStatus {
if w == nil { if w == nil {
return TaskStatus{State: "idle"} return TaskStatus{State: "idle"}
+95 -6
View File
@@ -21,9 +21,17 @@ import { formatBytes } from "./storageFormat";
const DESKTOP_VIDEOS_PAGE_SIZE = 50; const DESKTOP_VIDEOS_PAGE_SIZE = 50;
const MOBILE_VIDEOS_PAGE_SIZE = 20; const MOBILE_VIDEOS_PAGE_SIZE = 20;
const VIDEOS_MOBILE_QUERY = "(max-width: 640px)"; 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 TabKey = "current" | "blacklist";
type RegenPreviewState = {
expiresAt: number;
originalUpdatedAt: number;
};
const TABS: { key: TabKey; label: string }[] = [ const TABS: { key: TabKey; label: string }[] = [
{ key: "current", label: "当前视频" }, { key: "current", label: "当前视频" },
{ key: "blacklist", label: "拉黑视频" }, { key: "blacklist", label: "拉黑视频" },
@@ -121,6 +129,7 @@ function CurrentVideosTab({ onStatsChanged }: { onStatsChanged: () => void }) {
const [deleteTarget, setDeleteTarget] = useState<api.AdminVideo | null>(null); const [deleteTarget, setDeleteTarget] = useState<api.AdminVideo | null>(null);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
const [deleteSource, setDeleteSource] = useState(false); const [deleteSource, setDeleteSource] = useState(false);
const [regenPreviewById, setRegenPreviewById] = useState<Record<string, RegenPreviewState>>({});
const pageSize = useVideosPageSize(); const pageSize = useVideosPageSize();
const { show } = useToast(); 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(() => { useEffect(() => {
refresh(); refresh();
}, [driveId, page, searchKeyword, pageSize]); }, [driveId, page, searchKeyword, pageSize]);
@@ -164,6 +186,33 @@ function CurrentVideosTab({ onStatsChanged }: { onStatsChanged: () => void }) {
return () => window.clearTimeout(timer); return () => window.clearTimeout(timer);
}, [keyword]); }, [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 driveNameMap = new Map(drives.map((d) => [d.id, d.name || d.id]));
const listItems = list; const listItems = list;
@@ -177,6 +226,7 @@ function CurrentVideosTab({ onStatsChanged }: { onStatsChanged: () => void }) {
async function handleRegen(v: api.AdminVideo) { async function handleRegen(v: api.AdminVideo) {
try { try {
await api.regenPreview(v.id); await api.regenPreview(v.id);
trackRegeneratingPreview([v]);
show("已触发预览视频重生", "success"); show("已触发预览视频重生", "success");
} catch (e) { } catch (e) {
show(e instanceof Error ? e.message : "触发失败", "error"); show(e instanceof Error ? e.message : "触发失败", "error");
@@ -196,13 +246,20 @@ function CurrentVideosTab({ onStatsChanged }: { onStatsChanged: () => void }) {
async function confirmBatchRegen() { async function confirmBatchRegen() {
const ids = [...selectedIds]; const ids = [...selectedIds];
const videoById = new Map(listItems.map((v) => [v.id, v]));
setBatchRegening(true); setBatchRegening(true);
let success = 0; let success = 0;
try { try {
const results = await Promise.allSettled(ids.map((id) => api.regenPreview(id))); const results = await Promise.allSettled(ids.map((id) => api.regenPreview(id)));
for (const r of results) { const acceptedVideos: api.AdminVideo[] = [];
if (r.status === "fulfilled") success++; results.forEach((r, index) => {
} if (r.status === "fulfilled") {
const video = videoById.get(ids[index]);
if (video) acceptedVideos.push(video);
success++;
}
});
trackRegeneratingPreview(acceptedVideos);
show( show(
`批量触发完成,成功 ${success} / ${ids.length}`, `批量触发完成,成功 ${success} / ${ids.length}`,
success === ids.length ? "success" : "info" 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() { async function confirmDeleteVideo() {
if (!deleteTarget) return; if (!deleteTarget) return;
const target = deleteTarget; const target = deleteTarget;
@@ -398,7 +474,7 @@ function CurrentVideosTab({ onStatsChanged }: { onStatsChanged: () => void }) {
<td data-label="作者">{v.author || <span className="admin-text-faint"></span>}</td> <td data-label="作者">{v.author || <span className="admin-text-faint"></span>}</td>
<td data-label="时长">{formatDur(v.durationSeconds)}</td> <td data-label="时长">{formatDur(v.durationSeconds)}</td>
<td data-label="预览视频"> <td data-label="预览视频">
<PreviewStatus s={v.previewStatus} /> <PreviewStatus s={isPreviewGenerating(v) ? REGEN_PREVIEW_STATUS : v.previewStatus} />
</td> </td>
<td data-label="来源" className="admin-mono-cell"> <td data-label="来源" className="admin-mono-cell">
{driveNameMap.get(v.driveId) ?? v.driveId} {driveNameMap.get(v.driveId) ?? v.driveId}
@@ -407,8 +483,14 @@ function CurrentVideosTab({ onStatsChanged }: { onStatsChanged: () => void }) {
<button type="button" className="admin-btn" onClick={() => setEditing(v)} title="编辑视频"> <button type="button" className="admin-btn" onClick={() => setEditing(v)} title="编辑视频">
<Edit size={13} /> <Edit size={13} />
</button>{" "} </button>{" "}
<button type="button" className="admin-btn" onClick={() => handleRegen(v)} title="重生预览视频"> <button
<RefreshCw size={13} /> type="button"
className="admin-btn"
onClick={() => handleRegen(v)}
disabled={isPreviewGenerating(v)}
title={isPreviewGenerating(v) ? "预览视频正在生成" : "重生预览视频"}
>
<RefreshCw size={13} className={isPreviewGenerating(v) ? "admin-spin" : undefined} />
</button>{" "} </button>{" "}
<button <button
type="button" type="button"
@@ -832,6 +914,7 @@ function VideoTitleCell({ video: v }: { video: api.AdminVideo }) {
} }
function PreviewStatus({ s }: { s: string }) { function PreviewStatus({ s }: { s: string }) {
if (s === REGEN_PREVIEW_STATUS) return <span className="admin-status is-generating"></span>;
if (s === "ready") return <span className="admin-status is-ok"></span>; if (s === "ready") return <span className="admin-status is-ok"></span>;
if (s === "failed") return <span className="admin-status is-error"></span>; if (s === "failed") return <span className="admin-status is-error"></span>;
if (s === "disabled") return <span className="admin-status"></span>; if (s === "disabled") return <span className="admin-status"></span>;
@@ -871,6 +954,12 @@ function formatDateTime(ms: number): string {
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
} }
function videoUpdatedAtMs(video?: api.AdminVideo): number {
if (!video?.updatedAt) return 0;
const value = Date.parse(video.updatedAt);
return Number.isFinite(value) ? value : 0;
}
function useVideosPageSize() { function useVideosPageSize() {
const [pageSize, setPageSize] = useState(() => const [pageSize, setPageSize] = useState(() =>
window.matchMedia(VIDEOS_MOBILE_QUERY).matches ? MOBILE_VIDEOS_PAGE_SIZE : DESKTOP_VIDEOS_PAGE_SIZE window.matchMedia(VIDEOS_MOBILE_QUERY).matches ? MOBILE_VIDEOS_PAGE_SIZE : DESKTOP_VIDEOS_PAGE_SIZE
+1
View File
@@ -1898,6 +1898,7 @@
.admin-status.is-ok { background: var(--success-soft); color: var(--success); } .admin-status.is-ok { background: var(--success-soft); color: var(--success); }
.admin-status.is-error { background: var(--danger-soft); color: var(--danger); } .admin-status.is-error { background: var(--danger-soft); color: var(--danger); }
.admin-status.is-pending { background: var(--warning-soft); color: var(--warning); } .admin-status.is-pending { background: var(--warning-soft); color: var(--warning); }
.admin-status.is-generating { background: var(--info-soft); color: var(--info); }
.admin-generation-state.is-generating { background: var(--info-soft); color: var(--info); } .admin-generation-state.is-generating { background: var(--info-soft); color: var(--info); }
.admin-generation-state.is-cooling { background: var(--warning-soft); color: var(--warning); } .admin-generation-state.is-cooling { background: var(--warning-soft); color: var(--warning); }
+16
View File
@@ -20,3 +20,19 @@ test("admin videos batch delete runs deletions sequentially", () => {
/Promise\.allSettled\(\s*ids\.map\(\(id\) => api\.deleteVideo\(id(?:, [^)]+)?\)\)\s*\)/ /Promise\.allSettled\(\s*ids\.map\(\(id\) => api\.deleteVideo\(id(?:, [^)]+)?\)\)\s*\)/
); );
}); });
test("admin videos show generating status after preview regeneration is accepted", () => {
assert.match(videosPageSource, /const REGEN_PREVIEW_STATUS = "generating";/);
assert.match(videosPageSource, /const \[regenPreviewById, setRegenPreviewById\]/);
assert.match(videosPageSource, /trackRegeneratingPreview\(\[v\]\)/);
assert.match(videosPageSource, /<PreviewStatus s=\{isPreviewGenerating\(v\) \? REGEN_PREVIEW_STATUS : v\.previewStatus\} \/>/);
assert.match(videosPageSource, /refreshListOnly\(\)/);
});
test("admin videos keep generating status after page refresh", () => {
assert.match(videosPageSource, /const hasGeneratingPreview = list\.some\(\(v\) => v\.previewStatus === REGEN_PREVIEW_STATUS\);/);
assert.match(videosPageSource, /if \(trackedRegenCount === 0 && !hasGeneratingPreview\) return;/);
assert.match(videosPageSource, /function isPreviewGenerating\(v: api\.AdminVideo\)/);
assert.match(videosPageSource, /return !!regenPreviewById\[v\.id\] \|\| v\.previewStatus === REGEN_PREVIEW_STATUS;/);
assert.match(videosPageSource, /disabled=\{isPreviewGenerating\(v\)\}/);
});