import { mkdir, rename, stat } from "node:fs/promises"; import path from "node:path"; import { attributeVariant, readSong, readVerdicts, resolveRendition, songDir, verdictsFile, type CutName, type Song, type VerdictMap, } from "@/lib/browse"; import { withStateLock, writeJsonAtomic } from "@/lib/state"; import { carryNotes } from "@/lib/notes"; export const dynamic = "force-dynamic"; // Promote, retire, restore -- the three moves that change what ships. // // EVERY ONE OF THEM IS A rename(), AND NOTHING IS EVER DELETED. // // Promoting does not overwrite the current cut; it moves it into variants/ // first, under a name that still attributes back to the same cut, so the thing // that was shipping is one click from shipping again. That reversibility is not // theoretical: the unsliced `*-longintro.mp4` copies in this tree exist because // a slice had to be undone once already. // // Retiring moves to variants/retired/ rather than deleting, because pruning // mp4s is a decision the user makes by hand and this tool should not take it. type Action = "promote" | "retire" | "restore"; const ACTIONS: Action[] = ["promote", "retire", "restore"]; /** `2026-08-17T13:44:02Z` -> `20260817-1344`. */ function stamp(d = new Date()): string { const p = (n: number) => String(n).padStart(2, "0"); return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}`; } /** A note turned into a filename fragment, or "" if nothing usable survives. */ function slugFromNote(note: string | undefined): string { if (!note) return ""; return note .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, "") .split("-") .slice(0, 3) .join("-") .slice(0, 28); } const exists = (p: string) => stat(p).then( () => true, () => false, ); /** * A name for the cut being moved aside. * * The tag must not change which cut the file attributes to. `wide` + a note of * "short intro" would give `wide-short-intro.mp4`, which longest-prefix binds * to `wide-short` -- the demoted file would reappear under a DIFFERENT cut. * So every candidate is put back through attributeVariant() and rejected if it * does not come back to where it started. */ async function demotedName( dir: string, cut: CutName, ext: string, note: string | undefined, ): Promise { const at = stamp(); const slug = slugFromNote(note); const candidates = [ ...(slug ? [`${cut}-prev-${slug}-${at}`] : []), `${cut}-prev-${at}`, // Last resort, if two promotes land inside the same minute. `${cut}-prev-${at}-${process.hrtime.bigint().toString(36).slice(-4)}`, ]; for (const base of candidates) { if (attributeVariant(base).cut !== cut) continue; if (await exists(path.join(dir, "variants", `${base}${ext}`))) continue; return `${base}${ext}`; } throw new Error("could not find a free name for the outgoing cut"); } function findRendition(song: Song, rel: string) { return ( [...song.cuts.flatMap((c) => [c.shipped, ...c.variants]), ...song.unattributed].find( (r) => r && r.rel === rel, ) ?? null ); } export async function POST(request: Request) { let body: { song?: string; rel?: string; action?: string }; try { body = await request.json(); } catch { return Response.json({ error: "bad json" }, { status: 400 }); } const { song: id, rel, action } = body; if (!id || !rel) return Response.json({ error: "song and rel are required" }, { status: 400 }); if (!action || !ACTIONS.includes(action as Action)) { return Response.json({ error: `action must be one of ${ACTIONS.join(", ")}` }, { status: 400 }); } const song = await readSong(id); if (!song) return Response.json({ error: "no such song" }, { status: 404 }); const found = findRendition(song, rel); if (!found) return Response.json({ error: "not a file in this song" }, { status: 404 }); // Resolve through resolveInRoots as a second gate. The name checks above // already make traversal impossible; this catches the other way out of the // tree -- a symlink, or a SONG_REPORTS_DIR that points somewhere unexpected. const abs = resolveRendition(id, rel); if (!abs) return Response.json({ error: "outside the roots" }, { status: 400 }); const dir = songDir(id); const ext = path.extname(rel); const moved: { from: string; to: string }[] = []; try { await withStateLock(async () => { const verdicts = await readVerdicts(id); if (action === "promote") { const { cut } = attributeVariant(found.base); if (!cut) { throw new Error( `${found.base} names no cut — rename it - first (cuts: ${song.cuts .map((c) => c.name) .join(", ")})`, ); } if (!rel.startsWith("variants/")) throw new Error("only a variant can be promoted"); const target = path.join(dir, `${cut}${ext}`); const current = song.cuts.find((c) => c.name === cut)?.shipped ?? null; if (current) { await mkdir(path.join(dir, "variants"), { recursive: true }); const name = await demotedName(dir, cut, path.extname(current.rel), current.verdict?.note); await rename(path.join(dir, current.rel), path.join(dir, "variants", name)); moved.push({ from: current.rel, to: `variants/${name}` }); carry(verdicts, current.rel, `variants/${name}`); } // After moving the incumbent aside the destination must be free. If it // is not, something changed under us between the scan and now -- stop // rather than clobber whatever arrived. if (await exists(target)) throw new Error(`${cut}${ext} still exists — refusing to overwrite`); await rename(abs, target); moved.push({ from: rel, to: `${cut}${ext}` }); carry(verdicts, rel, `${cut}${ext}`); } if (action === "retire") { if (!rel.startsWith("variants/") || rel.startsWith("variants/retired/")) { // A missing shipped cut is a hole in the deliverable set, and the only // sane way to empty one is to promote something else into it. throw new Error("only a live variant can be retired"); } const to = path.posix.join("variants/retired", path.basename(rel)); await mkdir(path.join(dir, "variants", "retired"), { recursive: true }); if (await exists(path.join(dir, to))) throw new Error(`${to} already exists`); await rename(abs, path.join(dir, to)); moved.push({ from: rel, to }); carry(verdicts, rel, to); } if (action === "restore") { if (!rel.startsWith("variants/retired/")) throw new Error("only a retired variant can be restored"); const to = path.posix.join("variants", path.basename(rel)); if (await exists(path.join(dir, to))) throw new Error(`${to} already exists`); await rename(abs, path.join(dir, to)); moved.push({ from: rel, to }); carry(verdicts, rel, to); } await writeJsonAtomic(verdictsFile(id), verdicts); }); } catch (e) { return Response.json( { error: e instanceof Error ? e.message : String(e), moved }, { status: 409 }, ); } // Notes follow the bytes, exactly as verdicts do -- including the timestamped // marks, whose keys embed the file's name. Done AFTER the moves land so a // failed rename cannot leave notes pointing at a file that never moved. await carryNotes(id, moved); return Response.json({ ok: true, moved }, { headers: { "cache-control": "no-store" } }); } /** Follow a judgement to the file's new name. A verdict is about the bytes. */ function carry(verdicts: VerdictMap, from: string, to: string) { const entry = verdicts[from]; if (!entry) return; delete verdicts[from]; verdicts[to] = entry; }