import { after, test } from "node:test"; import assert from "node:assert/strict"; import { lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, readlinkSync, renameSync, rmSync, statSync, symlinkSync, utimesSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; // Run with: // pnpm --filter yt-dlp-transcript-common exec tsx --test bin/migrate-media-tier.test.ts // // The one-off media-tier migration over a fixture: a "platter" dir beside a // corpus, a legacy channel (its `data/` an absolute link to // `//data`, `config.dataDir`) holding text, audio, a raw live // chat, a clip window, a source container and scratch. The REAL rsync runs. // The environment is pinned before any module reads it (getPaths caches), as // buildIndex.test.ts does, so the index case can build over the same corpus. const ROOT = mkdtempSync(path.join(tmpdir(), "migrate-tier-")); const PINNED: Record = { TRANSCRIPTS_DIR: path.join(ROOT, "transcripts"), SAVED_VIDEOS_DIR: path.join(ROOT, "saved-videos"), SITES_DIR: path.join(ROOT, "transcripts", "sites"), SETTINGS_FILE: path.join(ROOT, "settings.json"), EXPORT_PUBLIC_DIR: path.join(ROOT, "public"), EXPORT_INDEX_DIR: path.join(ROOT, ".export-index"), EXPORT_BUILDS_DIR: path.join(ROOT, ".export-builds"), EDITOR_CHANGELOG_FILE: path.join(ROOT, "editor-CHANGELOG.md"), EXPORT_CHANGELOG_FILE: path.join(ROOT, "export-CHANGELOG.md"), CHARTS_CONFIG_FILE: path.join(ROOT, "chart-templates.json"), SEARCH_ALIASES_FILE: path.join(ROOT, "transcripts", "search-aliases.json"), CURATED_TAGS_FILE: path.join(ROOT, "transcripts", "tags.json"), ARCHILYZER_CONFIG_DIR: path.join(ROOT, "config"), ARCHILYZER_SOURCE_SCRATCH: path.join(ROOT, "source-scratch"), }; Object.assign(process.env, PINNED); delete process.env.ARCHILYZER_INDEX_ALLOW_HELD; after(() => rmSync(ROOT, { recursive: true, force: true })); const { getPaths } = await import("../lib/paths"); const { editorRunningReason, legacyChannels, migrateMediaTier, INCOMING_NAME, LIST_NAME, RECLAIM_NOTE, } = await import("./migrate-media-tier"); const { inspectChannelMedia, readRelocationMarker } = await import("../lib/channelMedia"); const { classifyEntry } = await import("../lib/mediaTier"); const { readChannelConfig } = await import("../controller/channels"); const { buildIndex } = await import("../controller/buildIndex"); const { isLiveChatCuesFresh, normalizeLiveChat } = await import( "../controller/normalizeLiveChat" ); type MigrateOpts = Parameters[0]; type MigrationStep = NonNullable< NonNullable["checkpoint"] > extends (slug: string, step: infer S) => unknown ? S : never; const paths = getPaths(); const PLATTER = path.join(ROOT, "platter"); const SLUG = "legacy-ch"; const SITE = "testsite"; const OLD = new Date("2021-03-04T05:06:07.000Z"); const CUES_AT = new Date("2021-03-05T05:06:07.000Z"); // Settings with the disk gate off, so the tmpfs's free space is not the // subject (relocateChannelMedia.test.ts says why at length); the free-space // cases inject `freeBytes` and turn the floor on. const SETTINGS = { minFreeDiskGB: 0, resumeMarginGB: 2, storage: { locations: [], defaultLocationId: "" }, } as unknown as MigrateOpts["settings"]; const CHAT_LINE = JSON.stringify({ replayChatItemAction: { actions: [ { addChatItemAction: { item: { liveChatTextMessageRenderer: { message: { runs: [{ text: "hello" }] }, authorName: { simpleText: "a" }, timestampUsec: "1000000", }, }, }, }, ], videoOffsetTimeMsec: "1000", }, }) + "\n"; const VTT = "WEBVTT\nKind: captions\nLanguage: en\n\n" + "00:00:00.000 --> 00:00:05.000\nFirst caption line.\n\n" + "00:01:00.000 --> 00:01:50.000\nSecond caption line.\n"; // One video dir's files: name -> content. `clips/` is a directory. const VIDEOS: Record> = { v1: { "transcript.en.vtt": VTT, "audio.mp3": "A".repeat(4096), "transcript.live_chat.json": CHAT_LINE.repeat(20), "source-media.mp4": "S".repeat(2048), "audio.m4a.part": "P".repeat(512), "audio.tmp-1234.mp3": "T".repeat(256), "clips/w-0001.mp4": "C".repeat(1024), // A postprocessor's dead temp: left on the platter, never copied. "source-media.temp.mp4": "D".repeat(1536), }, v2: { "transcript.json": JSON.stringify({ segments: [{ start: 0, end: 1, text: "hi there" }] }), "audio.opus": "O".repeat(3000), // Syncthing's unfinished transfer: scratch, carried. ".syncthing.audio.mp3.tmp": "Y".repeat(128), }, v3: {}, }; function writeJson(file: string, value: unknown): void { mkdirSync(path.dirname(file), { recursive: true }); writeFileSync(file, JSON.stringify(value, null, 2)); } function channelDir(slug = SLUG): string { return path.join(paths.channelsDir, slug); } function oldTree(slug = SLUG): string { return path.join(PLATTER, slug, "data"); } function mediaTarget(slug = SLUG): string { return path.join(PLATTER, slug, "media"); } function writeConfig(slug: string, extra: Record = {}): void { writeJson(path.join(channelDir(slug), "config.json"), { handling: "youtube", name: slug, url: `https://www.youtube.com/@${slug}/videos`, ...extra, }); } function resetCorpus(channels: string[] = [SLUG]): void { for (const p of [paths.transcriptsDir, PINNED.EXPORT_INDEX_DIR, PLATTER]) { rmSync(p, { recursive: true, force: true }); } mkdirSync(paths.channelsDir, { recursive: true }); mkdirSync(PLATTER, { recursive: true }); writeFileSync(paths.settingsFile, JSON.stringify({})); writeJson(path.join(paths.sitesDir, SITE, "site.json"), { siteId: SITE, siteTitle: "Test Site", siteDescription: "fixture", headerTitle: "Test Site", homeTagline: "", socialLinks: [], groups: [{ id: "default", name: "All channels", selectedByDefault: true }], defaultGroupId: "default", channels: channels.map((slug) => ({ slug, groupId: "default" })), }); } // Write the videos into `dataDir` (every file at OLD, the cues a day later). function writeVideos(dataDir: string, slug: string, scale = 1): void { for (const [id, files] of Object.entries(VIDEOS)) { const dir = path.join(dataDir, id); mkdirSync(dir, { recursive: true }); writeJson(path.join(dir, "metadata.info.json"), { id, title: `Video ${id}`, channel: slug, upload_date: "20260601", duration: 120, description: "fixture", webpage_url: `https://www.youtube.com/watch?v=${id}`, extractor_key: "Youtube", }); utimesSync(path.join(dir, "metadata.info.json"), OLD, OLD); for (const [name, content] of Object.entries(files)) { const file = path.join(dir, name); mkdirSync(path.dirname(file), { recursive: true }); writeFileSync(file, content.repeat(scale)); utimesSync(file, OLD, OLD); } } } // A channel on the RETIRED layout, as the pre-release-17 mover left it. function seedLegacy(slug = SLUG, scale = 1): void { writeVideos(oldTree(slug), slug, scale); writeConfig(slug, { dataDir: oldTree(slug) }); symlinkSync(oldTree(slug), path.join(channelDir(slug), "data")); } // What is on disk under ROOT's corpus and platter: type, size, mtime, link // target, content of small files. Equal snapshots = nothing was written. function snapshot(): Record { const out: Record = {}; const walk = (p: string) => { const st = lstatSync(p); const rel = path.relative(ROOT, p); if (st.isSymbolicLink()) { out[rel] = `link ${readlinkSync(p)} ${st.mtimeMs}`; return; } if (st.isDirectory()) { out[rel] = `dir`; for (const n of readdirSync(p)) walk(path.join(p, n)); return; } out[rel] = `file ${st.size} ${st.mtimeMs} ${readFileSync(p, "utf8").slice(0, 64)}`; }; walk(paths.transcriptsDir); walk(PLATTER); return out; } function run(over: Partial = {}, lines: string[] = []) { return migrateMediaTier({ paths, settings: SETTINGS, slugs: [SLUG], log: (l) => lines.push(l), ...over, deps: { editorProbe: async () => null, ...over.deps, }, }); } // THE MIGRATED STATE, every rule the slice names. const DEAD_TEMP = "source-media.temp.mp4"; async function assertMigrated( slug = SLUG, scale = 1, opts: { reclaimed?: boolean } = {}, ): Promise { const ch = channelDir(slug); const config = await readChannelConfig(paths, slug); assert.equal(config?.mediaDir, mediaTarget(slug)); assert.equal(config?.dataDir, undefined, "dataDir unset"); assert.ok(lstatSync(path.join(ch, "data")).isDirectory(), "data/ is a real dir"); assert.equal(readlinkSync(path.join(ch, "media")), mediaTarget(slug)); assert.throws(() => lstatSync(oldTree(slug)), "the old tree is renamed"); assert.throws(() => lstatSync(path.join(ch, INCOMING_NAME))); assert.throws(() => lstatSync(path.join(ch, LIST_NAME))); assert.equal(await readRelocationMarker(paths, slug), null, "no marker"); for (const [id, files] of Object.entries(VIDEOS)) { const dir = path.join(ch, "data", id); const meta = lstatSync(path.join(dir, "metadata.info.json")); assert.ok(meta.isFile()); assert.equal(meta.mtimeMs, OLD.getTime(), `${id} metadata keeps its mtime`); for (const name of Object.keys(files)) { const top = name.split("/")[0]; const p = path.join(dir, name); if (name === DEAD_TEMP) { assert.throws(() => lstatSync(p), "a dead temp is not carried to the corpus disk"); const onPlatter = path.join(mediaTarget(slug), id, name); if (opts.reclaimed) assert.throws(() => lstatSync(onPlatter), "reclaimed"); else assert.ok(lstatSync(onPlatter).isFile(), "left on the platter, unlinked"); continue; } const st = lstatSync(p); if (name === "audio.mp3" || name === "audio.opus" || name === "transcript.live_chat.json") { assert.ok(st.isSymbolicLink(), `${id}/${name} is a link`); assert.equal(readlinkSync(p), `../../media/${id}/${name}`, "relative"); assert.equal(st.mtimeMs, OLD.getTime(), `${id}/${name}: the link carries the file's mtime`); assert.equal(readFileSync(p, "utf8"), files[name].repeat(scale), "readable through the link"); assert.ok(lstatSync(path.join(mediaTarget(slug), id, name)).isFile(), "bytes on the platter"); } else { assert.ok(st.isFile(), `${id}/${name} stays real (${top})`); assert.equal(st.mtimeMs, OLD.getTime(), `${id}/${name} keeps its mtime`); assert.equal(readFileSync(p, "utf8"), files[name].repeat(scale)); } } } const loc = await inspectChannelMedia(paths, slug, undefined, { fresh: true }); assert.equal(loc.status, "ok", loc.detail); assert.equal(loc.text.readable, true); } test("clips/ is text by the classifier, and so is carried to the corpus disk", () => { assert.equal(classifyEntry("clips"), "text"); }); test("a dry run writes nothing, and says what it would do", async () => { resetCorpus(); seedLegacy(); const before = snapshot(); const lines: string[] = []; const res = await run({ dryRun: true }, lines); assert.equal(res.exitCode, 0); const [r] = res.channels; assert.equal(r.outcome, "would-migrate", r.detail); assert.equal(r.tierableFiles, 3); assert.equal(r.copy?.clips.files, 1); assert.equal(r.copy?.source.files, 1, "source-media is carried, not tiered"); assert.equal(r.copy?.scratch.files, 3, "the part, the tmp and the Syncthing temp"); assert.equal(r.leftOnPlatter?.files, 1, "the dead *.temp.* stays on the platter"); assert.deepEqual(snapshot(), before); assert.ok(lines.some((l) => l.includes("DRY RUN"))); }); test("a run migrates the channel; a rerun is a no-op", async () => { resetCorpus(); seedLegacy(); const lines: string[] = []; const res = await run({}, lines); assert.equal(res.exitCode, 0, lines.join("\n")); const [r] = res.channels; assert.equal(r.outcome, "migrated", r.detail); assert.equal(r.linksMade, 3); assert.equal(r.copy?.clips.files, 1); await assertMigrated(); // The live chat's cues (none here) aside, the raw replay's link answers the // freshness check from the corpus disk with the file's time. assert.equal(lstatSync(path.join(channelDir(), "data", "v1", "transcript.live_chat.json")).mtimeMs, OLD.getTime()); // Without --reclaim the platter keeps its text copies, and the reclaim note // says so. assert.ok(lstatSync(path.join(mediaTarget(), "v1", "transcript.en.vtt")).isFile()); assert.ok(lstatSync(path.join(channelDir(), RECLAIM_NOTE)).isFile()); const before = snapshot(); const again = await run(); assert.equal(again.exitCode, 0); assert.equal(again.channels[0].outcome, "already"); assert.deepEqual(snapshot(), before, "the rerun writes nothing"); }); test("--reclaim deletes the platter's text copies and keeps the media", async () => { resetCorpus(); seedLegacy(); const res = await run({ reclaim: true }); assert.equal(res.exitCode, 0, res.channels[0].detail); const r = res.channels[0]; assert.equal(r.outcome, "migrated"); assert.ok((r.reclaimedFiles ?? 0) > 0); assert.deepEqual(r.reclaimKept, []); await assertMigrated(SLUG, 1, { reclaimed: true }); assert.throws(() => lstatSync(path.join(channelDir(), RECLAIM_NOTE)), "the note goes"); assert.deepEqual(readdirSync(path.join(mediaTarget(), "v1")).sort(), [ "audio.mp3", "transcript.live_chat.json", ]); assert.deepEqual(readdirSync(path.join(mediaTarget(), "v2")), ["audio.opus"]); assert.throws(() => lstatSync(path.join(mediaTarget(), "v3")), "an emptied dir goes"); // Reclaim again, later: the note is gone, so it refuses and touches nothing. const before = snapshot(); const again = await run({ reclaim: true }); assert.equal(again.exitCode, 1); assert.equal(again.channels[0].outcome, "refused"); assert.match(again.channels[0].detail ?? "", /no \.tier-migration\.reclaim beside its config/); assert.deepEqual(snapshot(), before); }); test("--reclaim after a plain run takes the copies a first run left", async () => { resetCorpus(); seedLegacy(); await run(); const res = await run({ reclaim: true, dryRun: true }); assert.equal(res.channels[0].outcome, "already"); const wouldTake = res.channels[0].reclaimedFiles ?? 0; assert.ok(wouldTake > 0); assert.ok(lstatSync(path.join(mediaTarget(), "v1", "transcript.en.vtt")).isFile(), "dry: kept"); const real = await run({ reclaim: true }); assert.equal(real.channels[0].reclaimedFiles, wouldTake); assert.throws(() => lstatSync(path.join(mediaTarget(), "v1", "transcript.en.vtt"))); await assertMigrated(SLUG, 1, { reclaimed: true }); }); test("--reclaim keeps a platter copy whose twin on the corpus disk is gone or differs", async () => { resetCorpus(); seedLegacy(); await run(); // Since the migration: one text file removed, one rewritten to another size. rmSync(path.join(channelDir(), "data", "v1", "transcript.en.vtt")); writeFileSync(path.join(channelDir(), "data", "v2", "transcript.json"), "{}"); const lines: string[] = []; const res = await run({ reclaim: true }, lines); assert.equal(res.exitCode, 0, res.channels[0].detail); assert.deepEqual(res.channels[0].reclaimKept?.sort(), ["v1/transcript.en.vtt", "v2/transcript.json"]); assert.ok(lstatSync(path.join(mediaTarget(), "v1", "transcript.en.vtt")).isFile(), "kept"); assert.ok(lstatSync(path.join(mediaTarget(), "v2", "transcript.json")).isFile(), "kept"); assert.throws(() => lstatSync(path.join(mediaTarget(), "v1", "metadata.info.json")), "its twin is there: taken"); assert.throws(() => lstatSync(path.join(mediaTarget(), "v1", DEAD_TEMP)), "the dead temp is taken"); assert.ok(lines.some((l) => /kept 2 entries on the drive with no same-size copy/.test(l))); }); test("a reclaim killed after its deletes resumes from its marker and ends clean", async () => { resetCorpus(); seedLegacy(); const killed = await run({ reclaim: true, deps: { checkpoint: (_slug, s) => { if (s === "reclaimed") throw new Error("killed"); }, }, }); assert.equal(killed.channels[0].outcome, "failed"); assert.equal((await readRelocationMarker(paths, SLUG))?.phase, "reclaim"); const resumed = await run(); assert.equal(resumed.channels[0].outcome, "migrated", resumed.channels[0].detail); assert.equal(resumed.channels[0].resumedFrom, "reclaim"); await assertMigrated(SLUG, 1, { reclaimed: true }); assert.throws(() => lstatSync(path.join(channelDir(), RECLAIM_NOTE))); }); test("--all --reclaim takes only channels this tool migrated", async () => { resetCorpus(["moved", SLUG]); seedLegacy(); // A channel the Storage panel moved: on the media tier, no reclaim note, a // non-tierable file in its media tree that a reclaim would take. writeConfig("moved", { mediaDir: mediaTarget("moved") }); mkdirSync(path.join(mediaTarget("moved"), "x1"), { recursive: true }); writeFileSync(path.join(mediaTarget("moved"), "x1", "stray.txt"), "s"); mkdirSync(path.join(channelDir("moved"), "data", "x1"), { recursive: true }); writeFileSync(path.join(channelDir("moved"), "data", "x1", "stray.txt"), "s"); symlinkSync(mediaTarget("moved"), path.join(channelDir("moved"), "media")); const res = await run({ slugs: "all", reclaim: true }); assert.equal(res.exitCode, 0, res.channels.map((c) => c.detail).join("; ")); assert.deepEqual(res.channels.map((c) => c.slug), [SLUG]); await assertMigrated(SLUG, 1, { reclaimed: true }); assert.ok(lstatSync(path.join(mediaTarget("moved"), "x1", "stray.txt")).isFile(), "not walked"); const named = await run({ slugs: ["moved"], reclaim: true }); assert.equal(named.channels[0].outcome, "refused"); assert.match(named.channels[0].detail ?? "", /migrate-tier did not migrate it/); }); test("a media move's marker is refused, with or without a scope; nothing is touched", async () => { for (const marker of [ { target: mediaTarget(), direction: "out", startedAt: "", phase: "copy", scope: "media" }, { target: path.join(PLATTER, "elsewhere", SLUG, "data"), direction: "out", startedAt: "", phase: "swap" }, ]) { resetCorpus(); seedLegacy(); writeJson(path.join(channelDir(), ".relocating.json"), marker); const before = snapshot(); const res = await run(); assert.equal(res.exitCode, 1); assert.equal(res.channels[0].outcome, "refused"); assert.match(res.channels[0].detail ?? "", /media move .* Storage panel/); assert.deepEqual(snapshot(), before); } }); test("a running editor refuses a real run; a dry run only notes it", async () => { resetCorpus(); seedLegacy(); const before = snapshot(); const lines: string[] = []; const editorProbe = async () => "the editor answers at http://localhost:3001/api/pulse (HTTP 200)"; const res = await run({ deps: { editorProbe } }, lines); assert.equal(res.exitCode, 2); assert.equal(res.channels.length, 0); assert.ok(lines.some((l) => /Refusing: the editor must be stopped/.test(l))); assert.deepEqual(snapshot(), before); const dry = await run({ dryRun: true, deps: { editorProbe } }); assert.equal(dry.exitCode, 0); assert.equal(dry.channels[0].outcome, "would-migrate"); assert.deepEqual(snapshot(), before); }); test("editorRunningReason: the port, then a live job meta", async () => { const jobsDir = path.join(ROOT, "jobs-probe"); rmSync(jobsDir, { recursive: true, force: true }); mkdirSync(jobsDir, { recursive: true }); const refused = async () => { throw Object.assign(new TypeError("fetch failed"), { cause: { code: "ECONNREFUSED" } }); }; const timedOut = async () => { throw Object.assign(new Error("The operation was aborted due to timeout"), { name: "TimeoutError" }); }; const answers = async () => new Response("ok", { status: 200 }); const opts = { url: "http://localhost:3999", machineBootMs: 0 }; assert.equal(await editorRunningReason({ jobsDir }, { ...opts, fetchImpl: refused as typeof fetch }), null); assert.match( (await editorRunningReason({ jobsDir }, { ...opts, fetchImpl: answers as typeof fetch })) ?? "", /answers at http:\/\/localhost:3999\/api\/pulse \(HTTP 200\)/, ); assert.match( (await editorRunningReason({ jobsDir }, { ...opts, fetchImpl: timedOut as typeof fetch })) ?? "", /did not answer/, ); const meta = { id: "j1", kind: "whisper-video", queueKey: "q", channelSlug: "c", status: "running", queuedAt: Date.now(), pid: 424242 }; writeFileSync(path.join(jobsDir, "j1.meta.json"), JSON.stringify(meta)); assert.match( (await editorRunningReason({ jobsDir }, { ...opts, fetchImpl: refused as typeof fetch, isAlive: () => true })) ?? "", /job j1 \(whisper-video, c\) is running in a live process \(pid 424242\)/, ); assert.equal( await editorRunningReason({ jobsDir }, { ...opts, fetchImpl: refused as typeof fetch, isAlive: () => false }), null, "a dead writer's meta is a ghost", ); writeFileSync(path.join(jobsDir, "j1.meta.json"), JSON.stringify({ ...meta, pid: undefined })); assert.match( (await editorRunningReason({ jobsDir }, { ...opts, fetchImpl: refused as typeof fetch })) ?? "", /no recorded process/, ); assert.equal( await editorRunningReason({ jobsDir }, { ...opts, machineBootMs: Date.now() + 60_000, fetchImpl: refused as typeof fetch }), null, "a meta from before the machine booted is not read", ); }); test("killed at any step between copy and swap, a rerun resumes and finishes", async () => { const steps: MigrationStep[] = [ "copied", "linked", "marker-swap", "platter-renamed", "media-linked", "data-unlinked", "data-renamed", "config-written", ]; for (const step of steps) { resetCorpus(); seedLegacy(); const killed = await run({ deps: { checkpoint: (_slug, s) => { if (s === step) throw new Error(`killed at ${s}`); }, }, }); assert.equal(killed.channels[0].outcome, "failed", step); assert.equal(killed.exitCode, 1); const marker = await readRelocationMarker(paths, SLUG); assert.equal(marker?.scope, "tier-migration", step); const loc = await inspectChannelMedia(paths, SLUG, undefined, { fresh: true }); assert.equal(loc.status, "in-transition", step); assert.equal(loc.text.readable, false, `${step}: the text is held mid-migration`); assert.deepEqual(await legacyChannels(paths), [SLUG], `${step}: --all would resume it`); const resumed = await run(); assert.equal(resumed.exitCode, 0, `${step}: ${resumed.channels[0].detail}`); assert.equal(resumed.channels[0].outcome, "migrated", step); assert.equal( resumed.channels[0].resumedFrom, ["copied", "linked"].includes(step) ? "copy" : "swap", step, ); await assertMigrated(); } }); test("the free-space stop: refused before anything is written", async () => { resetCorpus(); seedLegacy(); const before = snapshot(); const settings = { ...SETTINGS, minFreeDiskGB: 5 } as MigrateOpts["settings"]; // 6 GB free, a 5 GB floor and a 2 GB margin: the copy cannot fit. const res = await run({ settings, deps: { freeBytes: async () => 6 * 1024 ** 3 } }); assert.equal(res.exitCode, 1); assert.equal(res.channels[0].outcome, "no-space"); assert.match(res.channels[0].detail ?? "", /not enough space on the corpus disk: 6\.00 GB free .*5 GB floor.*2 GB resume margin/); assert.deepEqual(snapshot(), before); // 8 GB free clears it. const ok = await run({ settings, deps: { freeBytes: async () => 8 * 1024 ** 3 } }); assert.equal(ok.channels[0].outcome, "migrated", ok.channels[0].detail); }); test("--all: smallest text first, and a stop before the big three", async () => { const big = "omnibased"; resetCorpus(["bigger", "small", big]); seedLegacy("bigger", 4); seedLegacy("small", 1); seedLegacy(big, 1); // A classic channel and a social one are not candidates. writeConfig("classic"); mkdirSync(path.join(channelDir("classic"), "data", "x1"), { recursive: true }); const lines: string[] = []; const res = await run({ slugs: "all" }, lines); assert.equal(res.exitCode, 0, lines.join("\n")); assert.deepEqual(res.channels.map((c) => c.slug), ["small", "bigger"]); assert.deepEqual(res.stoppedBefore, [big]); assert.ok(lines.some((l) => l.includes("STOPPED before the big-text channels: omnibased"))); assert.ok(lines.some((l) => l.includes("--all --include-large"))); await assertMigrated("small"); await assertMigrated("bigger", 4); assert.equal(lstatSync(path.join(channelDir(big), "data")).isSymbolicLink(), true, "untouched"); const rest = await run({ slugs: "all", includeLarge: true }); assert.deepEqual(rest.channels.map((c) => c.slug), [big]); await assertMigrated(big); assert.deepEqual(await legacyChannels(paths), []); }); test("the index build after the migration rewrites no record", async () => { resetCorpus(); // Built while the channel's text was readable (before release 17 the index // read a legacy channel through its link) — here, a classic layout with the // same files and times. writeConfig(SLUG); writeVideos(path.join(channelDir(), "data"), SLUG); const v1 = path.join(channelDir(), "data", "v1"); const outcome = await normalizeLiveChat({ videoDir: v1, channelSlug: SLUG, tier: false }); assert.equal(outcome.status, "wrote"); utimesSync(path.join(v1, "live_chat.cues.json"), CUES_AT, CUES_AT); const first = await buildIndex({ paths, onLog: () => {} }); assert.equal(first.added, 3); const subsBefore = statSync(path.join(v1, "transcript.live_chat.json")).mtimeMs; // Onto the retired layout, as the old mover left it (rename keeps mtimes). mkdirSync(path.dirname(oldTree()), { recursive: true }); renameSync(path.join(channelDir(), "data"), oldTree()); symlinkSync(oldTree(), path.join(channelDir(), "data")); writeConfig(SLUG, { dataDir: oldTree() }); const res = await run(); assert.equal(res.channels[0].outcome, "migrated", res.channels[0].detail); assert.equal(lstatSync(path.join(v1, "transcript.live_chat.json")).mtimeMs, subsBefore); assert.equal((await isLiveChatCuesFresh(v1)).fresh, true, "the cues are not stale"); const second = await buildIndex({ paths, onLog: () => {} }); assert.deepEqual(second.heldChannels, []); assert.equal(second.added, 0); assert.equal(second.changed, 0, "no record rewritten: every mtime the index keys on is unchanged"); assert.equal(second.removed, 0); });