429 lines
16 KiB
TypeScript
429 lines
16 KiB
TypeScript
"use client";
|
||
import Link from "next/link";
|
||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||
import { ArrowLeft, Captions, Loader2, Play, RefreshCw, Square, Sparkles } from "lucide-react";
|
||
import { thumbUrl } from "@/lib/assetUrls";
|
||
import { cn } from "@/lib/utils";
|
||
|
||
interface Candidate {
|
||
id: number;
|
||
code: string;
|
||
title: string | null;
|
||
thumbPath: string;
|
||
}
|
||
|
||
interface QueueState {
|
||
queued: number;
|
||
running: {
|
||
id: string;
|
||
code: string;
|
||
started_at: number | null;
|
||
stage: string | null;
|
||
stage_index: number | null;
|
||
stage_total: number | null;
|
||
} | null;
|
||
}
|
||
|
||
const PAGE_SIZE = 100;
|
||
|
||
export function BatchGeneratorClient() {
|
||
const [candidates, setCandidates] = useState<Candidate[]>([]);
|
||
const [total, setTotal] = useState(0);
|
||
const [loading, setLoading] = useState(true);
|
||
const [page, setPage] = useState(0);
|
||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||
const [queueState, setQueueState] = useState<QueueState>({ queued: 0, running: null });
|
||
const [enqueuing, setEnqueuing] = useState(false);
|
||
const [stopping, setStopping] = useState(false);
|
||
const [batchSize, setBatchSize] = useState<number>(5);
|
||
const [lastResult, setLastResult] = useState<string | null>(null);
|
||
|
||
const loadPage = useCallback(async (p: number) => {
|
||
setLoading(true);
|
||
try {
|
||
const r = await fetch(
|
||
`/api/whisperjav-candidates?limit=${PAGE_SIZE}&offset=${p * PAGE_SIZE}`,
|
||
{ cache: "no-store" },
|
||
);
|
||
const j = (await r.json()) as { candidates: Candidate[]; total: number };
|
||
setCandidates(j.candidates ?? []);
|
||
setTotal(j.total ?? 0);
|
||
} catch {
|
||
setCandidates([]);
|
||
setTotal(0);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => { loadPage(page); }, [page, loadPage]);
|
||
|
||
// Poll queue state every 3s while the tab is visible. Hidden tabs
|
||
// pause the interval (no point hammering the API when the UI isn't
|
||
// on screen) and tick once on visibility-restore so the user sees
|
||
// fresh state immediately.
|
||
useEffect(() => {
|
||
let live = true;
|
||
let interval: ReturnType<typeof setInterval> | null = null;
|
||
const tick = async () => {
|
||
try {
|
||
const r = await fetch("/api/whisperjav-jobs/batch", { cache: "no-store" });
|
||
if (!r.ok) return;
|
||
const j = (await r.json()) as QueueState;
|
||
if (live) setQueueState(j);
|
||
} catch { /* ignore */ }
|
||
};
|
||
const start = () => {
|
||
if (interval != null) return;
|
||
interval = setInterval(tick, 3000);
|
||
};
|
||
const stop = () => {
|
||
if (interval == null) return;
|
||
clearInterval(interval);
|
||
interval = null;
|
||
};
|
||
const onVisibility = () => {
|
||
if (document.hidden) {
|
||
stop();
|
||
} else {
|
||
void tick();
|
||
start();
|
||
}
|
||
};
|
||
void tick();
|
||
if (!document.hidden) start();
|
||
document.addEventListener("visibilitychange", onVisibility);
|
||
return () => {
|
||
live = false;
|
||
stop();
|
||
document.removeEventListener("visibilitychange", onVisibility);
|
||
};
|
||
}, []);
|
||
|
||
const toggleAll = (checked: boolean) => {
|
||
if (!checked) {
|
||
// Only clear codes from the current page so other-page selections persist.
|
||
const here = new Set(candidates.map((c) => c.code));
|
||
setSelected((prev) => {
|
||
const next = new Set(prev);
|
||
for (const c of here) next.delete(c);
|
||
return next;
|
||
});
|
||
} else {
|
||
setSelected((prev) => {
|
||
const next = new Set(prev);
|
||
for (const c of candidates) next.add(c.code);
|
||
return next;
|
||
});
|
||
}
|
||
};
|
||
|
||
const toggleOne = (code: string) => {
|
||
setSelected((prev) => {
|
||
const next = new Set(prev);
|
||
if (next.has(code)) next.delete(code);
|
||
else next.add(code);
|
||
return next;
|
||
});
|
||
};
|
||
|
||
const queueSelected = async (codesArg?: string[]) => {
|
||
const codes = codesArg ?? Array.from(selected);
|
||
if (codes.length === 0) return;
|
||
setEnqueuing(true);
|
||
setLastResult(null);
|
||
try {
|
||
const r = await fetch("/api/whisperjav-jobs/batch", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ codes }),
|
||
});
|
||
const j = (await r.json()) as { enqueued: number; skipped: number; errors: Array<{ code: string; error: string }> };
|
||
const summary = `Queued ${j.enqueued}${j.skipped ? `, skipped ${j.skipped} (already have subs)` : ""}${j.errors.length ? `, ${j.errors.length} failed` : ""}`;
|
||
setLastResult(summary);
|
||
// Drop just-queued codes from the selection so the user can keep
|
||
// moving down the list without manually unchecking.
|
||
setSelected((prev) => {
|
||
const next = new Set(prev);
|
||
for (const c of codes) next.delete(c);
|
||
return next;
|
||
});
|
||
// Refresh candidates so any newly-queued items can be removed
|
||
// from the list once they actually produce a subtitle.
|
||
void loadPage(page);
|
||
} catch (e) {
|
||
setLastResult(`Failed: ${(e as Error).message}`);
|
||
} finally {
|
||
setEnqueuing(false);
|
||
}
|
||
};
|
||
|
||
const queueNextN = () => {
|
||
// Walk the current page in order, skipping codes already selected
|
||
// (they'll go through queueSelected anyway) and codes already in
|
||
// the queue (we don't track them here; server-side check is the
|
||
// source of truth via alreadyExists).
|
||
const picks: string[] = [];
|
||
for (const c of candidates) {
|
||
if (picks.length >= batchSize) break;
|
||
picks.push(c.code);
|
||
}
|
||
void queueSelected(picks);
|
||
};
|
||
|
||
const stopBatch = async () => {
|
||
if (!window.confirm("Cancel all queued WhisperJAV jobs?\n\nThe currently running job is not affected — cancel it from the player.")) return;
|
||
setStopping(true);
|
||
try {
|
||
const r = await fetch("/api/whisperjav-jobs/batch", { method: "DELETE" });
|
||
const j = (await r.json()) as { cancelled: number };
|
||
setLastResult(`Cancelled ${j.cancelled} queued job(s).`);
|
||
} catch (e) {
|
||
setLastResult(`Failed: ${(e as Error).message}`);
|
||
} finally {
|
||
setStopping(false);
|
||
}
|
||
};
|
||
|
||
const allOnPageSelected = candidates.length > 0
|
||
&& candidates.every((c) => selected.has(c.code));
|
||
const noneOnPageSelected = candidates.length > 0
|
||
&& candidates.every((c) => !selected.has(c.code));
|
||
const totalPages = Math.ceil(total / PAGE_SIZE);
|
||
const [now, setNow] = useState(() => Date.now());
|
||
useEffect(() => {
|
||
if (!queueState.running?.started_at) return;
|
||
if (typeof document !== "undefined" && document.hidden) return;
|
||
const i = setInterval(() => setNow(Date.now()), 1000);
|
||
return () => clearInterval(i);
|
||
}, [queueState.running]);
|
||
const runningElapsed = useMemo(() => {
|
||
const r = queueState.running;
|
||
if (!r || !r.started_at) return null;
|
||
return Math.floor((now - r.started_at) / 1000);
|
||
}, [queueState.running, now]);
|
||
|
||
return (
|
||
<div className="space-y-section">
|
||
<div className="flex items-center justify-between gap-4">
|
||
<Link
|
||
href="/"
|
||
className="inline-flex items-center gap-1.5 text-sm text-[var(--color-fg-dim)] hover:text-[var(--color-fg)]"
|
||
>
|
||
<ArrowLeft className="w-4 h-4" /> Back to library
|
||
</Link>
|
||
<button
|
||
type="button"
|
||
onClick={() => loadPage(page)}
|
||
disabled={loading}
|
||
title="Refresh candidate list"
|
||
className="inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg glass glass-hover"
|
||
>
|
||
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
|
||
Refresh
|
||
</button>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2.5">
|
||
<Captions className="w-5 h-5 text-[var(--color-cyan)]" />
|
||
<h1 className="text-xl font-semibold tracking-tight">Batch Subtitle Generation</h1>
|
||
</div>
|
||
<div className="text-sm text-[var(--color-fg-muted)] max-w-3xl -mt-3">
|
||
Codes with a playable video but no subtitle file. Pick a batch size
|
||
(each video is roughly 1–3 hours of generation time on a single GPU)
|
||
and queue it. Jobs run sequentially via the existing single-worker
|
||
WhisperJAV queue.
|
||
</div>
|
||
|
||
{/* Live queue state */}
|
||
<div className="glass rounded-2xl p-4 flex items-center gap-4">
|
||
<div className="text-[10px] uppercase tracking-wider font-mono text-[var(--color-fg-muted)] shrink-0">
|
||
Queue
|
||
</div>
|
||
{queueState.running ? (
|
||
<div className="flex items-center gap-2 min-w-0">
|
||
<Loader2 className="w-4 h-4 animate-spin text-[var(--color-coral)] shrink-0" />
|
||
<span className="font-mono text-sm text-[var(--color-coral)]">
|
||
{queueState.running.code}
|
||
</span>
|
||
<span className="text-xs text-[var(--color-fg-dim)] truncate">
|
||
{queueState.running.stage
|
||
? (queueState.running.stage_index && queueState.running.stage_total
|
||
? `· Step ${queueState.running.stage_index}/${queueState.running.stage_total}: ${queueState.running.stage}`
|
||
: `· ${queueState.running.stage}`)
|
||
: "· Starting..."}
|
||
</span>
|
||
{runningElapsed != null && (
|
||
<span className="text-xs font-mono text-[var(--color-fg-dim)] shrink-0">
|
||
· {Math.floor(runningElapsed / 60)}m{(runningElapsed % 60).toString().padStart(2, "0")}s
|
||
</span>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<div className="text-sm text-[var(--color-fg-dim)]">Idle</div>
|
||
)}
|
||
<div className="ml-auto flex items-center gap-2 shrink-0">
|
||
<span className="text-sm font-mono">
|
||
{queueState.queued} queued
|
||
</span>
|
||
<button
|
||
type="button"
|
||
onClick={stopBatch}
|
||
disabled={queueState.queued === 0 || stopping}
|
||
className="inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg border border-red-500/30 text-red-300 hover:bg-red-500/10 disabled:opacity-40"
|
||
>
|
||
{stopping ? <Loader2 className="w-4 h-4 animate-spin" /> : <Square className="w-4 h-4" />}
|
||
Stop Batch
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Action bar */}
|
||
<div className="glass rounded-2xl p-4 flex items-center gap-3 flex-wrap">
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-xs text-[var(--color-fg-dim)] uppercase tracking-wider font-mono">Batch size</span>
|
||
<input
|
||
type="number"
|
||
min={1}
|
||
max={50}
|
||
value={batchSize}
|
||
onChange={(e) => setBatchSize(Math.max(1, Math.min(50, Number(e.target.value) || 1)))}
|
||
className="w-20 glass rounded-lg px-3 py-1.5 text-sm font-mono outline-none focus:border-[var(--color-cyan)]"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={queueNextN}
|
||
disabled={enqueuing || candidates.length === 0}
|
||
className="inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg bg-[var(--color-cyan)] text-black font-medium disabled:opacity-40"
|
||
>
|
||
<Sparkles className="w-4 h-4" /> Queue Next {Math.min(batchSize, candidates.length)}
|
||
</button>
|
||
</div>
|
||
<div className="ml-auto flex items-center gap-2">
|
||
<span className="text-xs text-[var(--color-fg-dim)]">{selected.size} selected</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => queueSelected()}
|
||
disabled={enqueuing || selected.size === 0}
|
||
className="inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg glass glass-hover disabled:opacity-40"
|
||
>
|
||
{enqueuing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />}
|
||
Queue Selected
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setSelected(new Set())}
|
||
disabled={selected.size === 0}
|
||
className="text-xs px-2 py-1 rounded-md text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)] disabled:opacity-40"
|
||
>
|
||
Clear
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{lastResult && (
|
||
<div className="text-xs text-[var(--color-mint)] bg-[var(--color-mint)]/5 border border-[var(--color-mint)]/25 rounded-lg px-3 py-2">
|
||
{lastResult}
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex items-baseline justify-between gap-3 text-xs text-[var(--color-fg-dim)] font-mono">
|
||
<span>
|
||
{total > 0
|
||
? `Showing ${page * PAGE_SIZE + 1}–${Math.min((page + 1) * PAGE_SIZE, total)} of ${total} candidates`
|
||
: (loading ? "Loading..." : "No candidates — every video has a subtitle")}
|
||
</span>
|
||
{totalPages > 1 && (
|
||
<div className="flex items-center gap-1">
|
||
<button
|
||
type="button"
|
||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||
disabled={page === 0 || loading}
|
||
className="px-2 py-0.5 rounded glass glass-hover disabled:opacity-40"
|
||
>
|
||
Prev
|
||
</button>
|
||
<span className="px-2">Page {page + 1} / {totalPages}</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
|
||
disabled={page >= totalPages - 1 || loading}
|
||
className="px-2 py-0.5 rounded glass glass-hover disabled:opacity-40"
|
||
>
|
||
Next
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Candidate table */}
|
||
<div className="glass rounded-2xl overflow-hidden">
|
||
<div className="grid grid-cols-[40px_60px_1fr_120px] gap-3 items-center px-4 py-2 border-b border-[var(--color-glass-border)] text-[10px] uppercase tracking-wider font-mono text-[var(--color-fg-muted)]">
|
||
<input
|
||
type="checkbox"
|
||
checked={allOnPageSelected}
|
||
ref={(el) => { if (el) el.indeterminate = !allOnPageSelected && !noneOnPageSelected; }}
|
||
onChange={(e) => toggleAll(e.target.checked)}
|
||
aria-label="Select all on this page"
|
||
/>
|
||
<span>Cover</span>
|
||
<span>Code · Title</span>
|
||
<span className="text-right">Action</span>
|
||
</div>
|
||
{loading && candidates.length === 0 && (
|
||
<div className="px-4 py-8 text-center text-[var(--color-fg-muted)]">
|
||
<Loader2 className="w-5 h-5 animate-spin mx-auto" />
|
||
</div>
|
||
)}
|
||
{!loading && candidates.length === 0 && (
|
||
<div className="px-4 py-8 text-center text-[var(--color-fg-muted)] text-sm">
|
||
No videos missing subtitles 🎉
|
||
</div>
|
||
)}
|
||
{candidates.map((c) => {
|
||
const isSelected = selected.has(c.code);
|
||
return (
|
||
<label
|
||
key={c.id}
|
||
className={cn(
|
||
"grid grid-cols-[40px_60px_1fr_120px] gap-3 items-center px-4 py-2 border-b border-[var(--color-glass-border)] cursor-pointer transition-colors",
|
||
isSelected ? "bg-[var(--color-cyan)]/10" : "hover:bg-[var(--color-glass)]",
|
||
)}
|
||
>
|
||
<input
|
||
type="checkbox"
|
||
checked={isSelected}
|
||
onChange={() => toggleOne(c.code)}
|
||
aria-label={`Select ${c.code}`}
|
||
/>
|
||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||
<img
|
||
src={thumbUrl({ thumbPath: c.thumbPath, code: c.code, id: c.id })}
|
||
alt=""
|
||
className="w-12 h-8 object-cover rounded"
|
||
/>
|
||
<div className="min-w-0">
|
||
<div className="font-mono text-sm font-semibold text-[var(--color-cyan)] truncate">
|
||
{c.code}
|
||
</div>
|
||
{c.title && (
|
||
<div className="text-xs text-[var(--color-fg-dim)] truncate">{c.title}</div>
|
||
)}
|
||
</div>
|
||
<div className="text-right">
|
||
<Link
|
||
href={`/id/${encodeURIComponent(c.code)}`}
|
||
onClick={(e) => e.stopPropagation()}
|
||
className="inline-flex items-center gap-1 text-xs text-[var(--color-fg-dim)] hover:text-[var(--color-fg)]"
|
||
>
|
||
Open →
|
||
</Link>
|
||
</div>
|
||
</label>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|