// A per-channel content signature for incremental export builds. // // The archive + compose stages redo work per channel every build. To skip a // channel whose content hasn't changed we need a stable answer to "did channel // X change since we last built its output?". There is no per-channel fingerprint // in the index, but build-index DOES persist per-VIDEO freshness in the `mtimes` // LMDB sub-DB (key [channelSlug, videoDir] -> {metaMs, transcriptMs, subsMs,…}). // Folding a channel's mtime records into one hash reuses the exact freshness // truth the rest of the build already trusts — so "unchanged" here means the // same thing build-index means, and it sidesteps the mtime-drift-across-shards // hazard that a raw fs.stat would reintroduce (the LMDB mtimes are the // reconciled source, captured once at index time). // // The signature is over INPUTS, never the produced bytes: archive zips embed a // `generatedAt` timestamp (archiveTranscripts.ts channelManifest) so they are // not byte-reproducible; only an input signature is stable across builds. import { createHash } from "node:crypto"; import { existsSync } from "node:fs"; import { open } from "lmdb"; import type { Paths } from "./paths"; // Structural subset of buildIndex.ts's MtimeRecord — only the fields that feed // the signature. `digestMs` is the newest mtime across BOTH digest sidecars // (machine + human overrides); without it in the hash below, a digest-only // change would leave every archive believing the channel was unchanged. type MtimeRecord = { metaMs: number; transcriptMs: number | null; subsMs: number | null; digestMs: number | null; }; type PathKey = [string, string]; export type ChannelSigner = { // A hex signature for the channel's on-disk content, or null when it can't be // determined (no index yet) — callers should treat null as "cannot cache, // rebuild unconditionally". `extra` folds in caller-specific inputs that also // affect the output (e.g. archive build options, channel config). signature(slug: string, extra?: string): string | null; close(): Promise; }; // Open a read-only view of the index's per-video mtimes for signature lookups. // Returns a no-op signer (signature() -> null) when the index doesn't exist yet // or can't be opened, so a fresh checkout simply builds everything. export function openChannelSigner(paths: Paths): ChannelSigner { if (!existsSync(paths.lmdbPath)) { return { signature: () => null, close: async () => {} }; } let root: ReturnType; try { root = open({ path: paths.lmdbPath, readOnly: true, maxDbs: 14 }); } catch { return { signature: () => null, close: async () => {} }; } const mtimes = root.openDB({ name: "mtimes", encoding: "msgpack", }); const meta = root.openDB({ name: "meta", encoding: "msgpack" }); // Fold the index schema version in so a schema bump (which rewrites the whole // index) invalidates every cached signature. Deliberately NOT the `generation` // counter — that bumps whenever ANY channel changes, which would needlessly // invalidate every OTHER channel's signature. const schema = String((meta.get("schema") as number | undefined) ?? "none"); return { signature(slug: string, extra = ""): string | null { const h = createHash("sha1"); h.update(`schema:${schema}\n`); if (extra) h.update(`extra:${extra}\n`); let sawAny = false; for (const { key, value } of mtimes.getRange({ start: [slug], end: [slug, "￿"], })) { const k = key as PathKey; if (k[0] !== slug) break; sawAny = true; h.update( `${k[1]}\t${value.metaMs}\t${value.transcriptMs ?? ""}\t${value.subsMs ?? ""}\t${value.digestMs ?? ""}\n`, ); } // A channel with zero indexed videos still gets a stable signature (schema // + extra), so an empty channel can be cached like any other. void sawAny; return h.digest("hex"); }, async close() { await root.close(); }, }; }