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:
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 {