Files
pinkudex/components/subtitles/BatchGeneratorClient.tsx
T
2026-05-26 22:46:00 +02:00

429 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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 13 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>
);
}