import { stat } from "node:fs/promises"; import path from "node:path"; import { readJson } from "./state"; import { songDir } from "./browse"; // build.json -- the recipe, read back. // // Written by song/video-dir.mjs, one file per song, keyed by the same // song-relative path verdicts.json uses. See that script's header for the shape // and for why it is keyed that way. // // This is NEW STATE THAT CAN GO STALE, and that is its main risk: a builder run // by hand records nothing, and everything in this tree gets rebuilt. So every // entry carries the file's size and mtime as built, and every read compares // them. An entry that no longer describes the file is reported as stale rather // than shown as fact -- a recipe you cannot trust is worse than no recipe, // because you act on it. export type BuildAsset = { path: string; bytes?: number; mtime?: string; missing?: boolean }; export type BuildEntry = { script: string | null; env: Record; plans: string[]; assets: BuildAsset[]; duration: number; note: string | null; file?: { bytes: number; mtime: string }; written: string; }; export type BuildDoc = { version: number; song: string; builds: Record }; export type BuildStatus = { entry: BuildEntry | null; /** null when the recipe still describes the file on disk. */ stale: string | null; }; export const manifestFile = (id: string) => path.join(songDir(id), "build.json"); export async function readManifest(id: string): Promise { const doc = await readJson(manifestFile(id), { version: 2, song: id, builds: {} }); return doc && typeof doc === "object" && doc.builds ? doc : { version: 2, song: id, builds: {} }; } /** Two seconds of slack: an mtime round-trips through an ISO string. */ const MTIME_SLACK = 2000; export async function buildStatus(id: string, rel: string, doc?: BuildDoc): Promise { const manifest = doc ?? (await readManifest(id)); const entry = manifest.builds[rel] ?? null; if (!entry) return { entry: null, stale: null }; if (!entry.file) return { entry, stale: "recorded without a file stamp" }; try { const st = await stat(path.join(songDir(id), rel)); if (st.size !== entry.file.bytes) { return { entry, stale: "the file has been rebuilt since this was recorded" }; } if (Math.abs(new Date(entry.file.mtime).getTime() - st.mtimeMs) > MTIME_SLACK) { return { entry, stale: "the file has been touched since this was recorded" }; } } catch { return { entry, stale: "the file is gone" }; } return { entry, stale: null }; }