commit ef0f538cf57463d8d9ddcf4ec44bd90130a8b98e
parent 4974c94023d518fe75742c78080d16c20473a675
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Tue, 6 Oct 2026 08:47:32 -0400
captions: one caption-track rule — en-orig first, then the first track with cues
The rule lives in lib/videoStatus.ts and nowhere else. englishVttsByPreference
orders a video's English VTTs: en-orig (the captions of the original audio),
then en (which YouTube can serve as a rewrite of what was said, or in a
cue-block shape), then regional, then auto-translated en-en-*.
readEnglishVttCues reads the first of them that parses to at least one cue.
resolvePrimaryVtt stays the name-only answer (what the editor labels primary).
CAPTION_TRACK_RULE_VERSION names the rule.
- normalize reads through readEnglishVttCues and records vttFile and
captionTrackRule in the file's first bytes.
- isCuesJsonFresh compares against every caption input (each English VTT,
since the fallback may read any, and the operator's pin), and a caption
cues.json is stale where the rule could read it differently: more than one
English VTT and no current rule recorded, or a lone cue-block VTT whose
cues.json holds no cues. One or two small head/tail reads, never a parse.
normalize asks the same question, so a Normalize run rewrites exactly those.
- composeReports drops its own copy of the order for the shared one.
- The editor's pick wins: transcript-pin.json (a declared sidecar) ranks
transcript.en.vtt first while present.
- Auto-captions-only is decided over every English track
(resolveCaptionsProvenance), so a video with a human en beside an ASR
en-orig is still never scheduled for replacement; the snapshot's
non-standard-name bucket counts en-orig as standard.
- readSubTracks lists the served en as an alternate beside an en-orig primary.
- umtool's local cue lookup refuses a caption record normalized under an
older rule where it could differ, naming Normalize as the fix, rather than
widen clips on words the corpus no longer publishes (twin constant, held
equal by a test).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
11 files changed, 629 insertions(+), 86 deletions(-)
diff --git a/common/controller/channelSnapshot.ts b/common/controller/channelSnapshot.ts
@@ -10,6 +10,7 @@ import {
isVideoTranscribed,
readVideoFiles,
LIVE_CHAT_FILENAME,
+ ORIG_VTT_FILENAME,
VTT_FILENAME,
type VideoFiles,
} from "../lib/videoStatus";
@@ -74,7 +75,7 @@ import { loadMaybeMissing } from "./quickAvailabilityCheck";
import { loadRoster } from "./rosterStore";
import { deriveChannelSets } from "./channelSets";
import { readTranscriptCoverage } from "./normalizeTranscript";
-import { resolveVttProvenance } from "../lib/subtitleProvenance";
+import { resolveCaptionsProvenance } from "../lib/subtitleProvenance";
import { isIncompleteTranscript } from "../lib/transcriptCoverage";
// THE PER-OPERATION WORK LISTS, keyed by operation id.
@@ -1007,8 +1008,10 @@ export async function generateChannelSnapshot(
// both the auto-subs work lane (no whisper yet) and the superseded
// backup inventory (whisper already won) need it. Same conditional
// per-video sidecar read pattern as the cues.json coverage read above.
+ // Over every English VTT (resolveCaptionsProvenance): the rule reads
+ // en-orig even beside a human `en`, which is not ASR-only.
const vttProvenance = files.ytVttFile
- ? await resolveVttProvenance(dir, files.ytVttFile)
+ ? await resolveCaptionsProvenance(dir, files.entries)
: null;
// Only transcribed videos can carry a digest, so everything else skips
// the sidecar read entirely — the same conditional per-video
@@ -1414,11 +1417,13 @@ export async function generateChannelSnapshot(
// A transcript that exists only under a non-standard VTT name — either a
// regional/auto English track (transcript.en-US.vtt) now picked up by the
// fallback, or a foreign-only transcript.<lang>.vtt that isn't recognized as
- // English. Whisper transcripts and the canonical transcript.en.vtt are fine.
+ // English. Whisper transcripts, the canonical transcript.en.vtt and the
+ // original-audio transcript.en-orig.vtt (the rule's first pick) are fine.
if (
!files.hasWhisper &&
files.hasNonCanonicalVtt &&
- files.ytVttFile !== VTT_FILENAME
+ files.ytVttFile !== VTT_FILENAME &&
+ files.ytVttFile !== ORIG_VTT_FILENAME
) {
nonStandardVtt.push(id);
}
diff --git a/common/controller/normalizeCaptionTrack.test.ts b/common/controller/normalizeCaptionTrack.test.ts
@@ -0,0 +1,141 @@
+// transcript.cues.json under the caption-track rule: normalize records the
+// track it read and the rule that chose it, and a cues.json made under an older
+// rule is stale exactly where the rule could now read something else.
+//
+// Run with: node_modules/.bin/tsx --test common/controller/normalizeCaptionTrack.test.ts
+
+import { after, test } from "node:test";
+import assert from "node:assert/strict";
+import { mkdirSync, mkdtempSync, readFileSync, rmSync, utimesSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import { isCuesJsonFresh, normalizeTranscript, readNormalizedTranscript } from "./normalizeTranscript";
+import { CAPTION_TRACK_RULE_VERSION, TRANSCRIPT_PIN_FILENAME } from "../lib/videoStatus";
+
+const ROOT = mkdtempSync(path.join(tmpdir(), "normalize-caption-"));
+after(() => rmSync(ROOT, { recursive: true, force: true }));
+
+const fixture = (name: string) =>
+ readFileSync(path.join(import.meta.dirname, "..", "lib", "__fixtures__", name), "utf8");
+const ROLLING = fixture("vtt-rolling.vtt");
+const CUE_BLOCKS = fixture("vtt-cue-blocks.vtt");
+
+const META = {
+ id: "vid",
+ title: "A video",
+ upload_date: "20260601",
+ duration: 30,
+ webpage_url: "https://www.youtube.com/watch?v=vid",
+ extractor_key: "Youtube",
+};
+
+let n = 0;
+function videoDir(files: Record<string, string>): string {
+ const dir = path.join(ROOT, `c${n}`, "data", `vid${n++}`);
+ mkdirSync(dir, { recursive: true });
+ writeFileSync(path.join(dir, "metadata.info.json"), JSON.stringify(META));
+ for (const [name, body] of Object.entries(files)) writeFileSync(path.join(dir, name), body);
+ return dir;
+}
+
+// A cues.json as the app wrote it before the rule had a version: no track
+// recorded, newer than every input.
+function oldCuesJson(dir: string, cues: { start: number; end: number; text: string }[]): void {
+ const p = path.join(dir, "transcript.cues.json");
+ writeFileSync(
+ p,
+ JSON.stringify({ version: 2, source: "vtt", transcriptFormat: "vtt", id: "vid", slug: "c/vid", cues }),
+ );
+ const later = new Date(Date.now() + 10_000);
+ utimesSync(p, later, later);
+}
+
+test("normalize reads en-orig, and records the track and the rule in the file's first bytes", async () => {
+ const dir = videoDir({ "transcript.en.vtt": CUE_BLOCKS, "transcript.en-orig.vtt": ROLLING });
+ const out = await normalizeTranscript({ videoDir: dir, channelSlug: "c" });
+ assert.equal(out.status, "wrote");
+ const raw = readFileSync(path.join(dir, "transcript.cues.json"), "utf8");
+ assert.match(raw.slice(0, 200), /"vttFile":"transcript\.en-orig\.vtt","captionTrackRule":\d+/);
+ const n = await readNormalizedTranscript(path.join(dir, "transcript.cues.json"));
+ assert.equal(n?.vttFile, "transcript.en-orig.vtt");
+ assert.equal(n?.captionTrackRule, CAPTION_TRACK_RULE_VERSION);
+ assert.equal(n?.cues[0].text, "are talking about the harbor");
+ assert.equal((await isCuesJsonFresh(dir)).fresh, true);
+ assert.equal((await normalizeTranscript({ videoDir: dir, channelSlug: "c" })).status, "fresh");
+});
+
+test("a cues.json from before the rule is stale beside two English tracks, and normalize rewrites it", async () => {
+ const dir = videoDir({ "transcript.en.vtt": CUE_BLOCKS, "transcript.en-orig.vtt": ROLLING });
+ oldCuesJson(dir, [{ start: 0, end: 1, text: "served words" }]);
+ assert.deepEqual(await isCuesJsonFresh(dir), {
+ fresh: false,
+ reason: "stale",
+ cuesPath: path.join(dir, "transcript.cues.json"),
+ });
+ assert.equal((await normalizeTranscript({ videoDir: dir, channelSlug: "c" })).status, "wrote");
+ assert.equal((await isCuesJsonFresh(dir)).fresh, true);
+});
+
+test("a cues.json from before the rule is stale over a lone cue-block track (it parsed to nothing)", async () => {
+ const dir = videoDir({ "transcript.en.vtt": CUE_BLOCKS });
+ oldCuesJson(dir, []);
+ assert.equal((await isCuesJsonFresh(dir)).fresh, false);
+ await normalizeTranscript({ videoDir: dir, channelSlug: "c" });
+ const n = await readNormalizedTranscript(path.join(dir, "transcript.cues.json"));
+ assert.equal(n?.cues.length, 7);
+ assert.equal((await isCuesJsonFresh(dir)).fresh, true);
+});
+
+test("a cues.json from before the rule that holds cues stays fresh over a lone cue-block track: the old parse made none", async () => {
+ const dir = videoDir({ "transcript.en.vtt": CUE_BLOCKS });
+ oldCuesJson(dir, [{ start: 0, end: 1, text: "written by hand" }]);
+ assert.equal((await isCuesJsonFresh(dir)).fresh, true);
+});
+
+test("a cues.json from before the rule stays fresh over a lone rolling track: nothing to choose, same parse", async () => {
+ const dir = videoDir({ "transcript.en.vtt": ROLLING });
+ oldCuesJson(dir, [{ start: 0, end: 1, text: "kept" }]);
+ assert.equal((await isCuesJsonFresh(dir)).fresh, true);
+ assert.equal((await normalizeTranscript({ videoDir: dir, channelSlug: "c" })).status, "fresh");
+});
+
+test("a newer secondary track, or a new pin, makes cues.json stale", async () => {
+ const dir = videoDir({ "transcript.en.vtt": CUE_BLOCKS, "transcript.en-orig.vtt": ROLLING });
+ const at = (name: string, s: number) => {
+ const t = new Date(Date.now() + s * 1000);
+ utimesSync(path.join(dir, name), t, t);
+ };
+ await normalizeTranscript({ videoDir: dir, channelSlug: "c" });
+ at("transcript.cues.json", 10);
+ assert.equal((await isCuesJsonFresh(dir)).fresh, true);
+ // Not the track the rule reads — still an input (the fallback may read it).
+ at("transcript.en.vtt", 20);
+ assert.equal((await isCuesJsonFresh(dir)).reason, "stale");
+ await normalizeTranscript({ videoDir: dir, channelSlug: "c" });
+ at("transcript.cues.json", 30);
+ assert.equal((await isCuesJsonFresh(dir)).fresh, true);
+
+ // The operator pins transcript.en.vtt: stale, and the rewrite reads en.
+ writeFileSync(
+ path.join(dir, TRANSCRIPT_PIN_FILENAME),
+ JSON.stringify({ from: "transcript.en.vtt", pinnedAt: "" }),
+ );
+ at(TRANSCRIPT_PIN_FILENAME, 40);
+ assert.equal((await isCuesJsonFresh(dir)).fresh, false);
+ await normalizeTranscript({ videoDir: dir, channelSlug: "c" });
+ const n = await readNormalizedTranscript(path.join(dir, "transcript.cues.json"));
+ assert.equal(n?.vttFile, "transcript.en.vtt");
+});
+
+test("whisper wins: a whisper cues.json is never asked about caption tracks", async () => {
+ const dir = videoDir({
+ "transcript.en.vtt": CUE_BLOCKS,
+ "transcript.en-orig.vtt": ROLLING,
+ "transcript.json": JSON.stringify({ transcription: [{ offsets: { from: 0, to: 1000 }, text: "spoken" }] }),
+ });
+ const p = path.join(dir, "transcript.cues.json");
+ writeFileSync(p, JSON.stringify({ version: 2, source: "whisper", cues: [{ start: 0, end: 1, text: "spoken" }] }));
+ const later = new Date(Date.now() + 10_000);
+ utimesSync(p, later, later);
+ assert.equal((await isCuesJsonFresh(dir)).fresh, true);
+});
diff --git a/common/controller/normalizeTranscript.ts b/common/controller/normalizeTranscript.ts
@@ -4,9 +4,9 @@
// shape that buildIndex would emit, plus a `source` marker.
import path from "node:path";
-import { readdir, readFile, stat } from "node:fs/promises";
+import { open, readdir, readFile, stat } from "node:fs/promises";
import { writeJsonAtomic } from "../lib/jsonFile-server";
-import { parseVtt, type Cue } from "../lib/vtt";
+import { hasWordTiming, type Cue } from "../lib/vtt";
import {
detectTranscriptFormat,
parseTranscriptJson,
@@ -19,13 +19,15 @@ import {
type TranscriptCoverage,
} from "../lib/transcriptCoverage";
import {
+ CAPTION_TRACK_RULE_VERSION,
CUES_JSON_FILENAME,
META_FILENAME,
- VTT_FILENAME,
WHISPER_FILENAME,
+ captionInputs,
+ englishVttsByPreference,
pickIndexTranscript,
+ readEnglishVttCues,
readVideoFiles,
- resolvePrimaryVtt,
type IndexTranscript,
} from "../lib/videoStatus";
@@ -36,6 +38,12 @@ export const CUES_FILE_VERSION = 2;
export type NormalizedTranscript = TranscriptDetail & {
version: number;
source: IndexTranscript["kind"] | "live_chat";
+ // source "vtt" only: the English VTT the cues were read from, and the
+ // caption-track rule that chose it (CAPTION_TRACK_RULE_VERSION). Written
+ // right after `source`, so they sit in the file's first bytes and
+ // isCuesJsonFresh can read them without parsing the cues.
+ vttFile?: string;
+ captionTrackRule?: number;
// The raw transcript format this was parsed from. Durable per-video record so
// a later re-normalize knows how to read the raw file without re-sniffing.
transcriptFormat?: TranscriptOutputFormat;
@@ -76,24 +84,14 @@ export async function normalizeTranscript(
const picked = pickIndexTranscript(files);
if (!picked) return { status: "skipped", reason: "no-raw-transcript" };
- const cuesPath = path.join(opts.videoDir, CUES_JSON_FILENAME);
const metaPath = path.join(opts.videoDir, META_FILENAME);
const transcriptPath = path.join(opts.videoDir, picked.filename);
- const [cuesMs, metaStatMs, transcriptStatMs] = await Promise.all([
- mtimeMs(cuesPath),
- mtimeMs(metaPath),
- mtimeMs(transcriptPath),
- ]);
-
- if (
- !opts.force &&
- cuesMs !== null &&
- metaStatMs !== null &&
- transcriptStatMs !== null &&
- cuesMs >= metaStatMs &&
- cuesMs >= transcriptStatMs
- ) {
+ // The same question every reader of cues.json asks (isCuesJsonFresh), so a
+ // normalize pass rewrites exactly the files they refuse.
+ const freshness = await isCuesJsonFresh(opts.videoDir);
+ const cuesPath = freshness.cuesPath;
+ if (!opts.force && freshness.fresh) {
return { status: "fresh", cuesPath };
}
@@ -106,14 +104,20 @@ export async function normalizeTranscript(
opts.configName,
);
- const rawTranscript = await readFile(transcriptPath, "utf8");
let cues: Cue[];
let transcriptFormat: TranscriptOutputFormat;
+ let vttFile: string | undefined;
try {
if (picked.kind === "vtt") {
- cues = parseVtt(rawTranscript);
+ // The caption-track rule: the first English VTT, in preference order,
+ // with cues (lib/videoStatus.ts).
+ const read = await readEnglishVttCues(opts.videoDir, files.entries);
+ if (!read) throw new Error("no English VTT could be read");
+ cues = read.cues;
+ vttFile = read.filename;
transcriptFormat = "vtt";
} else {
+ const rawTranscript = await readFile(transcriptPath, "utf8");
// Resolve the JSON format: authoritative hint -> recorded per-video tag
// -> content sniff -> whisper fallback (the only app before chough).
transcriptFormat =
@@ -132,6 +136,9 @@ export async function normalizeTranscript(
const out: NormalizedTranscript = {
version: CUES_FILE_VERSION,
source: picked.kind,
+ ...(vttFile !== undefined
+ ? { vttFile, captionTrackRule: CAPTION_TRACK_RULE_VERSION }
+ : {}),
transcriptFormat,
...summary,
cues,
@@ -140,7 +147,7 @@ export async function normalizeTranscript(
// Compact, no trailing newline: transcript.cues.json's historical bytes.
await writeJsonAtomic(cuesPath, out, { indent: 0, newline: false });
opts.log?.(
- `Normalized ${opts.channelSlug}/${path.basename(opts.videoDir)} (${transcriptFormat}, ${cues.length} cues)`,
+ `Normalized ${opts.channelSlug}/${path.basename(opts.videoDir)} (${vttFile ?? transcriptFormat}, ${cues.length} cues)`,
);
return { status: "wrote", cuesPath };
}
@@ -214,22 +221,33 @@ export type CuesFreshReason =
// Helper: given a video dir, decide whether transcript.cues.json (if present)
// is at least as new as metadata.info.json and the raw transcript file. Used
// by buildIndex to know whether it can trust cues.json without re-parsing.
+//
+// CAPTIONS: the raw transcript is EVERY caption input (captionInputs — each
+// English VTT, since the content fallback may read any of them, and the
+// operator's pin), and a cues.json that is new enough must also have been made
+// under the current caption-track rule. Made under an older one, it is `stale`
+// where the rule could read it differently: its cues may come from a track the
+// rule no longer picks (a served `en` beside an `en-orig`), or be the zero cues
+// a cue-block VTT used to parse to (followsCaptionTrackRule). Telling costs one
+// or two small reads, of a file's head or tail, never a parse.
export async function isCuesJsonFresh(
videoDir: string,
): Promise<{ fresh: boolean; reason: CuesFreshReason; cuesPath: string }> {
const cuesPath = path.join(videoDir, CUES_JSON_FILENAME);
const metaPath = path.join(videoDir, META_FILENAME);
- // Resolve the actual primary VTT (may be a regional/auto English track like
- // transcript.en-US.vtt) rather than assuming the literal transcript.en.vtt.
const entries = await readdir(videoDir).catch(() => [] as string[]);
- const vttPath = path.join(videoDir, resolvePrimaryVtt(entries) ?? VTT_FILENAME);
const whisperPath = path.join(videoDir, WHISPER_FILENAME);
- const [cuesMs, metaMs, vttMs, whisperMs] = await Promise.all([
+ const inputs = captionInputs(entries);
+ const [cuesMs, metaMs, whisperMs, ...inputMs] = await Promise.all([
mtimeMs(cuesPath),
mtimeMs(metaPath),
- mtimeMs(vttPath),
mtimeMs(whisperPath),
+ ...inputs.map((n) => mtimeMs(path.join(videoDir, n))),
]);
+ const vttMs = inputMs.reduce<number | null>(
+ (max, ms) => (ms !== null && (max === null || ms > max) ? ms : max),
+ null,
+ );
// Prefer whisper if present (matches pickIndexTranscript priority).
const rawMs = whisperMs ?? vttMs;
// Ordered to match normalizeTranscript's OWN precedence (metadata, then raw,
@@ -243,5 +261,66 @@ export async function isCuesJsonFresh(
if (cuesMs < metaMs || cuesMs < rawMs) {
return { fresh: false, reason: "stale", cuesPath };
}
+ if (whisperMs === null && !(await followsCaptionTrackRule(videoDir, cuesPath, entries))) {
+ return { fresh: false, reason: "stale", cuesPath };
+ }
return { fresh: true, reason: "fresh", cuesPath };
}
+
+const HEAD_BYTES = 2048;
+
+async function readHead(file: string, bytes = HEAD_BYTES): Promise<string> {
+ const fh = await open(file, "r");
+ try {
+ const buf = Buffer.alloc(bytes);
+ const { bytesRead } = await fh.read(buf, 0, bytes, 0);
+ return buf.subarray(0, bytesRead).toString("utf8");
+ } finally {
+ await fh.close();
+ }
+}
+
+async function readTail(file: string, bytes = 64): Promise<string> {
+ const fh = await open(file, "r");
+ try {
+ const { size } = await fh.stat();
+ const start = Math.max(0, size - bytes);
+ const buf = Buffer.alloc(size - start);
+ const { bytesRead } = await fh.read(buf, 0, buf.length, start);
+ return buf.subarray(0, bytesRead).toString("utf8");
+ } finally {
+ await fh.close();
+ }
+}
+
+// Whether a caption cues.json, already new enough by mtime, was made under the
+// current caption-track rule — or could not read differently under it:
+//
+// one English VTT with word timing — nothing to choose, and the parse of
+// that shape did not change;
+// one English VTT in cue blocks, and the file holds cues — the older parse
+// read that shape as NO cues, so a file with some was not made by it. The
+// cues are the last key normalize writes, so "none" is the file's tail.
+//
+// Anything else must carry `captionTrackRule`, which normalize writes into the
+// file's first bytes. A mis-read head only costs a rewrite, after which the
+// record is there.
+async function followsCaptionTrackRule(
+ videoDir: string,
+ cuesPath: string,
+ entries: readonly string[],
+): Promise<boolean> {
+ const vtts = englishVttsByPreference(entries);
+ if (vtts.length === 0) return true;
+ try {
+ if (vtts.length === 1 && hasWordTiming(await readHead(path.join(videoDir, vtts[0])))) {
+ return true;
+ }
+ const m = (await readHead(cuesPath)).match(/"captionTrackRule"\s*:\s*(\d+)/);
+ if (m !== null && Number(m[1]) === CAPTION_TRACK_RULE_VERSION) return true;
+ if (vtts.length > 1) return false;
+ return !/"cues"\s*:\s*\[\s*\]\s*\}\s*$/.test(await readTail(cuesPath));
+ } catch {
+ return false;
+ }
+}
diff --git a/common/lib/captionTrack.test.ts b/common/lib/captionTrack.test.ts
@@ -0,0 +1,117 @@
+// The caption-track rule (lib/videoStatus.ts): which English VTT a video's
+// transcript is read from, by name and then by content.
+//
+// Run with: node_modules/.bin/tsx --test common/lib/captionTrack.test.ts
+
+import { after, test } from "node:test";
+import assert from "node:assert/strict";
+import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import {
+ CAPTION_TRACK_RULE_VERSION,
+ TRANSCRIPT_PIN_FILENAME,
+ captionInputs,
+ englishVttsByPreference,
+ readEnglishVttCues,
+ readSubTracks,
+ resolvePrimaryVtt,
+} from "./videoStatus";
+
+const ROOT = mkdtempSync(path.join(tmpdir(), "caption-track-"));
+after(() => rmSync(ROOT, { recursive: true, force: true }));
+
+const fixture = (name: string) =>
+ readFileSync(path.join(import.meta.dirname, "__fixtures__", name), "utf8");
+
+const ROLLING = fixture("vtt-rolling.vtt");
+const CUE_BLOCKS = fixture("vtt-cue-blocks.vtt");
+const EMPTY = "WEBVTT\nKind: captions\nLanguage: en\n\n";
+
+let n = 0;
+function videoDir(files: Record<string, string>): string {
+ const dir = path.join(ROOT, `v${n++}`);
+ mkdirSync(dir, { recursive: true });
+ for (const [name, body] of Object.entries(files)) writeFileSync(path.join(dir, name), body);
+ return dir;
+}
+
+test("en-orig ranks above en; regional above auto-translated; translations are not English", () => {
+ const entries = [
+ "transcript.en-en-US.vtt",
+ "transcript.en.vtt",
+ "transcript.es-en-US.vtt",
+ "transcript.en-GB.vtt",
+ "transcript.en-orig.vtt",
+ "transcript.json",
+ ];
+ assert.deepEqual(englishVttsByPreference(entries), [
+ "transcript.en-orig.vtt",
+ "transcript.en.vtt",
+ "transcript.en-GB.vtt",
+ "transcript.en-en-US.vtt",
+ ]);
+ assert.equal(resolvePrimaryVtt(entries), "transcript.en-orig.vtt");
+ assert.equal(resolvePrimaryVtt(["transcript.en.vtt", "transcript.en-US.vtt"]), "transcript.en.vtt");
+ assert.equal(resolvePrimaryVtt(["transcript.es.vtt"]), null);
+});
+
+test("the operator's pin puts transcript.en.vtt first, and is a caption input", () => {
+ const entries = ["transcript.en-orig.vtt", "transcript.en.vtt", TRANSCRIPT_PIN_FILENAME];
+ assert.equal(resolvePrimaryVtt(entries), "transcript.en.vtt");
+ assert.deepEqual(captionInputs(entries), [
+ "transcript.en.vtt",
+ "transcript.en-orig.vtt",
+ TRANSCRIPT_PIN_FILENAME,
+ ]);
+ // A pin with no English VTT is no input.
+ assert.deepEqual(captionInputs([TRANSCRIPT_PIN_FILENAME]), []);
+});
+
+test("readEnglishVttCues reads en-orig when both tracks have text", async () => {
+ const dir = videoDir({
+ "transcript.en.vtt": CUE_BLOCKS,
+ "transcript.en-orig.vtt": ROLLING,
+ });
+ const got = await readEnglishVttCues(dir);
+ assert.equal(got?.filename, "transcript.en-orig.vtt");
+ assert.equal(got?.cues[0].text, "are talking about the harbor");
+});
+
+test("readEnglishVttCues falls back past a track with no cues", async () => {
+ const dir = videoDir({
+ "transcript.en-orig.vtt": EMPTY,
+ "transcript.en.vtt": CUE_BLOCKS,
+ });
+ const got = await readEnglishVttCues(dir);
+ assert.equal(got?.filename, "transcript.en.vtt");
+ assert.equal(got?.cues.length, 7);
+});
+
+test("readEnglishVttCues: every track empty is the first track with no cues; none is null", async () => {
+ const dir = videoDir({
+ "transcript.en-orig.vtt": EMPTY,
+ "transcript.en.vtt": EMPTY,
+ });
+ assert.deepEqual(await readEnglishVttCues(dir), { filename: "transcript.en-orig.vtt", cues: [] });
+ assert.equal(await readEnglishVttCues(videoDir({ "transcript.es.vtt": ROLLING })), null);
+});
+
+test("readSubTracks lists the served en as an alternate beside an en-orig primary", async () => {
+ const dir = videoDir({
+ "transcript.en.vtt": CUE_BLOCKS,
+ "transcript.en-orig.vtt": ROLLING,
+ "transcript.es.vtt": ROLLING,
+ });
+ const tracks = (await readSubTracks(dir)).map((t) => t.track).sort();
+ assert.deepEqual(tracks, ["en", "es"]);
+});
+
+test("umtool's copy of the caption-track rule version matches", () => {
+ const src = readFileSync(
+ path.join(import.meta.dirname, "..", "..", "umtool", "report-to-video", "cues.mjs"),
+ "utf8",
+ );
+ const m = src.match(/export const CAPTION_TRACK_RULE_VERSION = (\d+);/);
+ assert.equal(Number(m?.[1]), CAPTION_TRACK_RULE_VERSION);
+});
diff --git a/common/lib/sidecar-server.test.ts b/common/lib/sidecar-server.test.ts
@@ -11,6 +11,7 @@ import "./attribution-server";
import "./diarization-server";
import "./digest-server";
import "./metadataHistory-server";
+import "./transcriptPin-server";
import {
availabilitySidecar,
loadAvailability,
@@ -42,7 +43,7 @@ async function scratch(): Promise<string> {
return mkdtemp(path.join(os.tmpdir(), "sidecar-"));
}
-test("every declared sidecar filename escapes SUB_FILE_RE, and all eleven are declared", () => {
+test("every declared sidecar filename escapes SUB_FILE_RE, and all twelve are declared", () => {
assert.deepEqual([...SIDECAR_FILENAMES].sort(), [
"ai-digest.json",
"ai-digest.overrides.json",
@@ -55,6 +56,7 @@ test("every declared sidecar filename escapes SUB_FILE_RE, and all eleven are de
"exclude-truncated-check.json",
"metadata.history.json",
"transcribe-outcome.json",
+ "transcript-pin.json",
]);
for (const name of SIDECAR_FILENAMES) {
assert.ok(!SUB_FILE_RE.test(name), name);
diff --git a/common/lib/subtitleProvenance.ts b/common/lib/subtitleProvenance.ts
@@ -1,7 +1,7 @@
import path from "node:path";
import { createReadStream } from "node:fs";
import { readFile } from "node:fs/promises";
-import type { VideoFiles } from "./videoStatus";
+import { englishVttsByPreference, type VideoFiles } from "./videoStatus";
// Where a VTT transcript came from: YouTube's speech recognition ("asr", what
// yt-dlp downloads under --write-auto-subs) or a human-authored/uploaded track
@@ -140,6 +140,27 @@ export async function resolveVttProvenance(
}
}
+// Where a video's English CAPTIONS came from, taken over every English VTT and
+// not just the one the caption-track rule reads: "asr" only when each of them
+// is, "manual" when any is, else "unknown". The rule prefers the original-audio
+// ASR track (en-orig) even beside a human `en`, so asking only the track it
+// reads would call a video with human captions ASR-only and schedule them for
+// replacement. Single-track dirs pay the one sniff they always did.
+export async function resolveCaptionsProvenance(
+ videoDir: string,
+ entries: readonly string[],
+): Promise<SubtitleProvenance | null> {
+ const vtts = englishVttsByPreference(entries);
+ if (vtts.length === 0) return null;
+ let unknown = false;
+ for (const name of vtts) {
+ const p = await resolveVttProvenance(videoDir, name);
+ if (p === "manual") return "manual";
+ if (p === "unknown") unknown = true;
+ }
+ return unknown ? "unknown" : "asr";
+}
+
// True when this video's ONLY transcript is YouTube ASR — the work-lane
// candidate rule, shared by the snapshot buckets, the whisper gate
// (transcribeOneFromQueue) and the auto-runner's download override so all four
@@ -152,5 +173,5 @@ export async function isAutoSubsOnly(
if (!files.ytVttFile || files.hasWhisper || files.isUntranscribable) {
return false;
}
- return (await resolveVttProvenance(videoDir, files.ytVttFile)) === "asr";
+ return (await resolveCaptionsProvenance(videoDir, files.entries)) === "asr";
}
diff --git a/common/lib/transcriptPin-server.ts b/common/lib/transcriptPin-server.ts
@@ -0,0 +1,39 @@
+// THE OPERATOR'S CAPTION-TRACK PICK. The editor's "Set as transcript" copies
+// the chosen track to transcript.en.vtt and writes this sidecar beside it;
+// while it is present, the caption-track rule ranks transcript.en.vtt first
+// (lib/videoStatus.ts, englishVttsByPreference) — above en-orig, which the rule
+// otherwise prefers. Only the file's PRESENCE is read by the rule; the record
+// says which track was copied and when, for a person reading the dir.
+//
+// SERVER-ONLY (node:fs).
+
+import { TRANSCRIPT_PIN_FILENAME } from "./videoStatus";
+import { sidecar, sidecarField } from "./sidecar-server";
+
+export type TranscriptPinRecord = {
+ // The track the operator picked (copied to transcript.en.vtt).
+ from: string;
+ pinnedAt: string;
+};
+
+// Any parseable record still pins: the rule reads presence, so the record's
+// shape must never make a pin read as absent here and present there.
+export function coerceTranscriptPin(value: unknown): TranscriptPinRecord {
+ const v = value as Partial<TranscriptPinRecord> | null;
+ return {
+ from: typeof v?.from === "string" ? v.from : "",
+ pinnedAt: typeof v?.pinnedAt === "string" ? v.pinnedAt : "",
+ };
+}
+
+export const transcriptPinSidecar = sidecar(
+ TRANSCRIPT_PIN_FILENAME,
+ sidecarField(coerceTranscriptPin),
+);
+
+export async function pinTranscript(videoDir: string, from: string): Promise<void> {
+ await transcriptPinSidecar.write(videoDir, {
+ from,
+ pinnedAt: new Date().toISOString(),
+ });
+}
diff --git a/common/lib/videoStatus.ts b/common/lib/videoStatus.ts
@@ -1,12 +1,14 @@
import path from "node:path";
import { readdir, readFile, stat } from "node:fs/promises";
import { isPartAudioFile, isRealAudioFile } from "./mediaFiles";
+import { parseVtt, type Cue } from "./vtt";
export type VideoFiles = {
hasMeta: boolean;
hasYtVtt: boolean;
- // The resolved primary English VTT filename (transcript.en.vtt when present,
- // otherwise the best regional/auto English track — see resolvePrimaryVtt).
+ // The resolved primary English VTT filename by name (transcript.en-orig.vtt,
+ // else transcript.en.vtt, else the best regional/auto English track — see
+ // resolvePrimaryVtt; the cues may come from a later one, readEnglishVttCues).
// Null when no English VTT exists. hasYtVtt === (ytVttFile !== null).
ytVttFile: string | null;
// True when a transcript.<lang>.vtt exists under a non-canonical name (i.e.
@@ -40,8 +42,9 @@ export type VideoFiles = {
export type IndexTranscript =
| { kind: "whisper"; filename: "transcript.json" }
- // filename is usually "transcript.en.vtt" but may be a regional/auto English
- // track (e.g. "transcript.en-US.vtt") when YouTube served no plain `en` track.
+ // filename is the most preferred English track by name (resolvePrimaryVtt):
+ // transcript.en-orig.vtt, transcript.en.vtt, or a regional/auto one such as
+ // transcript.en-US.vtt. The cues are read with readEnglishVttCues.
| { kind: "vtt"; filename: string };
export const VTT_FILENAME = "transcript.en.vtt";
@@ -92,18 +95,50 @@ function isSubExt(value: string): value is SubExt {
return (SUB_EXT_VALUES as readonly string[]).includes(value);
}
-// Resolve the primary English transcript VTT in a video dir. Normally this is
-// the canonical transcript.en.vtt, but YouTube sometimes serves a video's
-// English captions only under regional/auto codes (transcript.en-US.vtt,
-// transcript.en-en-US.vtt, transcript.en-orig.vtt) with no plain `en` track. A
-// file counts as English iff the FIRST segment of its language code is `en`, so
-// translations like transcript.ab-en-US.vtt / transcript.es-en-US.vtt are
-// excluded. Preference: en (canonical) > en-orig (original audio) >
-// regional/manual en-US,en-GB,… > auto-translated en-en-* variants.
+// THE CAPTION-TRACK RULE — which English VTT a video's transcript is read from.
+// It lives here and nowhere else; every reader that turns captions into cues
+// (the index, normalize, report compose) goes through englishVttsByPreference /
+// readEnglishVttCues, and the MCP, the export and report-to-video read what
+// those wrote.
+//
+// A file counts as English iff the FIRST segment of its language code is `en`,
+// so translations like transcript.ab-en-US.vtt / transcript.es-en-US.vtt are
+// excluded. Preference:
+//
+// en-orig the captions of the ORIGINAL audio — the speaker's words.
+// en canonical. Usually the same text as en-orig, but for some videos
+// YouTube serves a rewritten/translated `en` that changes facts
+// (a date, a word), and for some livestream VODs one in a cue-block
+// shape with no word timing.
+// en-US, en-GB, … regional/manual.
+// en-en-* auto-translated en→en variants.
+//
+// AN OPERATOR'S PICK BEATS ALL OF IT. The editor's "Set as transcript" copies
+// the chosen track to transcript.en.vtt and writes TRANSCRIPT_PIN_FILENAME
+// beside it (lib/transcriptPin-server.ts); while that file is present,
+// transcript.en.vtt ranks first. Only its presence is read — the rule stays a
+// function of the listing.
+//
+// Name order is half the rule. The other half is CONTENT: the transcript is the
+// first track in this order that parses to at least one cue (readEnglishVttCues),
+// so a track that is present but empty never hides one that has text.
+// CAPTION_TRACK_RULE_VERSION names this rule; bump it when the order or the
+// fallback changes, and the next index build re-reads the records the change
+// can reach (buildIndex.ts, "Caption track"), and a cues.json normalized under
+// an older one reads as stale where it could differ (normalizeTranscript.ts).
+// umtool/report-to-video/cues.mjs carries a copy (plain node, no tsx); change
+// one, change the other — captionTrack.test.ts holds them equal.
+export const CAPTION_TRACK_RULE_VERSION = 1;
+
+export const ORIG_VTT_FILENAME = "transcript.en-orig.vtt";
+// Not transcript.<x>.<y>: SUB_FILE_RE would read it as a subtitle track.
+export const TRANSCRIPT_PIN_FILENAME = "transcript-pin.json";
+
const EN_VTT_RE = /^transcript\.(en(?:-[^.]+)?)\.vtt$/;
-function englishVttRank(track: string): number {
- if (track === "en") return 0;
- if (track === "en-orig") return 1;
+function englishVttRank(track: string, pinned: boolean): number {
+ if (track === "en" && pinned) return -1;
+ if (track === "en-orig") return 0;
+ if (track === "en") return 1;
if (/^en-en(?:-|$)/.test(track)) return 3; // auto-translated en→en variants
return 2; // regional/manual en-US, en-GB, …
}
@@ -115,15 +150,63 @@ export function isEnglishVtt(name: string): boolean {
return EN_VTT_RE.test(name);
}
-export function resolvePrimaryVtt(entries: string[]): string | null {
- let best: { name: string; rank: number } | null = null;
+// Every English VTT in a listing, most preferred first (ties by name, so the
+// order never depends on readdir's).
+export function englishVttsByPreference(entries: readonly string[]): string[] {
+ const pinned = entries.includes(TRANSCRIPT_PIN_FILENAME);
+ const ranked: { name: string; rank: number }[] = [];
for (const e of entries) {
const m = e.match(EN_VTT_RE);
- if (!m) continue;
- const rank = englishVttRank(m[1]);
- if (!best || rank < best.rank) best = { name: e, rank };
+ if (m) ranked.push({ name: e, rank: englishVttRank(m[1], pinned) });
+ }
+ ranked.sort((a, b) => a.rank - b.rank || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
+ return ranked.map((r) => r.name);
+}
+
+// The most preferred English VTT BY NAME — no file is read. This is the
+// track the index stats for change detection and the one the editor labels the
+// primary; the cues themselves come from readEnglishVttCues, which moves past
+// it when it parses to nothing.
+export function resolvePrimaryVtt(entries: readonly string[]): string | null {
+ return englishVttsByPreference(entries)[0] ?? null;
+}
+
+// The files a caption transcript is derived from: every English VTT (the
+// content fallback can reach any of them) and the operator's pin. Their newest
+// mtime is what a cues.json, or an index record, is compared against.
+export function captionInputs(entries: readonly string[]): string[] {
+ const out = englishVttsByPreference(entries);
+ if (out.length > 0 && entries.includes(TRANSCRIPT_PIN_FILENAME)) {
+ out.push(TRANSCRIPT_PIN_FILENAME);
+ }
+ return out;
+}
+
+// The cues of a video's caption transcript: the first English VTT, in
+// preference order, that parses to at least one cue. When every track parses
+// to nothing, the most preferred one is returned with its empty list (the
+// video has captions, they say nothing). Null when there is no English VTT, or
+// none can be read. `entries` is the caller's readdir of `videoDir`, when it
+// has one.
+export async function readEnglishVttCues(
+ videoDir: string,
+ entries?: readonly string[],
+): Promise<{ filename: string; cues: Cue[] } | null> {
+ const names = englishVttsByPreference(
+ entries ?? (await readdir(videoDir).catch(() => [] as string[])),
+ );
+ let first: { filename: string; cues: Cue[] } | null = null;
+ for (const filename of names) {
+ let cues: Cue[];
+ try {
+ cues = parseVtt(await readFile(path.join(videoDir, filename), "utf8"));
+ } catch {
+ continue;
+ }
+ if (cues.length > 0) return { filename, cues };
+ first ??= { filename, cues };
}
- return best?.name ?? null;
+ return first;
}
// Any transcript.<lang>.vtt file (any language code). These are the candidate
@@ -194,9 +277,11 @@ export async function readSubTracks(videoDir: string): Promise<SubTrack[]> {
const primaryVtt = resolvePrimaryVtt(entries);
const tracks: SubTrack[] = [];
for (const entry of entries) {
- if (entry === VTT_FILENAME || entry === WHISPER_FILENAME) continue;
- // The resolved primary English VTT (e.g. transcript.en-US.vtt when there's
- // no transcript.en.vtt) is the main transcript, not an alternate sub-track.
+ if (entry === WHISPER_FILENAME) continue;
+ // The resolved primary English VTT (transcript.en-orig.vtt, or e.g.
+ // transcript.en-US.vtt when there is nothing better) is the main
+ // transcript, not an alternate sub-track. Any other English track — the
+ // served transcript.en.vtt beside an en-orig — is an alternate.
if (entry === primaryVtt) continue;
if (entry === CUES_JSON_FILENAME) continue;
if (entry === LIVE_CHAT_CUES_FILENAME) continue;
diff --git a/common/publish/composeReports.ts b/common/publish/composeReports.ts
@@ -74,7 +74,7 @@ import {
import type { TranscriptSummary } from "../lib/transcripts";
import { parseVtt, type Cue } from "../lib/vtt";
import { parseTranscriptJson } from "../lib/whisper";
-import { WHISPER_FILENAME, isEnglishVtt, resolvePrimaryVtt } from "../lib/videoStatus";
+import { WHISPER_FILENAME, englishVttsByPreference, readEnglishVttCues } from "../lib/videoStatus";
import { platformMomentUrl } from "../lib/momentUrl";
import { archiveOrgCitationLinks, type ArchiveOrgProvenance } from "../lib/archiveOrg";
import { loadArchiveOrgProvenance } from "../lib/archiveOrg-server";
@@ -238,22 +238,6 @@ type CitedRecord = {
archiveOrg: ArchiveOrgProvenance | null;
};
-// The English VTT tracks of a video dir, `en-orig` first, then the order
-// resolvePrimaryVtt prefers.
-function englishVttsByPreference(entries: readonly string[]): string[] {
- const vtts = entries.filter(isEnglishVtt);
- const ordered: string[] = [];
- const orig = vtts.find((n) => n === "transcript.en-orig.vtt");
- if (orig) ordered.push(orig);
- const rest = vtts.filter((n) => n !== orig);
- while (rest.length > 0) {
- const best = resolvePrimaryVtt(rest)!;
- ordered.push(best);
- rest.splice(rest.indexOf(best), 1);
- }
- return ordered;
-}
-
async function readCues(file: string, kind: "vtt" | "whisper"): Promise<Cue[]> {
try {
const raw = await readFile(file, "utf8");
@@ -290,17 +274,10 @@ export async function readCitedRecord(
if (!meta) return null;
summary = summarize(slug, id, meta, channelName);
if (entries.includes(WHISPER_FILENAME)) cues = await readCues(path.join(dir, WHISPER_FILENAME), "whisper");
- if (cues.length === 0) {
- const primary = resolvePrimaryVtt(entries);
- if (primary) cues = await readCues(path.join(dir, primary), "vtt");
- }
- }
- if (cues.length === 0) {
- for (const name of englishVttsByPreference(entries)) {
- cues = await readCues(path.join(dir, name), "vtt");
- if (cues.length > 0) break;
- }
}
+ // The caption-track rule (videoStatus.ts): the first English VTT, `en-orig`
+ // first, that has cues.
+ if (cues.length === 0) cues = (await readEnglishVttCues(dir, entries))?.cues ?? [];
const tracks: CitedRecord["tracks"] = [];
if (fresh.fresh) {
const n = await readNormalizedTranscript(fresh.cuesPath);
diff --git a/umtool/report-to-video/cues.mjs b/umtool/report-to-video/cues.mjs
@@ -56,7 +56,7 @@
// a channel the stale copy lacked is found. Manifest and shard URLs carry no
// version, so a cue window does not move because of it.
-import { readFile, writeFile, mkdir, lstat, readlink } from "node:fs/promises";
+import { readFile, writeFile, mkdir, lstat, readlink, readdir } from "node:fs/promises";
import path from "node:path";
import os from "node:os";
import { createHash } from "node:crypto";
@@ -114,6 +114,27 @@ export function siteOriginFromManifest(manifest) {
return null;
}
+// A TWIN OF `CAPTION_TRACK_RULE_VERSION` (`common/lib/videoStatus.ts`) — the
+// caption-track rule a `transcript.cues.json` records as `captionTrackRule`.
+// Copied for the same reason as the text guard below: umtool's bins run under
+// plain node. Change one, change the other.
+export const CAPTION_TRACK_RULE_VERSION = 1;
+const ENGLISH_VTT_RE = /^transcript\.en(?:-[^.]+)?\.vtt$/;
+
+// A local caption record normalized under an older caption-track rule, where
+// the rule could now read other words: more than one English VTT (the served
+// `en` used to win over `en-orig`), or no cues at all (a cue-block VTT used to
+// parse to nothing). Cutting from it would widen clips on text the corpus no
+// longer publishes, so it is refused with the fix, not used.
+async function staleCaptionRecord(videoDir, record) {
+ if (record?.source !== "vtt" || record.captionTrackRule === CAPTION_TRACK_RULE_VERSION) {
+ return false;
+ }
+ if (!Array.isArray(record.cues) || record.cues.length === 0) return true;
+ const entries = await readdir(videoDir).catch(() => []);
+ return entries.filter((e) => ENGLISH_VTT_RE.test(e)).length > 1;
+}
+
export class CueLookupError extends Error {
constructor(message, { channelSlug, videoId, tried }) {
super(message);
@@ -423,6 +444,14 @@ export function createCueSource({
await assertChannelReachable(channelSlug);
const p = path.join(channelsDir, channelSlug, "data", videoId, "transcript.cues.json");
const parsed = JSON.parse(await readFile(p, "utf8"));
+ if (await staleCaptionRecord(path.dirname(p), parsed)) {
+ throw new CueLookupError(
+ `${channelSlug}/${videoId}: transcript.cues.json was normalized under an older caption-track rule ` +
+ `(it may hold the served en track's words, or none, where en-orig is read now). ` +
+ `Run Normalize for channel ${channelSlug} in the editor, or pass --cue-source http.`,
+ { channelSlug, videoId, tried: [p] },
+ );
+ }
return { ...parsed, from: "local" };
}
diff --git a/umtool/report-to-video/cues.test.mjs b/umtool/report-to-video/cues.test.mjs
@@ -12,6 +12,7 @@ import { tmpdir } from "node:os";
import path from "node:path";
import {
+ CAPTION_TRACK_RULE_VERSION,
createCueSource,
pageFileName,
pageUrlFrom,
@@ -148,6 +149,53 @@ test("a local corpus is preferred over the network", async () => {
}
});
+// --- a caption record normalized under an older caption-track rule ---------
+
+test("a local caption record from before the caption-track rule is refused where the rule could read other words", async () => {
+ const dir = await mkdtemp(path.join(tmpdir(), "cues-rule-"));
+ try {
+ const vdir = path.join(dir, "chan", "data", "vid1");
+ await mkdir(vdir, { recursive: true });
+ await writeFile(path.join(vdir, "transcript.en.vtt"), "WEBVTT\n");
+ await writeFile(path.join(vdir, "transcript.en-orig.vtt"), "WEBVTT\n");
+ await writeFile(path.join(vdir, "transcript.cues.json"), JSON.stringify({ ...RECORD, source: "vtt" }));
+ const seen = [];
+ const src = createCueSource({ channelsDir: dir, siteOrigin: ORIGIN, cacheDir: null, fetchImpl: stubFetch(ROUTES, seen) });
+ await assert.rejects(src.load("chan", "vid1"), (err) => {
+ assert.equal(err.name, "CueLookupError");
+ assert.match(err.message, /older caption-track rule.*Run Normalize for channel chan/s);
+ return true;
+ });
+ assert.deepEqual(seen, [], "never answered from the archive instead");
+
+ // Normalized under the current rule: read as usual.
+ await writeFile(
+ path.join(vdir, "transcript.cues.json"),
+ JSON.stringify({ ...RECORD, source: "vtt", captionTrackRule: CAPTION_TRACK_RULE_VERSION }),
+ );
+ assert.equal((await src.load("chan", "vid1")).from, "local");
+ } finally {
+ await rm(dir, { recursive: true, force: true });
+ }
+});
+
+test("a lone-track caption record with cues needs no rule to be read", async () => {
+ const dir = await mkdtemp(path.join(tmpdir(), "cues-rule-"));
+ try {
+ const vdir = path.join(dir, "chan", "data", "vid1");
+ await mkdir(vdir, { recursive: true });
+ await writeFile(path.join(vdir, "transcript.en.vtt"), "WEBVTT\n");
+ await writeFile(path.join(vdir, "transcript.cues.json"), JSON.stringify({ ...RECORD, source: "vtt" }));
+ const src = createCueSource({ channelsDir: dir, siteOrigin: ORIGIN, cacheDir: null, fetchImpl: stubFetch(ROUTES) });
+ assert.equal((await src.load("chan", "vid1")).from, "local");
+ // …but one with no cues is refused: a cue-block track used to parse to none.
+ await writeFile(path.join(vdir, "transcript.cues.json"), JSON.stringify({ ...RECORD, source: "vtt", cues: [] }));
+ await assert.rejects(src.load("chan", "vid1"), /older caption-track rule/);
+ } finally {
+ await rm(dir, { recursive: true, force: true });
+ }
+});
+
// --- a channel the corpus holds but whose text it cannot read ---------------
//
// The bug these cover: on the RETIRED layout `data/` is a symlink to another