"use client"; import { useRef, useState } from "react"; import { buttonVariants } from "@/components/ui/button"; // --------------------------------------------------------------------------- // "Fetch the clips nobody can watch yet." // // The walk skips a clip with nothing cached: a half-covered clip cannot be // watched through, so there is nothing to judge. That gap — `ready N of M` — // used to be closed one bench page at a time, pressing "fetch more" on every // row marked "not fetched yet". This is that, as one button. // // ONE AT A TIME, and it has to be. Each press is a download from somebody // else's server, the job runner serialises anyway (a second POST is a 409), and // the whole reason this now goes through the editor is that a burst is what // trips a bot check. So it awaits each job before starting the next, and says // which one it is on. // // STOP ABANDONS, IT DOES NOT KILL. The in-flight fetch is already paid for and // its bytes are worth keeping; what Stop means is "start no more". // --------------------------------------------------------------------------- type Pending = { id: string; padBefore: number; padAfter: number }; type JobView = { id: string; state: "running" | "done" | "failed"; error: string | null }; export default function FetchUnfetchedButton({ project, pending, }: { project: string; // The clips that need judgement and have nothing cached holding them, in // walk order. Computed on the server, where `fetched` already is. pending: Pending[]; }) { const [running, setRunning] = useState(false); const [done, setDone] = useState(0); const [msg, setMsg] = useState(null); const [failed, setFailed] = useState(false); const stop = useRef(false); if (!pending.length && !running) return null; const total = pending.length; // POLLED BY ID, not by "whatever is running". A bare GET answers with // `runningJob()`, which is null the moment the job ends — so a fetch that // FAILED between two polls would read as nothing running, i.e. as success. // // A null job for an id we were GIVEN is not "done" either: the runner has // forgotten a job it told us about, which is a state nobody can call a // success. Returned as null and treated as a failure by the caller. async function poll(id: string): Promise { for (;;) { await new Promise((r) => setTimeout(r, 1000)); const r = await fetch(`/api/report/fetch?job=${encodeURIComponent(id)}`, { cache: "no-store", }); const j = (await r.json()) as { job: JobView | null }; if (!j.job) return null; if (j.job.state !== "running") return j.job; } } async function run() { stop.current = false; setRunning(true); setFailed(false); setDone(0); setMsg(null); let i = 0; // A LOCAL FLAG, not the `failed` state: `run` closes over the value // `failed` had when this render was made, so reading it after setFailed(true) // reads `false` and the success line prints over a run that stopped. let ok = true; for (const clip of pending) { if (stop.current) { setMsg(`stopped after ${i} of ${total}`); break; } setMsg(`fetching ${clip.id} — ${i + 1} of ${total}`); const res = await fetch("/api/report/fetch", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ project, clip: clip.id, padBefore: clip.padBefore, padAfter: clip.padAfter, }), }); const body = (await res.json().catch(() => ({}))) as { error?: string; job?: JobView; cachedBefore?: number; cachedAfter?: number; cachedPad?: number; }; if (res.status === 409) { // TWO DIFFERENT 409s, and only one of them is a problem. // // "already cached to −N s / +N s" means the window arrived while this // sweep was running — which is the COMMON case here, because a // generous fetch for one clip routinely covers the next one in the // list. Treating it as a failure aborted the run on its own success. // The route marks that case with the cached pads; everything else // (notably "a job is already running") stops the sweep in the route's // own words. const alreadyCached = body.cachedBefore !== undefined || body.cachedPad !== undefined; if (alreadyCached) { i += 1; setDone(i); continue; } ok = false; setFailed(true); setMsg(body.error ?? "refused"); break; } if (!res.ok) { ok = false; setFailed(true); setMsg(body.error ?? `HTTP ${res.status}`); break; } if (!body.job?.id) { ok = false; setFailed(true); setMsg("the fetch route started no job"); break; } const finished = await poll(body.job.id); if (!finished || finished.state === "failed") { ok = false; setFailed(true); setMsg(`${clip.id}: ${finished?.error ?? "fetch failed"}`); break; } i += 1; setDone(i); } setRunning(false); if (ok && !stop.current && i === total) { setMsg(`fetched ${total} clip${total === 1 ? "" : "s"} — reload to see them`); } } return (
{running && ( )} {msg && ( {msg} )}
); }