import path from "node:path"; import { isSegment, readSong, thumbAliasesFor } from "@/lib/browse"; import { SONG_CODE, resolveInRoots } from "@/lib/paths"; import { readThumbAccepted, readThumbManifest, thumbNamesFor, thumbView, wouldClash } from "@/lib/thumbs"; import { jobView, startJob } from "@/lib/jobs"; export const dynamic = "force-dynamic"; // --------------------------------------------------------------------------- // The cover art bench: a READER, and a proposer that writes through the CLI. // // song/accept-thumb.mjs is the sole writer of thumb-accepted.json. It enforces // the unique-face rule at write time and it writes with a bare writeFileSync -- // no lock, no tmp+rename. Making this route a second writer would put two // writers with different atomicity guarantees on the one file whose entire job // is to be the authority, so accepting from the UI RUNS THE SCRIPT through // lib/jobs.ts instead. The rule stays in one place, and its refusal message // reaches the screen verbatim. // // wouldClash() is a pure re-implementation of the same check, run before the // job starts. That is not redundancy: it is what lets the button say "this // would clash with mario-rpg" while it is still unpressed, instead of failing // afterwards. // --------------------------------------------------------------------------- export async function GET(request: Request) { const id = new URL(request.url).searchParams.get("song") ?? ""; if (!isSegment(id)) return Response.json({ error: "bad song" }, { status: 400 }); return Response.json(await thumbView(id, thumbAliasesFor(id)), { headers: { "cache-control": "no-store" }, }); } export async function POST(request: Request) { let body: { song?: string; name?: string; file?: string }; try { body = await request.json(); } catch { return Response.json({ error: "bad json" }, { status: 400 }); } const { song: id, name, file } = body; if (!id || !name) return Response.json({ error: "song and name are required" }, { status: 400 }); if (!isSegment(id) || !isSegment(name)) { return Response.json({ error: "bad name" }, { status: 400 }); } if (!(await readSong(id))) return Response.json({ error: "no such song" }, { status: 404 }); const [generated, accepted] = await Promise.all([readThumbManifest(), readThumbAccepted()]); const entry = generated.thumbs[name] ?? accepted.thumbs[name]; if (!entry) { return Response.json({ error: `no generated cover named ${name}` }, { status: 404 }); } // A cover belongs to the song whose page proposed it. Nothing prevents a // request naming another song's cover, and accepting one from the wrong page // would spend that song's four videos without saying so. if (!thumbNamesFor(generated, id, thumbAliasesFor(id)).includes(name) && !accepted.thumbs[name]) { return Response.json({ error: `${name} is not a cover for ${id}` }, { status: 400 }); } const clash = wouldClash(accepted, name, entry); if (clash) { // Refuse here rather than let the job fail: the answer is the same, and // this way nothing is started and the reason is in the response. return Response.json({ error: `would break the unique-face rule — ${clash}` }, { status: 409 }); } // `file` names WHICH RENDERED VARIANT is being accepted, and accept-thumb // records it as the new `out` (relative to SONG_REPORTS when inside it). It // must resolve inside the roots, and it is passed absolute so the script // cannot resolve it against its own cwd. const argv = [process.execPath, path.join(SONG_CODE, "accept-thumb.mjs"), name]; if (file) { const abs = resolveInRoots(file); if (!abs) return Response.json({ error: "file is not inside a known root" }, { status: 400 }); argv.push(abs); } try { const job = startJob("accept-thumb", [ { label: `accept ${name}`, argv, cwd: SONG_CODE, env: {} }, ]); return Response.json({ ok: true, job: jobView(job) }, { headers: { "cache-control": "no-store" } }); } catch (e) { // "a job is already running" is the trim chain holding the runner. Say so. return Response.json({ error: e instanceof Error ? e.message : String(e) }, { status: 409 }); } }