// The song kind, read cheaply. // // The RICH read is lib/browse.ts's readSong(): verdict tallies against thumb // aliases, plan provenance, spec validation, the accepted-cover set. That stays // where it is and the project page still uses it. This is the index's version -- // two readdirs and one small JSON -- because `umtool ls` has to work from a // terminal with no server, and because the index pays this per project. import { readdir, readFile, stat } from "node:fs/promises"; import path from "node:path"; /** This kind's registry id. The one place the string lives. */ export const SONG_KIND = "song"; // 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. // // Defined here rather than in lib/browse.ts so the CLI and the app share one // list; lib/browse.ts re-exports it. export const CUT_NAMES = ["wide", "wide-short", "vertical", "vertical-short"]; const MEDIA_RE = /\.(mp4|mkv|webm|mov|m4v)$/i; const stat0 = (p) => stat(p).then((s) => s, () => null); const listMediaIn = (dir) => readdir(dir, { withFileTypes: true }).then( (es) => es.filter((e) => e.isFile() && MEDIA_RE.test(e.name) && !e.name.startsWith(".")).map((e) => e.name), () => [], ); export async function songSignature(dir) { const [d, v] = await Promise.all([stat0(dir), stat0(path.join(dir, "verdicts.json"))]); return [Math.round(d?.mtimeMs ?? 0), Math.round(v?.mtimeMs ?? 0), v?.size ?? 0].join(":"); } export async function summariseSong(ctx) { const { dir, name } = ctx; const [top, variants, readme, verdicts] = await Promise.all([ listMediaIn(dir), listMediaIn(path.join(dir, "variants")), readFile(path.join(dir, "README.md"), "utf8").catch(() => null), readFile(path.join(dir, "verdicts.json"), "utf8").then( (t) => JSON.parse(t), () => ({}), ), ]); const bases = new Set(top.map((n) => n.replace(MEDIA_RE, ""))); const present = CUT_NAMES.filter((c) => bases.has(c)); const missing = CUT_NAMES.filter((c) => !bases.has(c)); const counts = { keep: 0, reject: 0, undecided: 0 }; const all = [...top, ...variants.map((n) => path.posix.join("variants", n))]; for (const rel of all) { const v = verdicts[rel]?.verdict; counts[v === "keep" || v === "reject" ? v : "undecided"] += 1; } let newest = 0; // The shipped cuts' bytes, for the dashboard's deliverables count. Variants // are not deliverables, so they are not summed. let finalBytes = 0; for (const rel of all) { const st = await stat0(path.join(dir, rel)); if (!st) continue; newest = Math.max(newest, st.mtimeMs); if (!rel.startsWith("variants/")) finalBytes += st.size; } const title = readme?.match(/^#\s+(.+)$/m)?.[1]?.trim() ?? name; const state = present.length === 0 ? "draft" : present.length === CUT_NAMES.length ? "shipped" : "built"; const facts = [ `${present.length}/${CUT_NAMES.length} cuts`, `${variants.length} variant${variants.length === 1 ? "" : "s"}`, ]; if (counts.undecided) facts.push(`${counts.undecided} undecided`); return { title, subtitle: missing.length ? `no ${missing.join(", ")}` : null, state, newestMtimeMs: newest, facts, flags: [], posterRel: present.length ? `${present[0]}.mp4` : null, haystack: [ctx.id, title, ...bases].join(" ").toLowerCase(), attrs: { song: name, cuts: String(present.length), variants: String(variants.length), keep: String(counts.keep), reject: String(counts.reject), undecided: String(counts.undecided), ...(present.length ? { "final-bytes": String(finalBytes) } : {}), // Omitted entirely when the set is complete: a hole in the deliverables // is information, and its ABSENCE has to be readable as "nothing missing" // rather than as an empty list. ...(missing.length ? { missing: missing.join(",") } : {}), }, counts, present, missing, variantCount: variants.length, }; }