"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([]); const [total, setTotal] = useState(0); const [loading, setLoading] = useState(true); const [page, setPage] = useState(0); const [selected, setSelected] = useState>(new Set()); const [queueState, setQueueState] = useState({ queued: 0, running: null }); const [enqueuing, setEnqueuing] = useState(false); const [stopping, setStopping] = useState(false); const [batchSize, setBatchSize] = useState(5); const [lastResult, setLastResult] = useState(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 | 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 (
Back to library

Batch Subtitle Generation

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.
{/* Live queue state */}
Queue
{queueState.running ? (
{queueState.running.code} {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..."} {runningElapsed != null && ( · {Math.floor(runningElapsed / 60)}m{(runningElapsed % 60).toString().padStart(2, "0")}s )}
) : (
Idle
)}
{queueState.queued} queued
{/* Action bar */}
Batch size 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)]" />
{selected.size} selected
{lastResult && (
{lastResult}
)}
{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")} {totalPages > 1 && (
Page {page + 1} / {totalPages}
)}
{/* Candidate table */}
{ if (el) el.indeterminate = !allOnPageSelected && !noneOnPageSelected; }} onChange={(e) => toggleAll(e.target.checked)} aria-label="Select all on this page" /> Cover Code · Title Action
{loading && candidates.length === 0 && (
)} {!loading && candidates.length === 0 && (
No videos missing subtitles 🎉
)} {candidates.map((c) => { const isSelected = selected.has(c.code); return ( ); })}
); }