// Integration: the index build's HOLD, through the REAL buildIndex, over a temp // corpus. // // A channel's `data/` used to be an absolute symlink to another drive (the // retired whole-directory layout). With that drive unmounted the link // dangled, and the index build used to read the channel as having no videos: // it removed every record the channel had, and the site built next published // the channel as gone. These cases pin the hold that replaced it: the channel // is not rescanned, and its records and shared pages are kept as they are; a // FULL rebuild with a channel held refuses unless ARCHILYZER_INDEX_ALLOW_HELD // is set; and all of it undoes itself when the text is readable again. The // stats build's twin is buildStats.test.ts case (i). // // RELEASE 17: only the TEXT holds the index. A relocated channel's text stays // on the corpus disk and only its media (`channels//media`) is on the // drive, so an unmounted MEDIA drive holds nothing here (case (j)). What holds // is a text tier that cannot be read — here, the channel put back on the // retired layout with its drive away (`unmount`), which reads `legacy` — and // what lifts it is the text home again (`remount`, which is what // `archilyzer storage migrate-tier` does). // // Run with: node_modules/.bin/tsx --test common/controller/buildIndex.test.ts import { after, test } from "node:test"; import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; import { createRequire, syncBuiltinESMExports } from "node:module"; import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; // EVERY PATH getPaths() CAN RESOLVE TO A PLACE THIS FILE'S CODE MAY WRITE IS // PINNED UNDER ROOT before anything calls it (the buildStats.test.ts list). The // last case proves no write this file caused landed outside ROOT. const ROOT = mkdtempSync(path.join(tmpdir(), "build-index-")); 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 { buildIndex, INDEX_ALLOW_HELD_ENV } = await import("./buildIndex"); const { writeGlobalTags } = await import("../lib/curatedTagsStore"); const { META_PAGES_PENDING } = await import("./curatedTagsIndex"); const { open } = await import("lmdb"); const paths = getPaths(); const CHANNEL = "test-channel"; const DRIVE_CHANNEL = "drive-channel"; const SITE = "testsite"; const MEDIA = path.join(ROOT, "media"); const AWAY = `${MEDIA}-away`; const COMMON = fileURLToPath(new URL("..", import.meta.url)); // ── a write spy over the whole file ───────────────────────────────────────── // The buildStats.test.ts spy, writes only: node:fs and node:fs/promises, async, // sync and callback, synced into the named ESM imports the code under test // holds. The last case reads it. const writes: string[] = []; let afterStat: ((p: string) => void) | null = null; // A STALLED MEDIA DRIVE (release 17): while set, a promise-API stat, readFile // or open whose path is a symlink resolving under it never settles — what a // call blocked on a stalled drive looks like from here — and is recorded. let hangUnder: string | null = null; const driveCalls: string[] = []; { const req = createRequire(import.meta.url); const fsCjs = req("node:fs") as Record; const fspCjs = req("node:fs/promises") as Record; const WRITES = ["writeFile", "appendFile", "rename", "mkdir", "rm", "rmdir", "unlink", "copyFile", "cp", "symlink", "link", "utimes", "truncate", "mkdtemp", "chmod"]; // A symlink's first argument is its TARGET (relative to the link, and never // written); only the link itself, the second, is. const TWO_PATHS = new Set(["rename", "copyFile", "cp", "link"]); const opensForWrite = (flags: unknown) => (typeof flags === "string" && /[wa+]/.test(flags)) || (typeof flags === "number" && (flags & 3) !== 0); const asPath = (v: unknown) => typeof v === "string" ? v : v instanceof URL ? fileURLToPath(v) : Buffer.isBuffer(v) ? v.toString() : null; const wrap = (mod: Record, name: string, mode: "write" | "open") => { const fn = mod[name]; if (typeof fn !== "function") return; mod[name] = function (this: unknown, ...args: unknown[]) { if (mode === "write" || opensForWrite(args[1])) { const base = name.replace(/Sync$/, ""); const ps = base === "symlink" ? [args[1]] : TWO_PATHS.has(base) ? [args[0], args[1]] : [args[0]]; for (const a of ps) { const p = asPath(a); if (p !== null) writes.push(path.resolve(p)); } } return (fn as (...a: unknown[]) => unknown).apply(this, args); }; }; for (const n of WRITES) { wrap(fspCjs, n, "write"); wrap(fsCjs, n, "write"); wrap(fsCjs, `${n}Sync`, "write"); } wrap(fspCjs, "open", "open"); wrap(fsCjs, "open", "open"); wrap(fsCjs, "openSync", "open"); wrap(fsCjs, "createWriteStream", "write"); // Case (i)'s hook: the drive is lost DURING the scan's walk. node:fs/promises // `stat` calls `afterStat` with each path it has just answered for. const stat = fspCjs.stat as (...a: unknown[]) => Promise; fspCjs.stat = async function (this: unknown, ...args: unknown[]) { const result = await stat.apply(this, args); const p = asPath(args[0]); if (p !== null) afterStat?.(path.resolve(p)); return result; }; const throughLinkUnder = (p: string, root: string): boolean => { try { if (!fsCjsLstat(p).isSymbolicLink()) return false; // Through every link on the way (`media` is itself a link to the drive). return fsCjsRealpath(p).startsWith(root + path.sep); } catch { return false; } }; const fsCjsLstat = (fsCjs.lstatSync as (p: string) => { isSymbolicLink(): boolean }).bind(fsCjs); const fsCjsRealpath = (fsCjs.realpathSync as (p: string) => string).bind(fsCjs); for (const n of ["stat", "readFile", "open"]) { const orig = fspCjs[n] as (...a: unknown[]) => Promise; fspCjs[n] = function (this: unknown, ...args: unknown[]) { const p = asPath(args[0]); if (hangUnder && p !== null) { const abs = path.resolve(p); if (abs.startsWith(hangUnder + path.sep) || throughLinkUnder(abs, hangUnder)) { driveCalls.push(`${n} ${abs}`); return new Promise(() => {}); } } return orig.apply(this, args); }; } syncBuiltinESMExports(); } // ── the corpus ────────────────────────────────────────────────────────────── const writeJson = (file: string, value: unknown) => { mkdirSync(path.dirname(file), { recursive: true }); writeFileSync(file, JSON.stringify(value, null, 2)); }; const videoDir = (id: string, channel = CHANNEL) => path.join(paths.channelsDir, channel, "data", id); // YouTube's rolling-caption shape: parseVtt keeps only lines carrying inline // timing tags, so a plain cue would parse to nothing. const VTT = "WEBVTT\nKind: captions\nLanguage: en\n\n" + "00:00:00.000 --> 00:00:05.000 align:start position:0%\n" + "First<00:00:01.000> caption<00:00:02.000> line.\n\n" + "00:01:00.000 --> 00:01:50.000 align:start position:0%\n" + "Second<00:01:10.000> caption<00:01:20.000> line.\n"; // Metadata and English captions; `subs` adds a German track, which the index // publishes in the shared subs tree. function seedVideo( id: string, channel = CHANNEL, opts: { title?: string; subs?: boolean; dir?: string } = {}, ): void { const dir = opts.dir ?? videoDir(id, channel); writeJson(path.join(dir, "metadata.info.json"), { id, title: opts.title ?? `Video ${id}`, channel: channel, upload_date: "20260601", duration: 120, description: "fixture", webpage_url: `https://www.youtube.com/watch?v=${id}`, extractor_key: "Youtube", }); writeFileSync(path.join(dir, "transcript.en.vtt"), VTT); if (opts.subs) writeFileSync(path.join(dir, "transcript.de.vtt"), VTT); } function writeChannel(slug: string, extra: Record = {}): void { writeJson(path.join(paths.channelsDir, slug, "config.json"), { handling: "youtube", name: slug, url: `https://www.youtube.com/@${slug}/videos`, ...extra, }); } // A fresh corpus, LMDB and export tree per test, so every count is exact. The // drive is a storage location, as /storage records it: a held channel is named // by its label, never by a path. function resetCorpus(channels: string[] = [CHANNEL, DRIVE_CHANNEL]): void { for (const p of [paths.transcriptsDir, PINNED.EXPORT_INDEX_DIR, MEDIA, AWAY]) { rmSync(p, { recursive: true, force: true }); } mkdirSync(paths.transcriptsDir, { recursive: true }); writeFileSync( paths.settingsFile, JSON.stringify({ storage: { locations: [{ id: "usb", label: "USB drive", root: MEDIA, autoRepoint: false }] }, }), ); writeChannel(CHANNEL); 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" })), }); } // A channel whose MEDIA is relocated (release 17): its text in a real data/ // on the corpus disk, channels//media -> //media, with // config.mediaDir recording the target. d1 carries a subtitle track. const DRIVE_MEDIA = () => path.join(MEDIA, DRIVE_CHANNEL, "media"); const driveDataLink = () => path.join(paths.channelsDir, DRIVE_CHANNEL, "data"); const AWAY_TEXT = () => path.join(AWAY, DRIVE_CHANNEL, "data"); function seedDriveChannel(titles: Record = {}): void { mkdirSync(DRIVE_MEDIA(), { recursive: true }); writeChannel(DRIVE_CHANNEL, { mediaDir: DRIVE_MEDIA() }); symlinkSync(DRIVE_MEDIA(), path.join(paths.channelsDir, DRIVE_CHANNEL, "media")); seedVideo("d1", DRIVE_CHANNEL, { subs: true, title: titles.d1 }); seedVideo("d2", DRIVE_CHANNEL, { title: titles.d2 }); } // The text goes away: the channel is on the retired whole-directory layout // (`data` an absolute link to //data, `dataDir` recorded) with its // drive not mounted — the text moved to AWAY, the link dangling. `rename` // keeps every mtime, as `rsync -a` would. const unmount = () => { mkdirSync(path.dirname(AWAY_TEXT()), { recursive: true }); renameSync(driveDataLink(), AWAY_TEXT()); const target = path.join(MEDIA, DRIVE_CHANNEL, "data"); symlinkSync(target, driveDataLink()); writeChannel(DRIVE_CHANNEL, { dataDir: target }); }; // The text home again, in a real data/ (what `migrate-tier` leaves). const remount = () => { rmSync(driveDataLink()); renameSync(AWAY_TEXT(), driveDataLink()); writeChannel(DRIVE_CHANNEL, { mediaDir: DRIVE_MEDIA() }); }; // The MEDIA drive alone goes away and comes back. const unmountMedia = () => renameSync(MEDIA, `${MEDIA}-unmounted`); const remountMedia = () => renameSync(`${MEDIA}-unmounted`, MEDIA); async function runIndex(log: string[] = []) { const res = await buildIndex({ paths, onLog: (s) => log.push(s) }); return { res, log }; } // ── reading what the build left ───────────────────────────────────────────── // The index LMDB, opened the way buildIndex opens it, and closed again. function withIndex(fn: (db: (name: string) => ReturnType["openDB"]>) => T): T { const root = open({ path: paths.lmdbPath, maxDbs: 18, compression: true }); try { return fn((name) => root.openDB({ name, encoding: "msgpack" })); } finally { root.close(); } } // Every indexed video, as "/". const indexed = () => withIndex((db) => [...db("mtimes").getKeys()].map((k) => (k as unknown as string[]).join("/")).sort(), ); const summaryCount = () => withIndex((db) => [...db("sums").getKeys()].length); const storedSchema = () => withIndex((db) => db("meta").get("schema") as number); const setStoredSchema = (v: number) => withIndex((db) => db("meta").putSync("schema", v)); const pagesPending = () => withIndex((db) => db("meta").get(META_PAGES_PENDING)); // A directory tree as {relative path: contents}, or null when it is not there. function tree(dir: string): Record | null { if (!existsSync(dir)) return null; const out: Record = {}; const walk = (d: string) => { for (const e of readdirSync(d, { withFileTypes: true })) { const p = path.join(d, e.name); if (e.isDirectory()) walk(p); else out[path.relative(dir, p)] = readFileSync(p, "utf8"); } }; walk(dir); return out; } const sharedTranscripts = (slug = DRIVE_CHANNEL) => tree(path.join(paths.exportSharedTranscriptsDir, slug)); const sharedSubs = (slug = DRIVE_CHANNEL) => tree(path.join(paths.exportSharedSubsDir, slug)); type Published = { id: string; channelSlug?: string; state?: string; curatedTags?: string[] }; const siteDir = () => path.join(paths.exportSitesIndexDir, SITE); const summaries = (): Published[] => JSON.parse(readFileSync(path.join(siteDir(), "summaries", "page-0000.json"), "utf8")); const siteManifest = () => JSON.parse(readFileSync(path.join(siteDir(), "summaries", "manifest.json"), "utf8")) as { channels: { slug: string; count: number }[]; }; const siteSubsManifest = () => JSON.parse(readFileSync(path.join(siteDir(), "subs", "manifest.json"), "utf8")) as { channels: { slug: string; videoCount: number }[]; }; const stateOf = (id: string) => { const r = summaries().find((s) => s.id === id); assert.ok(r, `${id} is published`); return r.state ?? "available"; }; const transcriptRecord = (id: string, slug = DRIVE_CHANNEL): Published => { const page = JSON.parse( readFileSync(path.join(paths.exportSharedTranscriptsDir, slug, "page-0000.json"), "utf8"), ) as Published[]; const r = page.find((s) => s.id === id); assert.ok(r, `${id} is on its channel's transcript page`); return r; }; const DRIVE_VIDEOS = [`${DRIVE_CHANNEL}/d1`, `${DRIVE_CHANNEL}/d2`]; // ── the cases ─────────────────────────────────────────────────────────────── test("(a) an unmounted drive: the channel's records, transcript pages and subs survive an incremental build", async () => { resetCorpus(); seedVideo("local"); seedDriveChannel(); const mounted = await runIndex(); assert.deepEqual(mounted.res.heldChannels, []); assert.deepEqual(indexed(), [...DRIVE_VIDEOS, `${CHANNEL}/local`]); const pagesBefore = sharedTranscripts(); const subsBefore = sharedSubs(); assert.ok(pagesBefore?.["manifest.json"] && pagesBefore["page-0000.json"], "the drive's transcript pages"); assert.ok(subsBefore?.["manifest.json"], "the drive's subs pages"); unmount(); // Something else changed too, so the build rewrites the shared trees and // the site: the hold has to survive a real build, not a no-op one. seedVideo("local2"); const { res, log } = await runIndex(); assert.deepEqual(res.heldChannels, [DRIVE_CHANNEL]); assert.equal(res.added, 1); assert.equal(res.removed, 0, "not read as a channel with no videos"); assert.deepEqual(indexed(), [...DRIVE_VIDEOS, `${CHANNEL}/local`, `${CHANNEL}/local2`]); assert.equal(summaryCount(), 4); // Byte-identical, manifest included: its generatedAt shows no rewrite. assert.deepEqual(sharedTranscripts(), pagesBefore); assert.deepEqual(sharedSubs(), subsBefore); // The site built from this index still publishes the channel. for (const id of ["d1", "d2", "local", "local2"]) { assert.ok(summaries().some((s) => s.id === id), `${id} still published`); } assert.equal(siteManifest().channels.find((c) => c.slug === DRIVE_CHANNEL)?.count, 2); assert.equal(siteSubsManifest().channels.find((c) => c.slug === DRIVE_CHANNEL)?.videoCount, 1); // Said, with the location's label and no path. const line = log.find((l) => l.startsWith(`Channel ${DRIVE_CHANNEL}:`)); assert.ok(line, log.join("\n")); assert.match( line, /its media layout is the retired whole-directory one \(run archilyzer storage migrate-tier\), on location "USB drive"; its 2 indexed video\(s\) are kept as they are, not rescanned/, ); assert.ok(!line.includes(ROOT), line); assert.ok( log.some((l) => l.startsWith("Diff: +1 added, ~0 changed, -0 removed, 2 total. Held: 1 channel(s), 2 video(s) kept.")), log.join("\n"), ); assert.ok( log.some((l) => l.startsWith("Done in") && l.endsWith(`Held, their media not readable: ${DRIVE_CHANNEL}.`)), log.join("\n"), ); }); test("(b) a held channel's availability states are carried over, not re-read from the missing drive", async () => { resetCorpus(); seedVideo("local"); seedDriveChannel(); // Both drive videos fell out of the channel's listing. d2 was confirmed // public after that scan (available); d1 never was (maybe missing). writeJson(path.join(paths.channelsDir, DRIVE_CHANNEL, "maybe-missing.json"), { checkedAt: "2026-08-01T12:00:00.000Z", freshPlaylistCount: 0, ids: ["d1", "d2"], }); writeJson(path.join(videoDir("d2", DRIVE_CHANNEL), "availability.json"), { checkedAt: "2026-08-02T00:00:00.000Z", availability: "public", }); await runIndex(); assert.deepEqual([stateOf("d1"), stateOf("d2")], ["maybe_missing", "available"]); unmount(); seedVideo("local2"); // so the site is rebuilt const { res } = await runIndex(); assert.deepEqual(res.heldChannels, [DRIVE_CHANNEL]); // Re-read from the missing drive, d2's confirmation would be gone and both // would say "maybe missing"; skipped without the carry, d1's would vanish. assert.deepEqual([stateOf("d1"), stateOf("d2")], ["maybe_missing", "available"]); const videoState = withIndex((db) => Object.fromEntries([...db("videoState").getRange()].map(({ key, value }) => [String(key).replace("\x00", "/"), value])), ); assert.deepEqual(videoState, { [`${DRIVE_CHANNEL}/d1`]: "maybe_missing" }); }); test("(c) a full rebuild with a channel held refuses without the override, and holds with it", async () => { resetCorpus(); seedVideo("local"); seedDriveChannel(); await runIndex(); const current = storedSchema(); const pagesBefore = sharedTranscripts(); const subsBefore = sharedSubs(); unmount(); setStoredSchema(current - 1); await assert.rejects(runIndex(), (err: Error) => { assert.match( err.message, new RegExp( `must be rebuilt in full \\(index schema ${current - 1} -> ${current}\\), but 1 channel\\(s\\) cannot be read: ` + `drive-channel \\(its media layout is the retired whole-directory one \\(run archilyzer storage migrate-tier\\), on location "USB drive"\\)`, ), ); // Why, the ways out (mounting first), and the override by name; no path. assert.match(err.message, /the next site build would publish them as gone/); assert.match( err.message, /For each: mount its media and run this again; or repair or re-point its location on \/storage; or finish or clear its move .*; or, if it is gone for good, delete the channel or set excludeFromBuild/, ); // The override by name, and where it is set for each way a build runs. assert.match( err.message, new RegExp( `set ${INDEX_ALLOW_HELD_ENV}=1 in the environment of the process that runs the build: ` + `for the command line, the command's own \\(\`${INDEX_ALLOW_HELD_ENV}=1 pnpm archilyzer index\`\\); ` + `for the editor's Build index job, or a site build started from the editor, the editor's own environment, which takes a restart of the editor\\.$`, ), ); assert.ok(!err.message.includes(ROOT), err.message); return true; }); // Refused before the clear: nothing touched. assert.equal(storedSchema(), current - 1); assert.deepEqual(indexed(), [...DRIVE_VIDEOS, `${CHANNEL}/local`]); assert.deepEqual(sharedTranscripts(), pagesBefore); // The CLI (the export's build:index, a site build's data phase) exits // non-zero on it, and leaves the index as it was. const cli = spawnSync( path.join(COMMON, "node_modules", ".bin", "tsx"), ["bin/archilyzer.ts", "index"], { cwd: COMMON, env: { ...process.env }, encoding: "utf8" }, ); assert.notEqual(cli.status, 0, cli.stdout + cli.stderr); assert.match(cli.stderr, /must be rebuilt in full/); assert.equal(storedSchema(), current - 1); assert.deepEqual(indexed(), [...DRIVE_VIDEOS, `${CHANNEL}/local`]); // Overridden: the rebuild runs, the held channel's records go with the // clear (they cannot be re-read), and its pages are left as they are. process.env[INDEX_ALLOW_HELD_ENV] = "1"; try { const { res, log } = await runIndex(); assert.deepEqual(res.heldChannels, [DRIVE_CHANNEL]); assert.equal(storedSchema(), current); assert.deepEqual(indexed(), [`${CHANNEL}/local`]); assert.deepEqual(sharedTranscripts(), pagesBefore); assert.deepEqual(sharedSubs(), subsBefore); assert.ok( log.some( (l) => l.startsWith(`Channel ${DRIVE_CHANNEL}: its media layout is the retired`) && l.includes(`held under ${INDEX_ALLOW_HELD_ENV}: this full rebuild cleared its index records`), ), log.join("\n"), ); } finally { delete process.env[INDEX_ALLOW_HELD_ENV]; } // The drive back: an ordinary build takes the channel in again. remount(); const back = await runIndex(); assert.deepEqual(back.res.heldChannels, []); assert.equal(back.res.added, 2); assert.deepEqual(indexed(), [...DRIVE_VIDEOS, `${CHANNEL}/local`]); assert.equal(siteManifest().channels.find((c) => c.slug === DRIVE_CHANNEL)?.count, 2); }); test("(d) a first build, with no index yet, is a full rebuild: it refuses with a channel held too", async () => { resetCorpus(); seedVideo("local"); seedDriveChannel(); unmount(); await assert.rejects(runIndex(), /index schema -> \d+\), but 1 channel\(s\) cannot be read: drive-channel/); remount(); const { res } = await runIndex(); assert.deepEqual(res.heldChannels, []); assert.deepEqual(indexed(), [...DRIVE_VIDEOS, `${CHANNEL}/local`]); }); test("(e) the drive back: the held set is empty and what arrived meanwhile is indexed", async () => { resetCorpus(); seedVideo("local"); seedDriveChannel(); await runIndex(); unmount(); const away = await runIndex(); assert.deepEqual(away.res.heldChannels, [DRIVE_CHANNEL]); assert.equal(away.res.removed, 0); // Downloaded onto the drive while it was elsewhere. seedVideo("d3", DRIVE_CHANNEL, { dir: path.join(AWAY, DRIVE_CHANNEL, "data", "d3") }); remount(); const { res, log } = await runIndex(); assert.deepEqual(res.heldChannels, []); assert.equal(res.added, 1); assert.equal(res.changed, 0, "the kept records match the disk"); assert.equal(res.removed, 0); assert.deepEqual(indexed(), [...DRIVE_VIDEOS, `${DRIVE_CHANNEL}/d3`, `${CHANNEL}/local`]); assert.deepEqual(Object.keys(JSON.parse(sharedTranscripts()!["manifest.json"]).slugToPage).sort(), ["d1", "d2", "d3"]); assert.ok(!log.some((l) => l.includes("Held")), log.join("\n")); }); test("(f) a channel that is really empty is still emptied, not held", async () => { resetCorpus(["emptied", "gone", CHANNEL]); for (const slug of ["emptied", "gone"]) { writeChannel(slug); seedVideo("x1", slug); seedVideo("x2", slug); } seedVideo("local"); await runIndex(); assert.equal(indexed().length, 5); // Its media deleted: an empty data/ in place, and no data/ at all. Neither // was relocated, so there is no drive to be missing. for (const id of ["x1", "x2"]) rmSync(videoDir(id, "emptied"), { recursive: true }); rmSync(path.join(paths.channelsDir, "gone", "data"), { recursive: true }); const { res, log } = await runIndex(); assert.deepEqual(res.heldChannels, []); assert.equal(res.removed, 4); assert.deepEqual(indexed(), [`${CHANNEL}/local`]); assert.deepEqual(JSON.parse(sharedTranscripts("emptied")!["manifest.json"]).slugToPage, {}); // The missing directory is said, not swallowed. assert.ok( log.includes("Channel gone: no data/ directory; indexed as a channel with no videos."), log.join("\n"), ); }); test("(g) a data directory that cannot be read holds its channel, and says why", async () => { if (process.getuid?.() === 0) return; // root reads through a mode of 000 resetCorpus(["locked", "flaky", CHANNEL]); for (const slug of ["locked", "flaky"]) { writeChannel(slug); seedVideo("x1", slug); seedVideo("x2", slug); } await runIndex(); assert.equal(indexed().length, 4); // The whole data/ unreadable; and one video dir unreadable mid-walk. const locked = path.join(paths.channelsDir, "locked", "data"); const flaky = videoDir("x2", "flaky"); chmodSync(locked, 0o000); chmodSync(flaky, 0o000); try { const { res, log } = await runIndex(); assert.deepEqual([...res.heldChannels].sort(), ["flaky", "locked"]); assert.equal(res.removed, 0); assert.equal(indexed().length, 4); assert.ok( log.some((l) => l.startsWith("Channel locked: its data directory could not be read (EACCES)")), log.join("\n"), ); assert.ok( // One video's directory, said apart from the whole data/ above. log.some((l) => l.startsWith("Channel flaky: a video in its data directory could not be read (EACCES)")), log.join("\n"), ); } finally { chmodSync(locked, 0o755); chmodSync(flaky, 0o755); } const { res } = await runIndex(); assert.deepEqual(res.heldChannels, []); assert.equal(indexed().length, 4); }); test("(h) a curated-tag change while a channel is held reaches its pages when the drive is back", async () => { resetCorpus(); seedVideo("local"); seedDriveChannel({ d1: "Stream with Elfpire Eva" }); await runIndex(); assert.equal("curatedTags" in transcriptRecord("d1"), false); unmount(); // A rule edit moves no mtime. It re-derives d1 in the index (no disk read), // but d1's page is not rewritten while its channel is held. writeGlobalTags(paths, { version: 1, tags: [ { id: "eva-collab", label: "Collab", group: "eva", groupLabel: "Eva", order: 1, rules: [{ id: "meta", kind: "metadata" as const, pattern: "elfpire", enabled: true }], }, ], assignments: {}, }); const pagesBefore = sharedTranscripts(); const { log } = await runIndex(); assert.deepEqual(sharedTranscripts(), pagesBefore); assert.equal(pagesPending(), true, "the page debt is kept"); assert.ok(log.some((l) => l.startsWith("curated tags: the pages of 1 held channel(s) are not rewritten while held")), log.join("\n")); remount(); const back = await runIndex(); assert.equal(back.res.added + back.res.changed + back.res.removed, 0, "no mtime moved"); assert.deepEqual(transcriptRecord("d1").curatedTags, ["eva-collab"]); assert.equal(pagesPending(), false); }); test("(i) the drive lost MID-WALK: the second look holds the channel instead of dropping the rest of it", async () => { resetCorpus(); seedVideo("local"); seedDriveChannel(); for (const id of ["d3", "d4"]) seedVideo(id, DRIVE_CHANNEL); const drive = [...DRIVE_VIDEOS, `${DRIVE_CHANNEL}/d3`, `${DRIVE_CHANNEL}/d4`]; await runIndex(); assert.deepEqual(indexed(), [...drive, `${CHANNEL}/local`]); const pagesBefore = sharedTranscripts(); const subsBefore = sharedSubs(); // The first look before the walk finds the drive, and readdir lists all four // videos. Then the drive goes, right after the walk's first metadata stat: // every later stat in the channel is ENOENT, which on its own reads as "no // metadata yet" and would drop the rest of the channel as gone. let lost = false; afterStat = (p) => { if (lost || !p.endsWith(`${path.sep}metadata.info.json`)) return; if (!p.includes(`${path.sep}${DRIVE_CHANNEL}${path.sep}data${path.sep}`)) return; lost = true; unmount(); }; seedVideo("local2"); // so the build is not a no-op const { res, log } = await runIndex().finally(() => { afterStat = null; }); assert.ok(lost, "the drive went away inside the walk"); assert.deepEqual(res.heldChannels, [DRIVE_CHANNEL]); assert.equal(res.removed, 0, log.join("\n")); assert.equal(res.added, 1); assert.deepEqual(indexed(), [...drive, `${CHANNEL}/local`, `${CHANNEL}/local2`]); assert.deepEqual(sharedTranscripts(), pagesBefore); assert.deepEqual(sharedSubs(), subsBefore); assert.ok( log.some((l) => l.startsWith(`Channel ${DRIVE_CHANNEL}: its media layout is the retired whole-directory one (run archilyzer storage migrate-tier), on location "USB drive"; its 4 indexed video(s) are kept`), ), log.join("\n"), ); }); test("(j) release 17: an unmounted MEDIA drive does not hold the index — the text is read", async () => { resetCorpus(); seedVideo("local"); seedDriveChannel(); // d2's audio is tiered: a relative link into the channel's media/. mkdirSync(path.join(DRIVE_MEDIA(), "d2"), { recursive: true }); writeFileSync(path.join(DRIVE_MEDIA(), "d2", "audio.mp3"), "AUDIO"); symlinkSync("../../media/d2/audio.mp3", path.join(videoDir("d2", DRIVE_CHANNEL), "audio.mp3")); await runIndex(); assert.deepEqual(indexed(), [...DRIVE_VIDEOS, `${CHANNEL}/local`]); unmountMedia(); try { seedVideo("d3", DRIVE_CHANNEL); // arrived meanwhile, text on the corpus disk const { res, log } = await runIndex(); assert.deepEqual(res.heldChannels, [], log.join("\n")); assert.equal(res.added, 1); assert.equal(res.removed, 0); assert.deepEqual(indexed(), [...DRIVE_VIDEOS, `${DRIVE_CHANNEL}/d3`, `${CHANNEL}/local`]); } finally { remountMedia(); } }); test("(k) release 17: a STALLED media drive with a tiered live chat holds neither the index nor the stats build, and is not asked", async () => { resetCorpus(); seedVideo("local"); seedDriveChannel(); // d1's raw live chat is tiered: the bytes on the drive, a relative link in // data/d1 carrying the file's times, and no cues yet (so the build reads // the raw — the fallback this case pins). const chat = JSON.stringify({ replayChatItemAction: { actions: [{ addChatItemAction: { item: { liveChatTextMessageRenderer: { message: { runs: [{ text: "hello" }] }, authorName: { simpleText: "a" }, timestampUsec: "1000000" } } } }], videoOffsetTimeMsec: "1000" } }) + "\n"; mkdirSync(path.join(DRIVE_MEDIA(), "d1"), { recursive: true }); const bytes = path.join(DRIVE_MEDIA(), "d1", "transcript.live_chat.json"); writeFileSync(bytes, chat); symlinkSync("../../media/d1/transcript.live_chat.json", path.join(videoDir("d1", DRIVE_CHANNEL), "transcript.live_chat.json")); const first = await runIndex(); assert.deepEqual(first.res.heldChannels, []); const liveChatOf = () => withIndex((db) => [...db("subs").getRange()] .filter(({ key }) => (key as unknown as string[])[2] === "d1") .flatMap(({ value }) => (value as { track: string }[]).map((t) => t.track)), ); assert.ok(liveChatOf().includes("live_chat"), "read through the link while the drive answered"); // The drive stalls: marked (the health pass does this in the editor), and // every call that would reach it hangs. A metadata rewrite makes d1 changed. const { recordLocationHealth, resetStorageHealth } = await import("../lib/storageHealth"); recordLocationHealth({ id: "usb", label: "USB drive", root: MEDIA }, "stalled"); hangUnder = MEDIA; driveCalls.length = 0; try { seedVideo("d1", DRIVE_CHANNEL, { subs: true, title: "Retitled" }); const { res, log } = await runIndex(); assert.deepEqual(res.heldChannels, [], log.join("\n")); assert.equal(res.changed, 1); assert.equal(res.removed, 0); assert.ok(liveChatOf().includes("live_chat"), "the cues the last build held are kept"); const { buildStats } = await import("./buildStats"); const stats = await buildStats({ paths, onLog: () => {}, wholePoolStatsDir: path.join(ROOT, "pool-stats"), }); assert.deepEqual(stats.heldChannels, []); assert.deepEqual(driveCalls, [], "no call reached the stalled drive"); } finally { hangUnder = null; resetStorageHealth(); } }); test("(l) release 17: a tiered raw live chat that cannot be read, with no cues to keep, is retried by the next build", async () => { resetCorpus(); seedVideo("local"); seedDriveChannel(); const chat = JSON.stringify({ replayChatItemAction: { actions: [{ addChatItemAction: { item: { liveChatTextMessageRenderer: { message: { runs: [{ text: "hi" }] }, authorName: { simpleText: "a" }, timestampUsec: "1000000" } } } }], videoOffsetTimeMsec: "1000" } }) + "\n"; mkdirSync(path.join(DRIVE_MEDIA(), "d2"), { recursive: true }); writeFileSync(path.join(DRIVE_MEDIA(), "d2", "transcript.live_chat.json"), chat); symlinkSync("../../media/d2/transcript.live_chat.json", path.join(videoDir("d2", DRIVE_CHANNEL), "transcript.live_chat.json")); const tracksOfD2 = () => withIndex((db) => [...db("subs").getRange()] .filter(({ key }) => (key as unknown as string[])[2] === "d2") .flatMap(({ value }) => (value as { track: string }[]).map((t) => t.track)), ); // The media drive is away for the first build: nothing to read, nothing kept. unmountMedia(); try { const away = await runIndex(); assert.deepEqual(away.res.heldChannels, []); assert.equal(tracksOfD2().includes("live_chat"), false); } finally { remountMedia(); } // Back: nothing on disk moved, yet the record is retried and the chat read. const back = await runIndex(); assert.equal(back.res.changed, 1, "d2 is retried"); assert.ok(tracksOfD2().includes("live_chat")); // And then it settles. assert.equal((await runIndex()).res.changed, 0); }); test("(z) no write this file caused landed outside its temp root", () => { // LMDB writes natively, past the spy: its file must be under the root too. assert.ok(paths.lmdbPath.startsWith(ROOT + path.sep), paths.lmdbPath); assert.ok(statSync(ROOT).isDirectory()); const outside = writes.filter((p) => p !== ROOT && !p.startsWith(ROOT + path.sep)); assert.deepEqual(outside, []); assert.ok(writes.length > 0, "the spy saw the writes"); });