import { readdir, readFile, stat } from "node:fs/promises"; import path from "node:path"; import { SONG_REPORTS, labelFor, resolveInRoots } from "./paths"; import { probeMedia, type MediaInfo } from "./media"; import { readJson } from "./state"; import { CUT_NAMES as CUT_NAMES_RAW } from "./projects/song.mjs"; import { songIdsUnder } from "./projects/song-ids.mjs"; import { REPORTS_ROOT } from "./paths"; import type { CutName } from "./project-types"; import { acceptedFor, readThumbAccepted, type ThumbDoc } from "./thumbs"; // --------------------------------------------------------------------------- // The deliverables tree, read as a browsable thing. // // videos// // wide.mp4 wide-short.mp4 vertical.mp4 vertical-short.mp4 the CUTS // variants/-.mp4 alternatives awaiting a decision // variants/retired/ kept, not deleted // plan/*.json per-note provenance // README.md clips.csv verdicts.json // // No database. The filesystem is the model, and a tree of this shape carries // its own judgements -- which is why verdicts.json lives HERE and not in // umtool/song/ with the clip state. Copy the directory and the verdicts come // with it. // --------------------------------------------------------------------------- /** The root the whole feature hangs off. `SONG_REPORTS_DIR` already moves it. */ export const BROWSE_ROOT = path.join(SONG_REPORTS, "videos"); // The cut list is FIXED, not derived from the directory. // // mortal-kombat and mario-rpg have no vertical.mp4, and a scan-derived list // would render those songs as complete. A hole in the deliverable set is // information -- it is the thing the judging loop is meant to surface. // // The list itself moved to lib/projects/song.mjs so `umtool ls` shares it; this // re-export is what every existing caller still imports. export const CUT_NAMES = CUT_NAMES_RAW as readonly CutName[]; export type { CutName }; export function isCutName(v: string): v is CutName { return (CUT_NAMES as readonly string[]).includes(v); } // Longest first, so `wide-short-nokit` binds to `wide-short` and never to // `wide`. Sorted rather than hand-ordered: adding a fifth cut name later // cannot then silently break the rule by being written in the wrong place. const BY_LENGTH: CutName[] = [...CUT_NAMES].sort((a, b) => b.length - a.length); /** * Which cut a variant belongs to, and what distinguishes it. * * The `-` separator is load-bearing: a bare startsWith() would bind a * hypothetical `verticalcrop.mp4` to `vertical` with an empty tag. */ export function attributeVariant(base: string): { cut: CutName | null; tag: string } { for (const c of BY_LENGTH) { if (base === c) return { cut: c, tag: "" }; if (base.startsWith(c + "-")) return { cut: c, tag: base.slice(c.length + 1) }; } return { cut: null, tag: base }; } // --------------------------------------------------------------------------- // Names that cross the wire. // // Nothing here takes a path from a client. A request names a SONG and a // song-relative FILE, both of which must turn out to be members of the current // scan -- so `../../etc/passwd` fails the character check, and a plausible // in-tree name that simply is not there fails the membership check. The result // still goes through resolveInRoots() before anything opens or renames it, // because a bad SONG_REPORTS_DIR or a symlink could otherwise put a rename // outside the tree. // --------------------------------------------------------------------------- /** A single safe path segment: no separators, no traversal, no dotfiles. */ export function isSegment(v: string): boolean { return /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(v) && !v.includes(".."); } /** A song-relative rendition path, as the scan emits it. */ const REL_RE = /^(variants\/(retired\/)?)?[A-Za-z0-9][A-Za-z0-9._-]*\.[A-Za-z0-9]+$/; export function isRel(v: string): boolean { return REL_RE.test(v) && !v.includes(".."); } export type Verdict = "keep" | "reject" | "undecided"; export type VerdictEntry = { verdict: Verdict; note?: string; at: string }; export type VerdictMap = Record; export const VERDICTS: Verdict[] = ["keep", "reject", "undecided"]; export const isVerdict = (v: string): v is Verdict => (VERDICTS as string[]).includes(v); export type Rendition = { /** Song-relative, e.g. `wide.mp4` or `variants/wide-nokit.mp4`. The key. */ rel: string; /** Basename without extension, e.g. `wide-nokit`. */ base: string; /** Root-relative, which is what `/api/mix/media?path=` wants. */ label: string; size: number; mtimeMs: number; verdict: VerdictEntry | null; }; export type Variant = Rendition & { cut: CutName | null; tag: string; retired: boolean }; export type Cut = { name: CutName; shipped: Rendition | null; variants: Variant[] }; export type SongSummary = { id: string; title: string; /** Root-relative path of a `thumbs/` frame, or null to extract one. */ thumb: string | null; /** The newest cut, so the index can offer a poster without a thumbs hit. */ posterRel: string | null; present: number; missing: CutName[]; variantCount: number; newestMtimeMs: number; counts: Record; }; export type Song = SongSummary & { dir: string; cuts: Cut[]; /** Variants whose name matches no cut. Shown, never guessed at. */ unattributed: Variant[]; plans: { name: string; size: number; mtimeMs: number }[]; readme: string | null; hasClipsCsv: boolean; verdicts: VerdictMap; }; const MEDIA_RE = /\.(mp4|mkv|webm|mov|m4v)$/i; export const songDir = (id: string) => path.join(BROWSE_ROOT, id); export const verdictsFile = (id: string) => path.join(songDir(id), "verdicts.json"); export async function readVerdicts(id: string): Promise { return readJson(verdictsFile(id), {}); } /** Resolve a song + song-relative file to an absolute path, or null. */ export function resolveRendition(id: string, rel: string): string | null { if (!isSegment(id) || !isRel(rel)) return null; return resolveInRoots(path.join(songDir(id), rel)); } async function statRendition( id: string, rel: string, verdicts: VerdictMap, ): Promise { const abs = path.join(songDir(id), rel); try { const st = await stat(abs); return { rel, base: path.basename(rel).replace(MEDIA_RE, ""), label: labelFor(abs), size: st.size, mtimeMs: st.mtimeMs, verdict: verdicts[rel] ?? null, }; } catch { return null; } } async function listMediaIn(dir: string): Promise { try { const entries = await readdir(dir, { withFileTypes: true }); return entries .filter((e) => e.isFile() && MEDIA_RE.test(e.name) && !e.name.startsWith(".")) .map((e) => e.name) .sort(); } catch { return []; } } /** The `# Title` line of a README, which is a better name than the slug. */ function titleFromReadme(readme: string | null, fallback: string): string { const m = readme?.match(/^#\s+(.+)$/m); return m ? m[1].trim() : fallback; } // --------------------------------------------------------------------------- // thumbs/ is COVER ART, keyed by an abbreviated song name. // // The 17 frames there are 1280x720 covers built by song/make-thumb.mjs, and // they are named `mk-`, `ms2-`, `rpg-` -- none of which is a song directory, // and none of which corresponds to a cut. So they resolve at the SONG level // through this map, and every cut/variant poster is extracted. A song whose // name is not in the map degrades to extraction, which is what makes the page // work when pointed at a tree it has never seen. // --------------------------------------------------------------------------- const THUMB_ALIASES: Record = { "mortal-kombat": ["mortal-kombat", "mk"], "metal-slug": ["ms2", "metal-slug"], "mario-rpg": ["rpg", "mario-rpg"], pokemon: ["pokemon"], yoshi: ["yoshi"], }; /** The names a song's covers and files are filed under, the song id first. */ export const thumbAliasesFor = (id: string): string[] => [ id, ...(THUMB_ALIASES[id] ?? []).filter((a) => a !== id), ]; let thumbCache: { names: string[]; at: number } | null = null; async function thumbNames(): Promise { if (thumbCache && Date.now() - thumbCache.at < 5000) return thumbCache.names; let names: string[] = []; try { names = (await readdir(path.join(SONG_REPORTS, "thumbs"))) .filter((n) => /\.(jpe?g|png|webp)$/i.test(n)) .sort(); } catch { names = []; } thumbCache = { names, at: Date.now() }; return names; } let acceptedCache: { doc: ThumbDoc; at: number } | null = null; /** thumb-accepted.json, briefly memoised -- listSongs asks once per song. */ async function acceptedDoc(): Promise { if (acceptedCache && Date.now() - acceptedCache.at < 5000) return acceptedCache.doc; const doc = await readThumbAccepted(); acceptedCache = { doc, at: Date.now() }; return doc; } /** * A cover for a song, root-relative, or null. * * THE ACCEPTED SET WINS. It was rendering `yoshi-a-*` off the filename * convention while `thumb-accepted.json` named `yoshi-c.jpg` -- so the index * was showing a picture nobody chose, next to a page whose whole job is to say * what was decided. The convention scan stays as the fallback, because it is * what makes this work against a tree the accepted manifest has never seen. */ export async function thumbFor(id: string): Promise { const accepted = acceptedFor(await acceptedDoc(), id, thumbAliasesFor(id)); if (accepted?.entry?.out) { // `out` is recorded relative to SONG_REPORTS (since release 12; older // entries hold an ABSOLUTE path from the machine that rendered it), and the // poster route resolves a thumb against SONG_REPORTS -- so anything outside // that root (a moved tree, a stale path) falls through rather than // producing a join that silently points at the wrong file. const abs = resolveInRoots(accepted.entry.out); if (abs && (abs === SONG_REPORTS || abs.startsWith(SONG_REPORTS + path.sep))) { return path.relative(SONG_REPORTS, abs); } } const names = await thumbNames(); if (!names.length) return null; for (const k of thumbAliasesFor(id)) { // `-a-*` is make-thumb's own first choice; prefer it, then any match. const first = names.find((n) => n.startsWith(`${k}-a-`)) ?? names.find((n) => n === `${k}.jpg`); if (first) return path.posix.join("thumbs", first); const any = names.find((n) => n.startsWith(`${k}-`)); if (any) return path.posix.join("thumbs", any); } return null; } // --------------------------------------------------------------------------- // The scan. // --------------------------------------------------------------------------- export async function readSong(id: string): Promise { if (!isSegment(id)) return null; const dir = songDir(id); try { if (!(await stat(dir)).isDirectory()) return null; } catch { return null; } const verdicts = await readVerdicts(id); const [topNames, variantNames, retiredNames, planNames, readme] = await Promise.all([ listMediaIn(dir), listMediaIn(path.join(dir, "variants")), listMediaIn(path.join(dir, "variants", "retired")), readdir(path.join(dir, "plan")).then( (n) => n.filter((f) => f.endsWith(".json")).sort(), () => [] as string[], ), readFile(path.join(dir, "README.md"), "utf8").catch(() => null), ]); const shipped = new Map(); for (const name of topNames) { const base = name.replace(MEDIA_RE, ""); if (!isCutName(base)) continue; const r = await statRendition(id, name, verdicts); if (r) shipped.set(base, r); } const collect = async (names: string[], sub: string, retired: boolean): Promise => { const out: Variant[] = []; for (const name of names) { const r = await statRendition(id, path.posix.join(sub, name), verdicts); if (!r) continue; out.push({ ...r, ...attributeVariant(r.base), retired }); } return out; }; const variants = [ ...(await collect(variantNames, "variants", false)), ...(await collect(retiredNames, "variants/retired", true)), ]; const cuts: Cut[] = CUT_NAMES.map((name) => ({ name, shipped: shipped.get(name) ?? null, variants: variants.filter((v) => v.cut === name), })); const plans = await Promise.all( planNames.map(async (name) => { const st = await stat(path.join(dir, "plan", name)).catch(() => null); return { name, size: st?.size ?? 0, mtimeMs: st?.mtimeMs ?? 0 }; }), ); const all = [...shipped.values(), ...variants]; const counts: Record = { keep: 0, reject: 0, undecided: 0 }; for (const r of all) counts[r.verdict?.verdict ?? "undecided"] += 1; const present = cuts.filter((c) => c.shipped).length; const newest = all.reduce((m, r) => Math.max(m, r.mtimeMs), 0); return { id, title: titleFromReadme(readme, id), thumb: await thumbFor(id), posterRel: cuts.find((c) => c.shipped)?.shipped?.rel ?? null, present, missing: cuts.filter((c) => !c.shipped).map((c) => c.name), variantCount: variants.filter((v) => !v.retired).length, newestMtimeMs: newest, counts, dir, cuts, unattributed: variants.filter((v) => v.cut === null), plans, readme, hasClipsCsv: await stat(path.join(dir, "clips.csv")).then( () => true, () => false, ), verdicts, }; } /** Every song, by the basename the song routes are keyed on. See song.mjs. */ export const songIds = (): Promise => songIdsUnder(REPORTS_ROOT, BROWSE_ROOT); /** Every song, newest first. Never probes -- the index must not shell out. */ export async function listSongs(): Promise { const ids = await songIds(); const songs = await Promise.all(ids.map((id) => readSong(id))); return songs .filter((s): s is Song => s !== null) .map(summarise) .sort((a, b) => b.newestMtimeMs - a.newestMtimeMs); } /** Drop the per-song detail the index does not render. */ export function summarise(s: Song): SongSummary { return { id: s.id, title: s.title, thumb: s.thumb, posterRel: s.posterRel, present: s.present, missing: s.missing, variantCount: s.variantCount, newestMtimeMs: s.newestMtimeMs, counts: s.counts, }; } // --------------------------------------------------------------------------- // Durations. // // probeMedia() shells out to ffprobe and has NO cache of its own, by design -- // the mix bench depends on its freshness. Twenty probes on a force-dynamic // index would put a second or two in front of every page load, so the index // uses size and mtime (free from stat) and only the cut and variant levels // ask for a duration. The memo is here rather than in lib/media.ts so nothing // else inherits a staleness it did not ask for. // --------------------------------------------------------------------------- const probes = new Map(); export async function probeCached(abs: string): Promise { let st; try { st = await stat(abs); } catch { return null; } const key = `${abs}|${Math.round(st.mtimeMs)}|${st.size}`; const hit = probes.get(key); if (hit) return hit; try { const info = await probeMedia(abs); probes.set(key, info); return info; } catch { return null; } } /** Probe a set of renditions in parallel, keyed by their song-relative path. */ export async function probeAll( id: string, rels: string[], ): Promise> { const out: Record = {}; await Promise.all( rels.map(async (rel) => { const abs = resolveRendition(id, rel); if (!abs) return; const info = await probeCached(abs); if (info) out[rel] = info; }), ); return out; }