Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit be6430022594f56975b421910cfd448da580931d
parent 3e1db6bda4556127cb4887572046cf23e919f79f
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Thu, 24 Sep 2026 13:11:34 -0400

lib: sidecar() — one declaration per per-video sidecar

common/lib/sidecar-server.ts: sidecar(filename, schema, {indent}) gives
{filename, path, load, write, remove}. It refuses a filename SUB_FILE_RE
would claim as a subtitle track, at declaration (module load), and records
every declared name in SIDECAR_FILENAMES. load never throws: absent,
unreadable, unparseable or rejected by the coercion read as null.
sidecarField(coerce) = z.unknown().transform(coerce) — settingsField minus
the .catch, since a sidecar coercion falls back to null, not a default.

Declared on it, each with today's shape check as an exported coerceX and
the old loadX/writeX/xPath names kept: attribution, diarization,
availability, downloadOutcome, transcribeOutcome, doNotClean,
excludeTruncatedCheck, and digest's two files (ai-digest.json,
ai-digest.overrides.json). availability-server's download-outcome side reads
through loadDownloadOutcome. SUB_FILE_RE is exported from videoStatus.ts and
diarization.test.ts uses it instead of a copy. No mtime freshness (no
consumer).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Diffstat:
Mcommon/lib/attribution-server.ts | 74++++++++++++++++++++++++++++++++------------------------------------------
Mcommon/lib/availability-server.ts | 82+++++++++++++++++++++++++++++++------------------------------------------------
Mcommon/lib/diarization-server.ts | 54+++++++++++++++++++++++-------------------------------
Mcommon/lib/diarization.test.ts | 10++++++----
Mcommon/lib/digest-server.ts | 149++++++++++++++++++++++++++++++++++++-------------------------------------------
Mcommon/lib/doNotClean-server.ts | 46+++++++++++++++++-----------------------------
Mcommon/lib/downloadOutcome-server.ts | 60++++++++++++++++++++++++++----------------------------------
Mcommon/lib/excludeTruncatedCheck-server.ts | 50++++++++++++++++++++++----------------------------
Acommon/lib/sidecar-server.test.ts | 165+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/lib/sidecar-server.ts | 104+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/lib/transcribeOutcome-server.ts | 50+++++++++++++++++++++-----------------------------
Mcommon/lib/videoStatus.ts | 4+++-
12 files changed, 519 insertions(+), 329 deletions(-)

