import { cancelJob, getJob, jobView, runningJob, startJob } from "@/lib/jobs"; import { applyRulingsSteps, cutSteps, moveDeliverablesSteps, rebuildReportSteps, shareBatchSteps, } from "@/lib/report/driver.mjs"; import { SHARE_PREFIX, deliverStateOf } from "@/lib/report/deliver.mjs"; import { deliverablesProblems, isDeliverableName } from "@/lib/report/storage.mjs"; import { readClipDetail } from "@/lib/projects/report.mjs"; import { projectRef } from "@/lib/projects"; import type { Step } from "@/lib/trim"; export const dynamic = "force-dynamic"; // DELIVERING a walked report: the four things that happen after the last clip // is judged. // // cut the confirmed clips that have no mp4 yet, out of the cache // share a batch of those, in three encodes, for whoever is writing // apply the project's own apply-manifest.py, then `umtool corrections` // rebuild build.py, once per content variant present // move the deliverables (clips/, every share-*) to the media root or // back, then the manifest's storage.deliverables (release 17) -- // a job like the others, so nothing cuts or shares while it moves // // Each is a JOB, and jobs.ts allows exactly one at a time. That is not a // limitation to work around here: every one of these reads or writes the same // clips/ directory that a bench fetch writes into, and two of them at once // would race over the same file. A second request gets 409 with the name of // what is running, in the same words /api/report/build uses. // // The client sends a project id, an action, and at most a batch name. Never a // path and never an argv: the step list is built server-side from the // directory's own contents, which is what stops "rebuild" from being able to // run an arbitrary python file. const ACTIONS = ["cut", "share", "apply", "rebuild", "move"] as const; type Action = (typeof ACTIONS)[number]; export async function GET(request: Request) { const url = new URL(request.url); const headers = { "cache-control": "no-store" }; const id = url.searchParams.get("job"); if (id) { const job = getJob(id); if (!job) return Response.json({ error: "no such job" }, { status: 404, headers }); return Response.json(jobView(job, Number(url.searchParams.get("since") ?? 0)), { headers }); } // "Is anything running" is the ONLY question the panel asks on mount, and it // is not worth a second full read of the project: the state below re-reads // every cue file, every share folder and every content module, and the // server component beside it has just done exactly that. The job registry is // process-wide, so this needs no project at all -- the caller compares the // job's own `project` field. if (url.searchParams.get("running") === "1") { const r = runningJob(); return Response.json({ running: r ? jobView(r) : null }, { headers }); } const projectId = url.searchParams.get("project") ?? ""; const project = await projectRef(projectId); if (!project) return Response.json({ error: "no such project" }, { status: 404, headers }); const detail = await readClipDetail(project.dir); if (!detail) return Response.json({ error: "no manifest" }, { status: 400, headers }); const state = await deliverStateOf(project, { manifest: detail.manifest, entries: detail.entries, }); const running = runningJob(); return Response.json({ ...state, running: running ? jobView(running) : null }, { headers }); } export async function POST(request: Request) { const url = new URL(request.url); const cancel = url.searchParams.get("cancel"); // Stop ABANDONS THE REST, it does not undo what finished. Every artefact // here is content-addressed by clip id, so a cancelled cut is a paused one: // pressing the button again skips the clips that already have a file. if (cancel) return Response.json({ cancelled: cancelJob(cancel) }); const body = (await request.json().catch(() => ({}))) as Record; const action = String(body.action ?? "") as Action; if (!ACTIONS.includes(action)) { return Response.json({ error: `action must be one of ${ACTIONS.join(", ")}` }, { status: 400 }); } const project = await projectRef(String(body.project ?? "")); if (!project) return Response.json({ error: "no such project" }, { status: 404 }); const detail = await readClipDetail(project.dir); if (!detail) return Response.json({ error: "no manifest" }, { status: 400 }); const state = await deliverStateOf(project, { manifest: detail.manifest, entries: detail.entries, }); if (!state) return Response.json({ error: "no manifest" }, { status: 400 }); let steps: Step[] = []; let kind = ""; if (action === "cut") { // A clips/ on a drive that is not there reads as "nothing cut", and the // list below would be every confirmed clip: refuse with the reason instead. if (state.storage.cutBlocked.length) { return Response.json({ error: state.storage.cutBlocked.join("; "), ok: false }, { status: 409 }); } // The list is the SERVER's: confirmed, no mp4, and a window on this disk // that holds it. A client-supplied list could name a clip nobody judged. const ids = state.needCut.map((c: { id: string }) => c.id); if (!ids.length) { return Response.json( { error: "every confirmed clip already has a file — nothing to cut", ok: false }, { status: 400 }, ); } steps = cutSteps(project, ids); kind = `cut ${project.id} (${ids.length} clips)`; } else if (action === "share") { const name = String(body.name ?? state.nextName); if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name) || !isDeliverableName(`${SHARE_PREFIX}${name}`)) { return Response.json( { error: "a batch name is letters, digits, dot, dash and underscore, not ending .incoming or .moved-…" }, { status: 400 }, ); } const blocked = deliverablesProblems(state.storage, `${SHARE_PREFIX}${name}`); if (blocked.length) { return Response.json({ error: blocked.join("; "), ok: false }, { status: 409 }); } if (!state.candidates.length) { return Response.json( { error: "nothing to ship: every confirmed clip is already shared, or not cut yet" }, { status: 400 }, ); } steps = shareBatchSteps(project, name); kind = `share-${name} ${project.id} (${state.candidates.length} clips)`; } else if (action === "apply") { if (!state.hasApplyScript) { return Response.json( { error: "this project has no apply-manifest.py — there is nothing to fold back into" }, { status: 400 }, ); } // REFUSED WHILE THE WALK IS UNFINISHED, and this is the one guard here // that is about judgement rather than about files. apply-manifest.py syncs // clips.json from the manifest and deletes the mp4 of every clip whose // window moved; running it over a half-walked cut bakes "nobody has looked // at this yet" into the deliverable as though it were a verdict. `partial` // is how somebody says they meant it. if (state.review.unreviewed > 0 && !body.partial) { return Response.json( { error: `${state.review.unreviewed} of ${state.review.total} clips have not been judged — ` + "finish the walk, or tick “apply partial” to fold back what there is", unreviewed: state.review.unreviewed, needsPartial: true, }, { status: 409 }, ); } steps = applyRulingsSteps(project); kind = `apply rulings ${project.id}`; } else if (action === "move") { const to = String(body.to ?? ""); if (to !== "media" && to !== "local") { return Response.json({ error: "move needs `to`: media or local" }, { status: 400 }); } if (to === "media" && !state.storage.tiered) { return Response.json( { error: "UMTOOL_MEDIA_DIR is not set in umtool's environment — there is no media root to move to" }, { status: 400 }, ); } steps = moveDeliverablesSteps(project, to); kind = `move deliverables ${project.id} to ${to}`; } else { if (!state.hasBuildScript || !state.variants.length) { return Response.json( { error: "this project has no build.py and content*.py to render" }, { status: 400 }, ); } steps = rebuildReportSteps(project, state.variants); kind = `rebuild ${project.id} (${state.variants.length} variants)`; } const running = runningJob(); if (running) { return Response.json( { error: `a job is already running (${running.kind})`, running: jobView(running) }, { status: 409 }, ); } try { const job = startJob(kind, steps, { project: project.id }); return Response.json( { ok: true, action, job: jobView(job) }, { headers: { "cache-control": "no-store" } }, ); } catch (e) { return Response.json({ error: e instanceof Error ? e.message : String(e) }, { status: 409 }); } }