// 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 { 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 fresh beside a byte-identical en/en-orig pair: it reads the same", async () => { const dir = videoDir({ "transcript.en.vtt": ROLLING, "transcript.en-orig.vtt": ROLLING }); oldCuesJson(dir, [{ start: 0, end: 1, text: "same words" }]); 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 fresh beside two tracks the older rule ranked the same way", async () => { const dir = videoDir({ "transcript.en.vtt": CUE_BLOCKS, "transcript.en-US.vtt": ROLLING }); oldCuesJson(dir, [{ start: 0, end: 1, text: "kept" }]); assert.equal((await isCuesJsonFresh(dir)).fresh, true); }); test("a cues.json from before the rule with no cues is stale beside two tracks: the fallback may find text", async () => { const dir = videoDir({ "transcript.en.vtt": ROLLING, "transcript.en-orig.vtt": ROLLING }); oldCuesJson(dir, []); assert.equal((await isCuesJsonFresh(dir)).fresh, false); }); test("a cues.json from before the rule is stale beside an en and en-orig that differ, 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); });