diff --git a/common/lib/attribution-server.ts b/common/lib/attribution-server.ts @@ -1,47 +1,37 @@ -import { writeJsonAtomic } from "./jsonFile-server"; -import path from "node:path"; -import { readFile } from "node:fs/promises"; import { ATTRIBUTION_FILENAME, type AttributionRecord } from "./attribution"; +import { sidecar, sidecarField } from "./sidecar-server"; -export function attributionPath(videoDir: string): string { - return path.join(videoDir, ATTRIBUTION_FILENAME); -} - -export async function loadAttribution( - videoDir: string, -): Promise<AttributionRecord | null> { - try { - const raw = await readFile(attributionPath(videoDir), "utf8"); - const parsed = JSON.parse(raw) as Partial<AttributionRecord>; - // Validate the SHAPE, not just the parse. A half-written sidecar must read - // as absent everywhere — the same rule diarization-server.ts's - // hasDiarization encodes, and here it matters for a second reason: a - // malformed file that read as "present" would also read as a DOWNGRADE - // guard, and could block the diarized lane from ever writing a real record. - if ( - typeof parsed?.videoId === "string" && - typeof parsed.generatedAt === "string" && - Array.isArray(parsed.speakers) && - Array.isArray(parsed.segments) && - !!parsed.provenance && - typeof parsed.provenance.method === "string" - ) { - return parsed as AttributionRecord; - } - return null; - } catch { - return null; +// Validate the SHAPE, not just the parse. A half-written sidecar must read as +// absent everywhere — the same rule diarization-server.ts's hasDiarization +// encodes, and here it matters for a second reason: a malformed file that read +// as "present" would also read as a DOWNGRADE guard, and could block the +// diarized lane from ever writing a real record. +export function coerceAttribution(value: unknown): AttributionRecord | null { + const parsed = value as Partial<AttributionRecord> | null; + if ( + typeof parsed?.videoId === "string" && + typeof parsed.generatedAt === "string" && + Array.isArray(parsed.speakers) && + Array.isArray(parsed.segments) && + !!parsed.provenance && + typeof parsed.provenance.method === "string" + ) { + return parsed as AttributionRecord; } + return null; } -// tmp + rename, so a crash mid-write leaves the previous record rather than a -// truncated one. Identical to writeDiarization; the atomicity is what lets -// loadAttribution treat "parsed but wrong shape" as a real anomaly rather than -// the expected state of a file being written. -export async function writeAttribution( - videoDir: string, - record: AttributionRecord, -): Promise<void> { - const file = attributionPath(videoDir); - await writeJsonAtomic(file, record, { indent: 0 }); -} +// Compact (one line + "\n"), as it always was. The write is atomic (tmp + +// rename), which is what lets loadAttribution treat "parsed but wrong shape" +// as a real anomaly rather than the expected state of a file being written. +export const attributionSidecar = sidecar( + ATTRIBUTION_FILENAME, + sidecarField(coerceAttribution), + { indent: 0 }, +); + +export const { + path: attributionPath, + load: loadAttribution, + write: writeAttribution, +} = attributionSidecar; diff --git a/common/lib/availability-server.ts b/common/lib/availability-server.ts @@ -1,6 +1,4 @@ import { writeJsonAtomic } from "./jsonFile-server"; -import path from "node:path"; -import { readFile } from "node:fs/promises"; import { AVAILABILITY_FILENAME, AVAILABILITY_VALUES, @@ -11,41 +9,31 @@ import { type AvailabilityHistorySource, type AvailabilityRecord, } from "./availability"; -import { - DOWNLOAD_OUTCOME_FILENAME, - type DownloadOutcomeRecord, -} from "./downloadOutcome"; - -export function availabilityPath(videoDir: string): string { - return path.join(videoDir, AVAILABILITY_FILENAME); -} +import { loadDownloadOutcome } from "./downloadOutcome-server"; +import { sidecar, sidecarField } from "./sidecar-server"; -export async function loadAvailability( - videoDir: string, -): Promise<AvailabilityRecord | null> { - try { - const raw = await readFile(availabilityPath(videoDir), "utf8"); - const parsed = JSON.parse(raw) as Partial<AvailabilityRecord>; - if ( - typeof parsed?.availability === "string" && - (AVAILABILITY_VALUES as string[]).includes(parsed.availability) && - typeof parsed.checkedAt === "string" - ) { - return parsed as AvailabilityRecord; - } - return null; - } catch { - return null; +export function coerceAvailability(value: unknown): AvailabilityRecord | null { + const parsed = value as Partial<AvailabilityRecord> | null; + if ( + typeof parsed?.availability === "string" && + (AVAILABILITY_VALUES as string[]).includes(parsed.availability) && + typeof parsed.checkedAt === "string" + ) { + return parsed as AvailabilityRecord; } + return null; } -export async function writeAvailability( - videoDir: string, - record: AvailabilityRecord, -): Promise<void> { - const file = availabilityPath(videoDir); - await writeJsonAtomic(file, record); -} +export const availabilitySidecar = sidecar( + AVAILABILITY_FILENAME, + sidecarField(coerceAvailability), +); + +export const { + path: availabilityPath, + load: loadAvailability, + write: writeAvailability, +} = availabilitySidecar; // Append-on-change writer that all availability persistence funnels through. // Records an observation in the video's history[] only when the availability @@ -170,24 +158,18 @@ export async function resolveMaybeMissingState( return "maybe_missing"; } +// The download-outcome side of resolveEffectiveAvailability: the LAST +// attempt's availabilityClass, when there is one. Reads through +// loadDownloadOutcome, so a download-outcome.json that fails that shape check +// no longer contributes an availability (slice 4b record, behaviour changes). async function loadDownloadOutcomeAvailability( videoDir: string, ): Promise<{ availability: Availability; finishedAt: string | null } | null> { - try { - const raw = await readFile( - path.join(videoDir, DOWNLOAD_OUTCOME_FILENAME), - "utf8", - ); - const parsed = JSON.parse(raw) as Partial<DownloadOutcomeRecord>; - const attempts = parsed.attempts; - if (!Array.isArray(attempts) || attempts.length === 0) return null; - const last = attempts[attempts.length - 1]; - if (!last?.availabilityClass) return null; - return { - availability: last.availabilityClass, - finishedAt: typeof parsed.finishedAt === "string" ? parsed.finishedAt : null, - }; - } catch { - return null; - } + const outcome = await loadDownloadOutcome(videoDir); + const last = outcome?.attempts.at(-1); + if (!outcome || !last?.availabilityClass) return null; + return { + availability: last.availabilityClass, + finishedAt: typeof outcome.finishedAt === "string" ? outcome.finishedAt : null, + }; } diff --git a/common/lib/diarization-server.ts b/common/lib/diarization-server.ts @@ -1,34 +1,34 @@ -import { writeJsonAtomic } from "./jsonFile-server"; -import path from "node:path"; -import { readFile } from "node:fs/promises"; import { DIARIZATION_FILENAME, type DiarizationRecord, } from "./diarization"; +import { sidecar, sidecarField } from "./sidecar-server"; -export function diarizationPath(videoDir: string): string { - return path.join(videoDir, DIARIZATION_FILENAME); -} - -export async function loadDiarization( - videoDir: string, -): Promise<DiarizationRecord | null> { - try { - const raw = await readFile(diarizationPath(videoDir), "utf8"); - const parsed = JSON.parse(raw) as Partial<DiarizationRecord>; - if ( - typeof parsed?.videoId === "string" && - typeof parsed.generatedAt === "string" && - Array.isArray(parsed.turns) - ) { - return parsed as DiarizationRecord; - } - return null; - } catch { - return null; +export function coerceDiarization(value: unknown): DiarizationRecord | null { + const parsed = value as Partial<DiarizationRecord> | null; + if ( + typeof parsed?.videoId === "string" && + typeof parsed.generatedAt === "string" && + Array.isArray(parsed.turns) + ) { + return parsed as DiarizationRecord; } + return null; } +// Compact (one line + "\n"), as it always was. +export const diarizationSidecar = sidecar( + DIARIZATION_FILENAME, + sidecarField(coerceDiarization), + { indent: 0 }, +); + +export const { + path: diarizationPath, + load: loadDiarization, + write: writeDiarization, +} = diarizationSidecar; + // Presence of a VALID sidecar. This is the predicate the cleanup guard keys // off, so it deliberately treats a malformed file as absent: a half-written // diarization.json must not be what convinces the sweep it is safe to delete @@ -36,11 +36,3 @@ export async function loadDiarization( export async function hasDiarization(videoDir: string): Promise<boolean> { return (await loadDiarization(videoDir)) !== null; } - -export async function writeDiarization( - videoDir: string, - record: DiarizationRecord, -): Promise<void> { - const file = diarizationPath(videoDir); - await writeJsonAtomic(file, record, { indent: 0 }); -} diff --git a/common/lib/diarization.test.ts b/common/lib/diarization.test.ts @@ -1,4 +1,5 @@ import { test } from "node:test"; +import { SUB_FILE_RE } from "./videoStatus"; import assert from "node:assert/strict"; import path from "node:path"; import os from "node:os"; @@ -37,12 +38,13 @@ function record(over: Partial<DiarizationRecord> = {}): DiarizationRecord { } test("the sidecar filename is not claimed by SUB_FILE_RE as a subtitle track", () => { - // The regex is /^transcript\.([^.]+)\.([^.]+)$/. A name like - // transcript.diarization.json would be indexed as a `diarization`-language - // subtitle track — which is exactly the trap this constant exists to avoid. + // A name like transcript.diarization.json would be indexed as a + // `diarization`-language subtitle track — which is exactly the trap this + // constant exists to avoid. (sidecar() also refuses such a name at + // declaration; see sidecar-server.test.ts.) assert.equal(DIARIZATION_FILENAME, "diarization.json"); assert.equal(STATUS_FILENAME, DIARIZATION_FILENAME); - assert.ok(!/^transcript\.([^.]+)\.([^.]+)$/.test(DIARIZATION_FILENAME)); + assert.ok(!SUB_FILE_RE.test(DIARIZATION_FILENAME)); }); test("write then load round-trips a record", async () => { diff --git a/common/lib/digest-server.ts b/common/lib/digest-server.ts @@ -8,9 +8,7 @@ // replaces exactly one section and leaves the other alone, so a metered tags run // never clobbers local chapters (and vice versa). -import path from "node:path"; -import { readFile, rm } from "node:fs/promises"; -import { writeJsonAtomic } from "./jsonFile-server"; +import { sidecar, sidecarField } from "./sidecar-server"; import { DIGEST_FILENAME, DIGEST_OVERRIDES_FILENAME, @@ -34,83 +32,80 @@ import { // iterations during Stage B tuning cannot grow an unbounded sidecar. const MAX_HISTORY_ENTRIES = 40; -export function digestPath(videoDir: string): string { - return path.join(videoDir, DIGEST_FILENAME); -} - -export function digestOverridesPath(videoDir: string): string { - return path.join(videoDir, DIGEST_OVERRIDES_FILENAME); -} - // --------------------------------------------------------------------------- // Reads (tolerant: anything unparseable or structurally wrong reads as absent, // so one corrupt sidecar can never fail a channel-wide sweep) // --------------------------------------------------------------------------- -export async function loadDigest( - videoDir: string, -): Promise<DigestRecord | null> { - try { - const raw = await readFile(digestPath(videoDir), "utf8"); - const parsed = JSON.parse(raw) as Partial<DigestRecord>; - if (typeof parsed?.digestSchemaVersion !== "number") return null; - if (!parsed.sections || typeof parsed.sections !== "object") return null; - return { - digestSchemaVersion: parsed.digestSchemaVersion, - promptVersion: - typeof parsed.promptVersion === "number" ? parsed.promptVersion : 0, - contextHash: - typeof parsed.contextHash === "string" ? parsed.contextHash : "", - warnings: Array.isArray(parsed.warnings) - ? (parsed.warnings as DigestWarning[]) - : [], - sections: parsed.sections, - // This reader rebuilds the record field by field rather than spreading, - // so EVERY new field has to be added here or it round-trips to nothing — - // silently, since the write succeeds and the read just omits it. - ...(Array.isArray(parsed.failures) - ? { failures: parsed.failures as DigestSectionFailure[] } - : {}), - ...(Array.isArray(parsed.history) - ? { history: parsed.history as DigestHistoryEntry[] } - : {}), - ...(parsed.derivedFrom - ? { derivedFrom: parsed.derivedFrom as DigestDerivedFrom } - : {}), - }; - } catch { - return null; - } +export function coerceDigest(value: unknown): DigestRecord | null { + const parsed = value as Partial<DigestRecord> | null; + if (typeof parsed?.digestSchemaVersion !== "number") return null; + if (!parsed.sections || typeof parsed.sections !== "object") return null; + return { + digestSchemaVersion: parsed.digestSchemaVersion, + promptVersion: + typeof parsed.promptVersion === "number" ? parsed.promptVersion : 0, + contextHash: + typeof parsed.contextHash === "string" ? parsed.contextHash : "", + warnings: Array.isArray(parsed.warnings) + ? (parsed.warnings as DigestWarning[]) + : [], + sections: parsed.sections, + // This reader rebuilds the record field by field rather than spreading, + // so EVERY new field has to be added here or it round-trips to nothing — + // silently, since the write succeeds and the read just omits it. + ...(Array.isArray(parsed.failures) + ? { failures: parsed.failures as DigestSectionFailure[] } + : {}), + ...(Array.isArray(parsed.history) + ? { history: parsed.history as DigestHistoryEntry[] } + : {}), + ...(parsed.derivedFrom + ? { derivedFrom: parsed.derivedFrom as DigestDerivedFrom } + : {}), + }; } -export async function loadDigestOverrides( - videoDir: string, -): Promise<DigestOverrides | null> { - try { - const raw = await readFile(digestOverridesPath(videoDir), "utf8"); - const parsed = JSON.parse(raw) as Partial<DigestOverrides>; - // A hand-authored file may omit `version`; treat it as current rather than - // discarding human work over a missing scalar. - const chapters = sanitizeChapters(parsed.chapters); - const tags = sanitizeTags(parsed.tags); - if (chapters.length === 0 && tags.length === 0 && !parsed.note) return null; - return { - version: - typeof parsed.version === "number" - ? parsed.version - : DIGEST_OVERRIDES_VERSION, - ...(chapters.length > 0 ? { chapters } : {}), - ...(tags.length > 0 ? { tags } : {}), - ...(typeof parsed.note === "string" ? { note: parsed.note } : {}), - ...(typeof parsed.updatedAt === "string" - ? { updatedAt: parsed.updatedAt } - : {}), - }; - } catch { - return null; - } +export function coerceDigestOverrides(value: unknown): DigestOverrides | null { + // `null` threw inside the old try (`parsed.chapters` on null) and so read as + // absent; the explicit guard keeps that. + if (value === null || value === undefined) return null; + const parsed = value as Partial<DigestOverrides>; + // A hand-authored file may omit `version`; treat it as current rather than + // discarding human work over a missing scalar. + const chapters = sanitizeChapters(parsed.chapters); + const tags = sanitizeTags(parsed.tags); + if (chapters.length === 0 && tags.length === 0 && !parsed.note) return null; + return { + version: + typeof parsed.version === "number" + ? parsed.version + : DIGEST_OVERRIDES_VERSION, + ...(chapters.length > 0 ? { chapters } : {}), + ...(tags.length > 0 ? { tags } : {}), + ...(typeof parsed.note === "string" ? { note: parsed.note } : {}), + ...(typeof parsed.updatedAt === "string" + ? { updatedAt: parsed.updatedAt } + : {}), + }; } +// Two sidecars, each read field by field above (so the read is also what drops +// a field it does not name — see the comment in coerceDigest). +export const digestSidecar = sidecar(DIGEST_FILENAME, sidecarField(coerceDigest)); +export const digestOverridesSidecar = sidecar( + DIGEST_OVERRIDES_FILENAME, + sidecarField(coerceDigestOverrides), +); + +export const { + path: digestPath, + load: loadDigest, + write: writeDigest, +} = digestSidecar; +export const { path: digestOverridesPath, load: loadDigestOverrides } = + digestOverridesSidecar; + // Hand-authored overrides are coerced, not trusted: an entry missing an id or a // body is dropped rather than poisoning the merge. function sanitizeChapters(value: unknown): DigestChapter[] { @@ -162,13 +157,6 @@ function sanitizeTags(value: unknown): DigestTag[] { // Writes // --------------------------------------------------------------------------- -export async function writeDigest( - videoDir: string, - record: DigestRecord, -): Promise<void> { - await writeJsonAtomic(digestPath(videoDir), record); -} - export type WriteDigestSectionInput = { section: DigestSectionKind; items: DigestItem[]; @@ -315,14 +303,13 @@ export async function writeDigestOverrides( (overrides.chapters?.length ?? 0) > 0 || (overrides.tags?.length ?? 0) > 0 || Boolean(overrides.note); - const file = digestOverridesPath(videoDir); if (!hasContent) { // Emptying the override list means "revert to machine output" — remove the // file rather than leaving an empty shadow behind. - await rm(file, { force: true }); + await digestOverridesSidecar.remove(videoDir); return; } - await writeJsonAtomic(file, { + await digestOverridesSidecar.write(videoDir, { version: DIGEST_OVERRIDES_VERSION, ...(overrides.chapters?.length ? { chapters: overrides.chapters } : {}), ...(overrides.tags?.length ? { tags: overrides.tags } : {}), diff --git a/common/lib/doNotClean-server.ts b/common/lib/doNotClean-server.ts @@ -1,31 +1,24 @@ -import { writeJsonAtomic } from "./jsonFile-server"; -import path from "node:path"; -import { readFile, rm } from "node:fs/promises"; import { DO_NOT_CLEAN_FILENAME, type DoNotCleanRecord, } from "./doNotClean"; +import { sidecar, sidecarField } from "./sidecar-server"; -export function doNotCleanPath(videoDir: string): string { - return path.join(videoDir, DO_NOT_CLEAN_FILENAME); +// A parseable-but-malformed marker still means "protected" — it reads as an +// empty record rather than as absent. Only an absent, unreadable or +// unparseable file reads as null (sidecar-server's rule). +export function coerceDoNotClean(value: unknown): DoNotCleanRecord { + const parsed = value as Partial<DoNotCleanRecord> | null; + if (typeof parsed?.setAt === "string") return parsed as DoNotCleanRecord; + return { setAt: "" }; } -export async function loadDoNotClean( - videoDir: string, -): Promise<DoNotCleanRecord | null> { - try { - const raw = await readFile(doNotCleanPath(videoDir), "utf8"); - const parsed = JSON.parse(raw) as Partial<DoNotCleanRecord>; - if (typeof parsed?.setAt === "string") { - return parsed as DoNotCleanRecord; - } - // A parseable-but-malformed marker still means "protected" — fall back to - // an empty record rather than treating it as absent. - return { setAt: "" }; - } catch { - return null; - } -} +export const doNotCleanSidecar = sidecar( + DO_NOT_CLEAN_FILENAME, + sidecarField(coerceDoNotClean), +); + +export const { path: doNotCleanPath, load: loadDoNotClean } = doNotCleanSidecar; // Presence of a valid sidecar = protected. Cheap existence check for the // cleanup controllers' per-dir loops. @@ -38,14 +31,9 @@ export async function setDoNotClean( enabled: boolean, note?: string, ): Promise<void> { - const file = doNotCleanPath(videoDir); - if (!enabled) { - await rm(file, { force: true }); - return; - } - const record: DoNotCleanRecord = { + if (!enabled) return doNotCleanSidecar.remove(videoDir); + await doNotCleanSidecar.write(videoDir, { setAt: new Date().toISOString(), ...(note ? { note } : {}), - }; - await writeJsonAtomic(file, record); + }); } diff --git a/common/lib/downloadOutcome-server.ts b/common/lib/downloadOutcome-server.ts @@ -1,47 +1,39 @@ -import { writeJsonAtomic } from "./jsonFile-server"; -import path from "node:path"; -import { readFile } from "node:fs/promises"; import { DOWNLOAD_OUTCOME_FILENAME, DOWNLOAD_OUTCOME_STATUS_VALUES, type DownloadOutcomeRecord, type DownloadOutcomeStatus, } from "./downloadOutcome"; +import { sidecar, sidecarField } from "./sidecar-server"; -export function downloadOutcomePath(videoDir: string): string { - return path.join(videoDir, DOWNLOAD_OUTCOME_FILENAME); -} - -export async function loadDownloadOutcome( - videoDir: string, -): Promise<DownloadOutcomeRecord | null> { - try { - const raw = await readFile(downloadOutcomePath(videoDir), "utf8"); - const parsed = JSON.parse(raw) as Partial<DownloadOutcomeRecord>; - if ( - typeof parsed?.status === "string" && - (DOWNLOAD_OUTCOME_STATUS_VALUES as string[]).includes(parsed.status) && - typeof parsed.videoId === "string" && - typeof parsed.startedAt === "string" && - typeof parsed.finishedAt === "string" && - Array.isArray(parsed.attempts) - ) { - // Cast: status is already narrowed to a member of the enum tuple. - return parsed as DownloadOutcomeRecord; - } - return null; - } catch { - return null; +export function coerceDownloadOutcome( + value: unknown, +): DownloadOutcomeRecord | null { + const parsed = value as Partial<DownloadOutcomeRecord> | null; + if ( + typeof parsed?.status === "string" && + (DOWNLOAD_OUTCOME_STATUS_VALUES as string[]).includes(parsed.status) && + typeof parsed.videoId === "string" && + typeof parsed.startedAt === "string" && + typeof parsed.finishedAt === "string" && + Array.isArray(parsed.attempts) + ) { + // Cast: status is already narrowed to a member of the enum tuple. + return parsed as DownloadOutcomeRecord; } + return null; } -export async function writeDownloadOutcome( - videoDir: string, - record: DownloadOutcomeRecord, -): Promise<void> { - const file = downloadOutcomePath(videoDir); - await writeJsonAtomic(file, record); -} +export const downloadOutcomeSidecar = sidecar( + DOWNLOAD_OUTCOME_FILENAME, + sidecarField(coerceDownloadOutcome), +); + +export const { + path: downloadOutcomePath, + load: loadDownloadOutcome, + write: writeDownloadOutcome, +} = downloadOutcomeSidecar; // Narrow re-export so callers don't have to import from both modules. export type { DownloadOutcomeStatus }; diff --git a/common/lib/excludeTruncatedCheck-server.ts b/common/lib/excludeTruncatedCheck-server.ts @@ -1,31 +1,30 @@ -import { writeJsonAtomic } from "./jsonFile-server"; -import path from "node:path"; -import { readFile, rm } from "node:fs/promises"; import { EXCLUDE_TRUNCATED_CHECK_FILENAME, type ExcludeTruncatedCheckRecord, } from "./excludeTruncatedCheck"; +import { sidecar, sidecarField } from "./sidecar-server"; -export function excludeTruncatedCheckPath(videoDir: string): string { - return path.join(videoDir, EXCLUDE_TRUNCATED_CHECK_FILENAME); -} - -export async function loadExcludeTruncatedCheck( - videoDir: string, -): Promise<ExcludeTruncatedCheckRecord | null> { - try { - const raw = await readFile(excludeTruncatedCheckPath(videoDir), "utf8"); - const parsed = JSON.parse(raw) as Partial<ExcludeTruncatedCheckRecord>; - if (typeof parsed?.setAt === "string") { - return parsed as ExcludeTruncatedCheckRecord; - } - // Parseable-but-malformed still means "excluded". - return { setAt: "" }; - } catch { - return null; +// Parseable-but-malformed still means "excluded" — the doNotClean rule. +export function coerceExcludeTruncatedCheck( + value: unknown, +): ExcludeTruncatedCheckRecord { + const parsed = value as Partial<ExcludeTruncatedCheckRecord> | null; + if (typeof parsed?.setAt === "string") { + return parsed as ExcludeTruncatedCheckRecord; } + return { setAt: "" }; } +export const excludeTruncatedCheckSidecar = sidecar( + EXCLUDE_TRUNCATED_CHECK_FILENAME, + sidecarField(coerceExcludeTruncatedCheck), +); + +export const { + path: excludeTruncatedCheckPath, + load: loadExcludeTruncatedCheck, +} = excludeTruncatedCheckSidecar; + // Presence of a valid sidecar = excluded from the truncated check. Cheap // existence check for the snapshot generator's per-dir loop. export async function isExcludedFromTruncatedCheck( @@ -39,14 +38,9 @@ export async function setExcludedFromTruncatedCheck( enabled: boolean, note?: string, ): Promise<void> { - const file = excludeTruncatedCheckPath(videoDir); - if (!enabled) { - await rm(file, { force: true }); - return; - } - const record: ExcludeTruncatedCheckRecord = { + if (!enabled) return excludeTruncatedCheckSidecar.remove(videoDir); + await excludeTruncatedCheckSidecar.write(videoDir, { setAt: new Date().toISOString(), ...(note ? { note } : {}), - }; - await writeJsonAtomic(file, record); + }); } diff --git a/common/lib/sidecar-server.test.ts b/common/lib/sidecar-server.test.ts @@ -0,0 +1,165 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, readFile, writeFile, readdir } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { SIDECAR_FILENAMES, sidecar, sidecarField } from "./sidecar-server"; +import { SUB_FILE_RE } from "./videoStatus"; +// Every sidecar module, so the enumeration below sees every declaration. +import "./attribution-server"; +import "./diarization-server"; +import "./digest-server"; +import { + availabilitySidecar, + loadAvailability, + writeAvailability, +} from "./availability-server"; +import { + loadDownloadOutcome, + writeDownloadOutcome, +} from "./downloadOutcome-server"; +import { + loadTranscribeOutcome, + writeTranscribeOutcome, +} from "./transcribeOutcome-server"; +import { + isDoNotClean, + loadDoNotClean, + setDoNotClean, +} from "./doNotClean-server"; +import { + isExcludedFromTruncatedCheck, + loadExcludeTruncatedCheck, + setExcludedFromTruncatedCheck, +} from "./excludeTruncatedCheck-server"; +import type { AvailabilityRecord } from "./availability"; +import type { DownloadOutcomeRecord } from "./downloadOutcome"; +import type { TranscribeOutcomeRecord } from "./transcribeOutcome"; + +async function scratch(): Promise<string> { + return mkdtemp(path.join(os.tmpdir(), "sidecar-")); +} + +test("every declared sidecar filename escapes SUB_FILE_RE, and all nine are declared", () => { + assert.deepEqual([...SIDECAR_FILENAMES].sort(), [ + "ai-digest.json", + "ai-digest.overrides.json", + "attribution.json", + "availability.json", + "diarization.json", + "do-not-clean.json", + "download-outcome.json", + "exclude-truncated-check.json", + "transcribe-outcome.json", + ]); + for (const name of SIDECAR_FILENAMES) { + assert.ok(!SUB_FILE_RE.test(name), name); + } +}); + +test("sidecar() refuses a transcript.<x>.<y> name at declaration", () => { + assert.throws( + () => sidecar("transcript.speakers.json", sidecarField((v) => v)), + /SUB_FILE_RE/, + ); + assert.throws(() => sidecar("a/b.json", sidecarField((v) => v)), /bare filename/); +}); + +test("absent, unparseable and coercion-rejected all load as null", async () => { + const dir = await scratch(); + assert.equal(await loadAvailability(dir), null); + await writeFile(availabilitySidecar.path(dir), "{ not json"); + assert.equal(await loadAvailability(dir), null); + await writeFile(availabilitySidecar.path(dir), JSON.stringify({ availability: "nope", checkedAt: "x" })); + assert.equal(await loadAvailability(dir), null); + await writeFile(availabilitySidecar.path(dir), "null"); + assert.equal(await loadAvailability(dir), null); +}); + +test("a coercion that throws reads as null, not as an exception", async () => { + const dir = await scratch(); + const s = sidecar( + "throwing-test.json", + sidecarField((): null => { + throw new Error("boom"); + }), + ); + await writeFile(s.path(dir), "{}"); + assert.equal(await s.load(dir), null); +}); + +test("doNotClean / excludeTruncatedCheck: a malformed marker still reads as present", async () => { + const dir = await scratch(); + for (const [name, load, is] of [ + ["do-not-clean.json", loadDoNotClean, isDoNotClean], + ["exclude-truncated-check.json", loadExcludeTruncatedCheck, isExcludedFromTruncatedCheck], + ] as const) { + assert.equal(await load(dir), null); + assert.equal(await is(dir), false); + await writeFile(path.join(dir, name), JSON.stringify({ nope: 1 })); + assert.deepEqual(await load(dir), { setAt: "" }); + await writeFile(path.join(dir, name), "{ torn"); + assert.equal(await load(dir), null); + } +}); + +test("setDoNotClean / setExcludedFromTruncatedCheck: write, load, remove", async () => { + const dir = await scratch(); + await setDoNotClean(dir, true, "kept"); + const dnc = await loadDoNotClean(dir); + assert.equal(dnc?.note, "kept"); + assert.ok(dnc?.setAt); + await setDoNotClean(dir, false); + assert.equal(await loadDoNotClean(dir), null); + + await setExcludedFromTruncatedCheck(dir, true); + const etc = await loadExcludeTruncatedCheck(dir); + assert.ok(etc?.setAt); + assert.equal("note" in (etc ?? {}), false); + await setExcludedFromTruncatedCheck(dir, false); + assert.equal(await loadExcludeTruncatedCheck(dir), null); + assert.deepEqual(await readdir(dir), []); +}); + +test("availability / download-outcome / transcribe-outcome round-trip, indented with a newline", async () => { + const dir = await scratch(); + const avail: AvailabilityRecord = { + checkedAt: "2026-01-01T00:00:00.000Z", + availability: "public", + history: [{ availability: "public", observedAt: "2026-01-01T00:00:00.000Z", source: "check" }], + } as AvailabilityRecord; + await writeAvailability(dir, avail); + assert.deepEqual(await loadAvailability(dir), avail); + assert.equal( + await readFile(path.join(dir, "availability.json"), "utf8"), + JSON.stringify(avail, null, 2) + "\n", + ); + + const dlo = { + status: "ok", + videoId: "v1", + startedAt: "2026-01-01T00:00:00.000Z", + finishedAt: "2026-01-01T00:01:00.000Z", + attempts: [], + } as unknown as DownloadOutcomeRecord; + await writeDownloadOutcome(dir, dlo); + assert.deepEqual(await loadDownloadOutcome(dir), dlo); + + const tro = { + videoId: "v1", + transcribedAt: "2026-01-01T00:00:00.000Z", + } as unknown as TranscribeOutcomeRecord; + await writeTranscribeOutcome(dir, tro); + assert.deepEqual(await loadTranscribeOutcome(dir), tro); +}); + +test("a load keeps keys the shape check does not name (no strip on a sidecar)", async () => { + const dir = await scratch(); + const raw = { + videoId: "v1", + transcribedAt: "2026-01-01T00:00:00.000Z", + extra: { kept: true }, + }; + await writeFile(path.join(dir, "transcribe-outcome.json"), JSON.stringify(raw)); + assert.deepEqual(await loadTranscribeOutcome(dir), raw); +}); diff --git a/common/lib/sidecar-server.ts b/common/lib/sidecar-server.ts @@ -0,0 +1,104 @@ +// ONE DECLARATION PER PER-VIDEO SIDECAR: its filename, its read, its write. +// +// one-core phase 3 slice 4b. Each `<video dir>/<name>.json` sidecar used to be +// a hand-written `X-server.ts` with the same four moves — join the path, read + +// JSON.parse + a shape check inside a try that turns anything wrong into null, +// tmp + rename on write, `rm -f` to clear — copied per file. `sidecar()` is those +// four moves once; an `X-server.ts` now declares its sidecar and keeps only what +// is genuinely its own (a merge, a history append, a derived predicate). +// +// const s = sidecar(ATTRIBUTION_FILENAME, sidecarField(coerceAttribution), { indent: 0 }); +// +// THE NAMING GUARD RUNS AT DECLARATION. `SUB_FILE_RE` (lib/videoStatus.ts) +// claims any `transcript.<x>.<y>` file in a video dir as a subtitle track; a +// sidecar so named would be indexed as a language. `sidecar()` throws when its +// filename matches, and every declaration runs at module load — so a misnamed +// sidecar fails every test that imports it, and the editor's boot, rather than +// quietly becoming a `<x>`-language track. `SIDECAR_FILENAMES` lists every +// declared name for the enumeration test. +// +// READS NEVER THROW. Absent, unreadable, unparseable, or rejected by the +// coercion — all read as null, exactly the rule every sidecar reader had: one +// corrupt sidecar must never fail a channel-wide sweep. A coercion MAY decide a +// parseable-but-malformed file is not null (doNotClean: "any marker at all means +// protected") — that decision is the coercion's, not this module's. +// +// NO FRESHNESS CHECK. The plan named an mtime freshness field; no reader +// consumes one, so none was built (slice 4b record, deviation 1). +// +// SERVER-ONLY (zod, node:fs). + +import path from "node:path"; +import { rm } from "node:fs/promises"; +import { z } from "zod"; +import { SUB_FILE_RE } from "./videoStatus"; +import { + readJsonFile, + writeJsonAtomic, + type WriteJsonOptions, +} from "./jsonFile-server"; + +// A sidecar's schema: any parsed JSON in, the record or null out. +export type SidecarSchema<T> = z.ZodType<T | null, unknown>; + +// A sidecar FIELD: the existing shape check as a zod schema. It is +// settingsSchema.ts's `settingsField` minus the `.catch`: a settings coercion +// falls back to a default, a sidecar coercion falls back to null ("absent"). +export function sidecarField<T>( + coerce: (value: unknown) => T | null, +): SidecarSchema<T> { + return z.unknown().transform((value): T | null => coerce(value)); +} + +export type Sidecar<T> = { + filename: string; + path: (videoDir: string) => string; + load: (videoDir: string) => Promise<T | null>; + write: (videoDir: string, value: T) => Promise<void>; + remove: (videoDir: string) => Promise<void>; +}; + +const declared: string[] = []; + +// Every filename declared through `sidecar()` in this process, in declaration +// order. Populated as each `X-server.ts` loads. +export const SIDECAR_FILENAMES: readonly string[] = declared; + +export function sidecar<T>( + filename: string, + schema: SidecarSchema<T>, + opts: Pick<WriteJsonOptions, "indent" | "newline"> = {}, +): Sidecar<T> { + if (SUB_FILE_RE.test(filename)) { + throw new Error( + `Sidecar "${filename}" matches SUB_FILE_RE (transcript.<x>.<y>) and would be read as a subtitle track — rename it`, + ); + } + if (filename.includes("/") || filename.includes(path.sep)) { + throw new Error(`Sidecar "${filename}" must be a bare filename`); + } + if (!declared.includes(filename)) declared.push(filename); + const at = (videoDir: string) => path.join(videoDir, filename); + return { + filename, + path: at, + async load(videoDir) { + const read = await readJsonFile(at(videoDir)); + if (!read.ok) return null; + try { + const parsed = schema.safeParse(read.value); + return parsed.success ? parsed.data : null; + } catch { + // A coercion that throws on some input is a bug in the coercion; it + // still must not fail a sweep. + return null; + } + }, + async write(videoDir, value) { + await writeJsonAtomic(at(videoDir), value, opts); + }, + async remove(videoDir) { + await rm(at(videoDir), { force: true }); + }, + }; +} diff --git a/common/lib/transcribeOutcome-server.ts b/common/lib/transcribeOutcome-server.ts @@ -1,37 +1,29 @@ -import { writeJsonAtomic } from "./jsonFile-server"; -import path from "node:path"; -import { readFile } from "node:fs/promises"; import { TRANSCRIBE_OUTCOME_FILENAME, type TranscribeOutcomeRecord, } from "./transcribeOutcome"; +import { sidecar, sidecarField } from "./sidecar-server"; -export function transcribeOutcomePath(videoDir: string): string { - return path.join(videoDir, TRANSCRIBE_OUTCOME_FILENAME); -} - -export async function loadTranscribeOutcome( - videoDir: string, -): Promise<TranscribeOutcomeRecord | null> { - try { - const raw = await readFile(transcribeOutcomePath(videoDir), "utf8"); - const parsed = JSON.parse(raw) as Partial<TranscribeOutcomeRecord>; - if ( - typeof parsed?.videoId === "string" && - typeof parsed.transcribedAt === "string" - ) { - return parsed as TranscribeOutcomeRecord; - } - return null; - } catch { - return null; +export function coerceTranscribeOutcome( + value: unknown, +): TranscribeOutcomeRecord | null { + const parsed = value as Partial<TranscribeOutcomeRecord> | null; + if ( + typeof parsed?.videoId === "string" && + typeof parsed.transcribedAt === "string" + ) { + return parsed as TranscribeOutcomeRecord; } + return null; } -export async function writeTranscribeOutcome( - videoDir: string, - record: TranscribeOutcomeRecord, -): Promise<void> { - const file = transcribeOutcomePath(videoDir); - await writeJsonAtomic(file, record); -} +export const transcribeOutcomeSidecar = sidecar( + TRANSCRIBE_OUTCOME_FILENAME, + sidecarField(coerceTranscribeOutcome), +); + +export const { + path: transcribeOutcomePath, + load: loadTranscribeOutcome, + write: writeTranscribeOutcome, +} = transcribeOutcomeSidecar; diff --git a/common/lib/videoStatus.ts b/common/lib/videoStatus.ts @@ -83,7 +83,9 @@ export type SubTrack = { // primary transcript outputs (transcript.en.vtt, transcript.json) or a // derived/auxiliary file (transcript.cues.json). Live chat lands as // transcript.live_chat.json; non-en languages as transcript.<lang>.vtt. -const SUB_FILE_RE = /^transcript\.([^.]+)\.([^.]+)$/; +// Exported for lib/sidecar-server.ts, which refuses to declare a sidecar this +// matches (a sidecar so named would be read as a subtitle track). +export const SUB_FILE_RE = /^transcript\.([^.]+)\.([^.]+)$/; const SUB_EXT_VALUES = ["vtt", "json", "json3", "srv1", "srv2", "srv3"] as const; type SubExt = (typeof SUB_EXT_VALUES)[number]; function isSubExt(value: string): value is SubExt {