import { mkdir, stat, writeFile } from "node:fs/promises"; import path from "node:path"; import { BROWSE_ROOT, isSegment, songDir } from "../browse"; import { writeSpec } from "../spec"; // Start a song. Moved verbatim from app/api/browse/init/route.ts, which this // replaces; TypeScript because writeSpec is, and the CLI keeps refusing // `--kind song` for the same reason it cannot compute the song reducer. // // The tree has been made by hand five times, and each time something was left // out -- pokemon is the only one with a clips.csv, two have no vertical cut, and // three have plan/ directories whose contents nobody can now attribute. This // makes the shape once: the directories the scan expects, and a spec sheet with // the song's name in it, so the very first thing the UI shows is a sheet to // fill in rather than an empty page. // // It does NOT create empty cut files. A cut that does not exist must read as a // hole in the set -- that is the whole reason the cut list is fixed rather than // derived -- and a zero-byte wide.mp4 would read as a built one. const README = (id: string, title: string) => `# ${title} _New song, nothing built yet._ Fill in \`spec.json\` with what this song is made of — the background video, any one-off sounds, the drum map, the vocal hooks, the arrangement. What is declared there is what the browse UI will offer to do. | file | | |---|---| | \`wide.mp4\` | not built | | \`wide-short.mp4\` | not built | | \`vertical.mp4\` | not built | | \`vertical-short.mp4\` | not built | `; export class ScaffoldError extends Error { constructor(message: string, public status = 400) { super(message); } } export async function scaffoldSong({ slug, title }: { slug: string; title?: string | null }) { const id = (slug ?? "").trim().toLowerCase(); if (!isSegment(id)) { throw new ScaffoldError("a song name is letters, digits, dots, dashes and underscores — no slashes"); } const dir = songDir(id); // Containment check as well as the name check. songDir() joins under // BROWSE_ROOT, and isSegment() already rules out traversal, but a mkdir is // the first thing here that CREATES something and it gets both gates. if (dir !== path.join(BROWSE_ROOT, id)) throw new ScaffoldError("refusing to create outside the tree"); if (await stat(dir).then(() => true, () => false)) throw new ScaffoldError(`${id} already exists`, 409); const finalTitle = (title ?? "").trim() || id; await mkdir(path.join(dir, "plan"), { recursive: true }); await mkdir(path.join(dir, "variants"), { recursive: true }); await writeFile(path.join(dir, "README.md"), README(id, finalTitle)); const spec = await writeSpec(id, { version: 1, song: id, title: finalTitle }); return { song: id, spec, href: `/browse/${id}` }; }