mirror of
https://github.com/nianzhibai/91.git
synced 2026-06-15 08:45:41 +08:00
fix: show active preview generation status
This commit is contained in:
@@ -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 == "" {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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{
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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); }
|
||||||
|
|||||||
@@ -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\)\}/);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user