import { copyFile, lstat, mkdir, readdir, readlink, rename, rm, stat, symlink, utimes, writeFile, } from "node:fs/promises"; import { join } from "node:path"; import { test, expect, type Page } from "@playwright/test"; import { baseUrl } from "./baseUrl"; import { channelStage, channelVideos, generateReport, pathExists, readJson, resetData, resolvePath, writeChannelConfig, writeDigestVideo, writeSettings, } from "./helpers"; // MOVING A CHANNEL'S MEDIA TO ANOTHER DIRECTORY, AND BACK. // // RELEASE 17: what moves is the MEDIA TIER — `channels//media`, the big // files (the audio, the raw live chat), each reached from `data//` // by a relative link. The text never leaves `data/` on the corpus disk. A // classic channel (its audio still a real file in `data//`) is tiered by // the move's preview and preflight first. // // The thing this spec is really pinning is the claim the whole design rests on: // that a relocated channel is indistinguishable from an in-place one to every // reader. So the assertions after the move are deliberately NOT about the move — // they are the videos list still listing the video and the file route still // serving the transcript, through the same URLs, with the bytes now somewhere // else entirely. // // THE REPORT IS GENERATED FIRST AND THEN WAITED OUT. The Storage panel refuses // a move while the channel has a queued or running job — the rename's guard, for // the sharper reason that a download writing into data/ mid-copy either fails // the verify or loses the write — and generateReport leaves exactly such a job // behind for a moment after the snapshot lands. quiet() is that wait, and it is // the honest one: the guard is real and the spec has to satisfy it rather than // race it. // // A relocation itself queues NO report (`relocate-channel-media` is in // NO_REGEN_KINDS: the move changes where the bytes are, not what they are), so // there is nothing to wait for between the move out and the move back. // // The destination is the TEST'S OWN tmp dir (testInfo.outputPath), never a real // drive and never a shared path — with --repeat-each each repeat gets its own. const SLUG = "test-youtube"; const VIDEO = "20240101_test1234567"; const dataDir = () => resolvePath(`test-transcripts/channels/${SLUG}/data`); const mediaLink = () => resolvePath(`test-transcripts/channels/${SLUG}/media`); const videoFile = (name: string) => join(dataDir(), VIDEO, name); // A small audio file — the route streams bytes, it does not decode them. const AUDIO_BYTES = 4096; const AUDIO = "ID3" + "x".repeat(AUDIO_BYTES - 3); // THE TIERED LAYOUT, BY HAND: the bytes in `//` and the // RELATIVE link `data// -> ../../media//` — what the hook // leaves behind (lib/mediaTier-server.ts). `mediaDir` is the channel's real // `media/` (tiered in place) or a relocated target the `media` link points at. async function tierByHand( mediaDir: string, files: Record, ): Promise { await mkdir(join(mediaDir, VIDEO), { recursive: true }); for (const [name, content] of Object.entries(files)) { await writeFile(join(mediaDir, VIDEO, name), content); await rm(videoFile(name), { force: true }); await symlink(join("..", "..", "media", VIDEO, name), videoFile(name)); } } // Wait until the channel has no running or queued job. Polled from the same // endpoint /jobs draws, because the guard reads the same registry. async function quiet(page: Page): Promise { await expect .poll( async () => { const res = await page.request.get(`${baseUrl}/api/jobs/active`); const body = await res.json(); const jobs: { channelSlug?: string; status: string }[] = Array.isArray( body, ) ? body : (body.jobs ?? []); return jobs.filter( (j) => j.channelSlug === SLUG && (j.status === "running" || j.status === "queued"), ).length; }, { timeout: 30_000 }, ) .toBe(0); } test("relocate a channel's media to another root, and move it back", async ({ page, }, testInfo) => { test.setTimeout(120_000); await resetData("one-youtube-channel-with-data"); await generateReport(page, SLUG); await quiet(page); const root = testInfo.outputPath("media-root"); await mkdir(root, { recursive: true }); const target = join(root, SLUG, "media"); // A CLASSIC CHANNEL: its audio and its raw live chat are real files in // `data//`, which is how every channel downloaded before release 17 is. await writeFile(videoFile("audio.mp3"), AUDIO); await writeFile(videoFile("transcript.live_chat.json"), "[]"); // --- before ------------------------------------------------------------- await page.goto(channelStage(SLUG, "storage")); // Two rows under one heading: the media tier (not there yet — in place) and // the text, which never moves. await expect(page.getByLabel("media path")).toHaveText( `${mediaLink()} (in place)`, ); await expect(page.getByLabel("text path")).toContainText(dataDir()); // In place draws no badge at all — a badge on every channel saying "normal" // is what makes the one that matters hard to find. await expect(page.getByLabel(/^media location:/)).toHaveCount(0); // --- preview gates the move -------------------------------------------- const moveButton = page.getByRole("button", { name: "Move media" }); // Nothing previewed yet, so the move is not offered even with a root typed. // Asserted through the HINT rather than through the disabled button alone: // the button is also disabled before hydration, so `toBeDisabled()` on its // own would pass for the wrong reason. await page.getByLabel("destination root").fill(root); await expect( page.getByText("Preview this destination to enable the move."), ).toBeVisible(); await expect(moveButton).toBeDisabled(); // EXACT. The panel has a second preview since the clip-window card joined it // ("Preview eviction"), and getByRole's name match is a case-insensitive // SUBSTRING by default — so the bare name resolves to two buttons and the // strict-mode violation reads as "the button vanished". await page.getByRole("button", { name: "Preview", exact: true }).click(); const preview = page.getByLabel("relocation preview"); await expect(preview).toBeVisible({ timeout: 15_000 }); // The media tier only: the audio and the raw live chat — not the transcript // or the metadata, which stay. await expect(page.getByLabel("bytes to move")).toContainText("2 file(s)"); // THE PREVIEW TIERED THE CLASSIC CHANNEL FIRST, on this disk, and says so. await expect(page.getByLabel("tiered first")).toContainText( "2 file(s) tiered first", ); expect((await lstat(videoFile("audio.mp3"))).isSymbolicLink()).toBe(true); expect((await lstat(mediaLink())).isDirectory()).toBe(true); // --- the move ----------------------------------------------------------- await expect(moveButton).toBeEnabled(); await moveButton.click(); await expect(page.getByLabel("Move media output")).toContainText("Moved", { timeout: 60_000, }); // THE COPY SAYS HOW FAR IT GOT. rsync's `--info=progress2` frames used to go // into the log as several thousand carriage-return redraws of one line; the // controller parses them and writes one line per decile instead. The fixture // is two small files, so the copy is over in one frame and the assertion is // on the DECILE LINE that frame produces — a percentage-and-rate line that // could only have come from a parsed frame. (The live task bar on /jobs is // fed by the same parse; there is no honest way to catch a 3 ms copy // mid-flight from a browser, which is what the unit tests in // jobs/progressParsers.test.ts are for.) await expect(page.getByLabel("Move media output")).toContainText( /Copying… .* · \d+ % · /, ); // `media` is ONE symlink now, `data/` is still a real directory, and each big // file in it is a link into `media/` — config.json records the target, // written only by the job, on success, after the copy verified. expect((await lstat(mediaLink())).isSymbolicLink()).toBe(true); expect(await readlink(mediaLink())).toBe(target); expect((await lstat(dataDir())).isDirectory()).toBe(true); expect((await lstat(dataDir())).isSymbolicLink()).toBe(false); expect((await lstat(videoFile("audio.mp3"))).isSymbolicLink()).toBe(true); expect( (await lstat(videoFile("transcript.live_chat.json"))).isSymbolicLink(), ).toBe(true); expect((await lstat(videoFile("transcript.en.vtt"))).isFile()).toBe(true); expect(await existsAbs(join(target, VIDEO, "audio.mp3"))).toBe(true); const config = await readJson<{ mediaDir?: string; dataDir?: string }>( `test-transcripts/channels/${SLUG}/config.json`, ); expect(config.mediaDir).toBe(target); expect(config.dataDir).toBe(undefined); // The source was reclaimed: no parked copy left holding a second copy of the // media on the volume the move exists to free, and no marker. const siblings = await readdir( resolvePath(`test-transcripts/channels/${SLUG}`), ); expect(siblings.filter((n) => n.startsWith("media."))).toEqual([]); expect( await pathExists(`test-transcripts/channels/${SLUG}/.relocating.json`), ).toBe(false); // --- NOTHING ELSE NOTICED ---------------------------------------------- // The videos list is read off data/ through the same joined path it always // was; the selected video lists the tiered audio beside the transcript. await page.goto(channelVideos(SLUG, { video: VIDEO })); await expect(page.getByText(VIDEO).first()).toBeVisible(); await expect(page.getByLabel("file name audio.mp3")).toBeVisible(); await expect(page.getByLabel("file name transcript.en.vtt")).toBeVisible(); // So does the per-file route, which resolves channelsDir//data// // and has no idea any of this happened: the transcript off the corpus disk, // the audio streamed through its link onto the other drive. const fileUrl = (name: string) => `${baseUrl}/api/channels/${SLUG}/videos/${VIDEO}/files/${name}`; const file = await page.request.get(fileUrl("transcript.en.vtt")); expect(file.status()).toBe(200); expect(await file.text()).toContain("WEBVTT"); const audio = await page.request.get(fileUrl("audio.mp3")); expect(audio.status()).toBe(200); expect(audio.headers()["content-type"]).toBe("audio/mpeg"); expect((await audio.body()).length).toBe(AUDIO_BYTES); const ranged = await page.request.get(fileUrl("audio.mp3"), { headers: { range: "bytes=0-99" }, }); expect(ranged.status()).toBe(206); expect((await ranged.body()).toString()).toBe(AUDIO.slice(0, 100)); // --- the badge ---------------------------------------------------------- await page.goto("/channels"); await expect( page.getByLabel(/^media location: Media relocated/), ).toBeVisible(); // --- THE MEDIA DRIVE GOES AWAY; THE TEXT DOES NOT ------------------------ // Renamed away (the buildStats.test.ts trick): the `media` link dangles, as // it does with the drive unmounted. The video page still reads — its text is // on the corpus disk — the audio is listed with its size unknown, the file // route answers 503 with a retry for it and 200 for the transcript, and the // rack says the media is held. const away = join(root, `${SLUG}.away`); await rename(join(root, SLUG), away); await forgetCaches(); await page.goto(`/channels/${SLUG}/videos/${VIDEO}`); await expect(page.getByLabel("file name transcript.en.vtt")).toBeVisible(); await expect(page.getByLabel("file name audio.mp3")).toBeVisible(); await expect(page.getByText("media drive not reachable").first()).toBeVisible(); const text = await page.request.get(fileUrl("transcript.en.vtt")); expect(text.status()).toBe(200); const held = await page.request.get(fileUrl("audio.mp3")); expect(held.status()).toBe(503); expect(held.headers()["retry-after"]).toBe("15"); await page.goto("/channels"); const chip = page.getByLabel(`media hold for ${SLUG}`); await expect(chip).toContainText("media held"); await page.goto(channelStage(SLUG, "storage")); await expect(page.getByLabel("media hold", { exact: true })).toContainText( "Its text stays readable", ); await rename(away, join(root, SLUG)); await forgetCaches(); // --- back, WHICH IS A DESTINATION AND NOT A SECOND BUTTON ---------------- // "Move back in place" was its own section with its own button; it is now // the one destination the select offers while the media is on a location, // and it runs the same action (moveChannelMediaBackAction) it always did. await page.goto(channelStage(SLUG, "storage")); await expect(page.getByLabel("media path")).toHaveText(target); const destination = page.getByLabel("destination location"); await expect(destination).toHaveValue("__internal"); // The only option: a location-to-location move is refused by the controller, // so the select does not offer one. await expect(destination.locator("option")).toHaveCount(1); await expect(destination.locator("option")).toHaveText( "Internal (in place)", ); const backButton = page.getByRole("button", { name: "Move back in place", }); await expect(backButton).toBeEnabled(); await backButton.click(); await expect(page.getByLabel("Move media output")).toContainText( "Moved back", { timeout: 60_000 }, ); // `media/` a REAL directory on the corpus disk again, the config field gone, // the target reclaimed — and the per-file link untouched, resolving here now. expect((await lstat(mediaLink())).isDirectory()).toBe(true); expect((await lstat(mediaLink())).isSymbolicLink()).toBe(false); expect((await lstat(videoFile("audio.mp3"))).isSymbolicLink()).toBe(true); expect( ( await readJson<{ mediaDir?: string }>( `test-transcripts/channels/${SLUG}/config.json`, ) ).mediaDir, ).toBe(undefined); expect(await existsAbs(target)).toBe(false); const back = await page.request.get(fileUrl("transcript.en.vtt")); expect(back.status()).toBe(200); const backAudio = await page.request.get(fileUrl("audio.mp3")); expect(backAudio.status()).toBe(200); expect((await backAudio.body()).length).toBe(AUDIO_BYTES); }); test("the Configure form shows the media location read-only", async ({ page, }) => { await resetData("one-youtube-channel-with-data"); await page.goto(channelStage(SLUG, "configure")); // exact: the Storage panel's badge is "media location: …", and a substring // match would find either. const line = page.getByLabel("media location", { exact: true }); await expect(line).toHaveText("In the channel directory (media/)"); // Not an input: it is a record of what is on disk, and the only writer is a // move that succeeded. A text box here would let config and disk disagree // with a keystroke. expect(await line.evaluate((el) => el.tagName)).not.toBe("INPUT"); }); // THE BULK MOVE, from /channels, with the cold root coming out of Settings. // // Two channels are selected and exactly one moves. The other is a channel that // is ALREADY relocated, built directly on disk — an absolute `media` symlink // plus `config.mediaDir`, which is precisely what a finished move leaves behind — // rather than by running a second relocation first. A real move here would cost // a second rsync, a second job wait and a second 60s timeout to assert a skip // that is decided before any byte is read; what is under test is the skip, and // the state it keys off is three filesystem calls to produce. // // THE HAPPENS-BEFORE EDGE IS THE BADGE. The bulk action returns when the jobs // are ENQUEUED, not when they finish, so "Queued 1" is not permission to read // config.json. The row's badge is rendered from inspectChannelMedia on the // server, so it cannot appear before the swap has committed — waiting for it is // waiting for the job, through the same surface an operator watches. The videos // list afterwards is the claim the whole design rests on, checked once more from // a channel that got there by a different route. test("the /channels bulk move queues one job per channel and skips the rest", async ({ page, }, testInfo) => { test.setTimeout(120_000); await resetData("one-youtube-channel-with-data"); const root = testInfo.outputPath("bulk-root"); const warm = testInfo.outputPath("warm-root"); await mkdir(root, { recursive: true }); await mkdir(warm, { recursive: true }); // THE DEFAULT ROOT, set the way the operator does. The bulk bar's box is // seeded from it, so this is also what asserts the settings value reaches the // client. The other four keys mirror fixtures/test-settings.default.json, // which writeSettings replaces wholesale — notably minFreeDiskGB 0, so the // move is not charged the resume margin on a nearly-full disk. await writeSettings({ adminTitle: "Test Admin", minFreeDiskGB: 0, verifyAvailabilityBeforeClean: false, syncScheduler: { fullSweepIntervalMinutes: 0 }, // TWO LOCATIONS, and the default is NOT the one this spec moves to. The // deck opens on `warm` and the spec picks `cold` by name, which is what // asserts the select's value reaches the action — the old single-location // fixture could not tell "the id was sent" from "the default was used". storage: { locations: [ { id: "warm", label: "Warm", root: warm, autoRepoint: false }, { id: "cold", label: "Cold", root, autoRepoint: false }, ], defaultLocationId: "warm", }, }); const PRE = "pre-moved"; const preTarget = join(root, PRE, "media"); const preVideo = "20240102_pre1234567"; await mkdir(join(preTarget, preVideo), { recursive: true }); await writeFile(join(preTarget, preVideo, "audio.mp3"), AUDIO); const preData = resolvePath(`test-transcripts/channels/${PRE}/data/${preVideo}`); await mkdir(preData, { recursive: true }); await writeFile( join(preData, "transcript.en.vtt"), "WEBVTT\n\n00:00.000 --> 00:01.000\nhello\n", ); await symlink( join("..", "..", "media", preVideo, "audio.mp3"), join(preData, "audio.mp3"), ); await writeChannelConfig(PRE, { mediaDir: preTarget }); await symlink( preTarget, resolvePath(`test-transcripts/channels/${PRE}/media`), ); await page.goto("/channels"); // One badge before the move: the channel that is already on the root. const badges = page.getByLabel(/^media location: Media relocated/); await expect(badges).toHaveCount(1); // The deck only exists with a selection — it is a selection deck, and a // destination with nothing to apply it to is a control that cannot do // anything. const destination = page.getByLabel("bulk media location"); await expect(destination).toHaveCount(0); await page.getByLabel("select all channels").check(); await expect(page.getByLabel(`select ${SLUG}`)).toBeChecked(); await expect(page.getByLabel(`select ${PRE}`)).toBeChecked(); // It opens on the DEFAULT location, and names every configured one. No root // is typed anywhere in this test: the deck sends an id. await expect(destination).toHaveValue("warm"); await expect(page.getByLabel("bulk media destination")).toHaveText(warm); await destination.selectOption("cold"); await expect(page.getByLabel("bulk media destination")).toHaveText(root); await page.getByLabel("move media for selected channels").click(); const result = page.getByLabel("bulk media move result"); await expect(result).toContainText("Queued 1 · skipped 1", { timeout: 30_000, }); // The skip names the channel and why — the same title-attribute shape the // "Sync every channel" result uses for its own skips. await expect(result).toHaveAttribute( "title", `${PRE}: already relocated to ${preTarget}`, ); // Wait for the job through the badge: two relocated channels, not one. await expect .poll( async () => { await page.reload(); return badges.count(); }, { timeout: 90_000 }, ) .toBe(2); // AND THE BADGE NAMES THE PLACE. Both channels are under the `cold` root, so // the row says the operator's own word for that drive rather than a 60-column // absolute path nobody reads. The accessible name still carries the full // sentence, which is what the locator above matches on. await expect(badges.first()).toHaveText("on Cold"); await expect(badges.nth(1)).toHaveText("on Cold"); // The moved channel: config records the target under the tmp root, `media` is // a link, `data/` is still real, and the videos list lists the video. expect( ( await readJson<{ mediaDir?: string }>( `test-transcripts/channels/${SLUG}/config.json`, ) ).mediaDir, ).toBe(join(root, SLUG, "media")); expect((await lstat(mediaLink())).isSymbolicLink()).toBe(true); expect((await lstat(dataDir())).isSymbolicLink()).toBe(false); await page.goto(channelVideos(SLUG)); await expect(page.getByText(VIDEO).first()).toBeVisible(); // The skipped channel was not touched: same target, still a link, no marker. expect( ( await readJson<{ mediaDir?: string }>( `test-transcripts/channels/${PRE}/config.json`, ) ).mediaDir, ).toBe(preTarget); expect( ( await lstat(resolvePath(`test-transcripts/channels/${PRE}/media`)) ).isSymbolicLink(), ).toBe(true); expect( await pathExists(`test-transcripts/channels/${PRE}/.relocating.json`), ).toBe(false); }); // ONE QUEUE FOR EVERY MOVE, AND A CHANNEL WITH NOTHING TO MOVE NEVER BECOMES A // JOB. // // The queue key is the ONLY thing that decides whether two relocations run at // once: registry.ts caps a key at concurrency 1 and caps nothing across keys. // A per-channel key therefore starts every selected channel's rsync together, // onto one destination volume, and each job's space check runs when it STARTS — // so concurrent starts are each credited room the others have already claimed. // The shared key is what the bulk bar's "refuses the remainder one job at a // time" sentence actually rests on, so it is asserted rather than described: // both jobs land on `relocate`, read off the /jobs table's own queue column. // // The empty channel is the second half: `inspect()` calls a channel that has // downloaded nothing `in-place` — correctly, it is not relocated — so without an // up-front skip it becomes a job record, a log and a queue slot whose only act // is to throw "has no data/ to move". On a fresh corpus most of a page is that. test("a bulk move puts every job on one queue and skips a channel with nothing to move", async ({ page, }, testInfo) => { test.setTimeout(120_000); await resetData("one-youtube-channel-with-data"); const root = testInfo.outputPath("queue-root"); await mkdir(root, { recursive: true }); await writeSettings({ adminTitle: "Test Admin", storage: { locations: [{ id: "cold", label: "Cold", root, autoRepoint: false }], defaultLocationId: "cold", }, }); // A second channel with real media, so the selection queues TWO moves — one // queue key is only a claim about two of them. const SECOND = "second-mover"; await writeChannelConfig(SECOND, { handling: "youtube", name: "Second Mover", url: "https://www.youtube.com/@second/videos", }); await mkdir( resolvePath( `test-transcripts/channels/${SECOND}/data/20240103_second12345`, ), { recursive: true }, ); await writeFile( resolvePath( `test-transcripts/channels/${SECOND}/data/20240103_second12345/transcript.en.vtt`, ), "WEBVTT\n\n00:00.000 --> 00:01.000\nhello\n", ); // ...and a third with a config and no data/ at all: the skip. const EMPTY = "no-media-yet"; await writeChannelConfig(EMPTY, { handling: "youtube", name: "No Media Yet", url: "https://www.youtube.com/@empty/videos", }); await page.goto("/channels"); await page.getByLabel("select all channels").check(); await page.getByLabel("move media for selected channels").click(); const result = page.getByLabel("bulk media move result"); await expect(result).toContainText("Queued 2 · skipped 1", { timeout: 30_000, }); await expect(result).toHaveAttribute( "title", `${EMPTY}: nothing to move — no media has been downloaded for it yet`, ); // Both moves land, through the badge, and then both jobs are on ONE queue. await expect .poll( async () => { await page.reload(); return page.getByLabel(/^media location: Media relocated/).count(); }, { timeout: 90_000 }, ) .toBe(2); await page.goto("/jobs"); const rows = page .getByRole("row") .filter({ hasText: "Relocate channel media" }); await expect(rows).toHaveCount(2); // The queue column: `relocate` for both. The discriminating half is the // negative — a per-channel key prints `channel:` there, and that is // exactly the shape that would let the two run at once. for (const i of [0, 1]) { await expect(rows.nth(i)).toContainText("relocate"); await expect(rows.nth(i)).not.toContainText("channel:"); } }); // THE DESTINATION IS A NAME NOW, not a path retyped per channel. // // Two locations are configured and the DEFAULT IS NOT THE ONE THIS SPEC MOVES // TO, which is the whole point: the panel opens on `warm`, the spec picks // `cold` by name, and what reaches the server is the ID — the root is looked up // there, from the same settings.json /storage writes. A fixture with one // location could not tell "the id was sent" from "the default was used". // // The free-text box is GONE while a location is picked. It is one option away // ("Another root…") and it is the whole control on a corpus with no locations // configured, which is what the first test in this file exercises. test("the Storage panel moves to a location picked by name", async ({ page, }, testInfo) => { test.setTimeout(90_000); await resetData("one-youtube-channel-with-data"); const warm = testInfo.outputPath("warm-root"); const cold = testInfo.outputPath("cold-root"); await mkdir(warm, { recursive: true }); await mkdir(cold, { recursive: true }); await writeSettings({ adminTitle: "Test Admin", minFreeDiskGB: 0, storage: { locations: [ { id: "warm", label: "Warm", root: warm, autoRepoint: false }, { id: "cold", label: "Cold", root: cold, autoRepoint: false }, ], defaultLocationId: "warm", }, }); await generateReport(page, SLUG); await quiet(page); await page.goto(channelStage(SLUG, "storage")); const destination = page.getByLabel("destination location"); await expect(destination).toHaveValue("warm"); // No root box while a location is picked — there is nothing to type. await expect(page.getByLabel("destination root")).toHaveCount(0); // Both names are offered, with each drive's state from the server-side probe. await expect(destination).toContainText("Warm"); await expect(destination).toContainText("Cold"); await destination.selectOption("cold"); const target = join(cold, SLUG, "media"); // EXACT. The panel has a second preview since the clip-window card joined it // ("Preview eviction"), and getByRole's name match is a case-insensitive // SUBSTRING by default — so the bare name resolves to two buttons and the // strict-mode violation reads as "the button vanished". await page.getByRole("button", { name: "Preview", exact: true }).click(); const preview = page.getByLabel("relocation preview"); await expect(preview).toBeVisible({ timeout: 15_000 }); // The preview names the target the SERVER resolved from the id. await expect(preview).toContainText(target); const moveButton = page.getByRole("button", { name: "Move media" }); await expect(moveButton).toBeEnabled(); await moveButton.click(); await expect(page.getByLabel("Move media output")).toContainText("Moved", { timeout: 60_000, }); expect( ( await readJson<{ mediaDir?: string }>( `test-transcripts/channels/${SLUG}/config.json`, ) ).mediaDir, ).toBe(target); // AND THE BADGE READS THE NAME. `on Cold`, not sixty columns of absolute // path — while the accessible name keeps the full sentence, which is what // every other assertion in this file matches on. await page.goto("/channels"); const badge = page.getByLabel(/^media location: Media relocated/); await expect(badge).toHaveText("on Cold"); await expect(badge).toHaveAttribute("aria-label", new RegExp(`on Cold$`)); }); // FINISHING AN INTERRUPTED MOVE, rather than throwing away what already copied. // // `relocateChannelMedia` has always resumed a same-direction marker; nothing in // the editor could reach it, so the only offered way out of a killed copy was // Clear marker and a full re-copy — for the omnimirror incident, 131 GB already // correctly on the far side. // // The state is BUILT ON DISK rather than produced by killing a real run: a // marker at phase "copy" plus a partial copy at the target is exactly what a // killed rsync leaves, and it is three filesystem calls instead of a job that // has to be interrupted at the right instant. What is under test is the resume, // not the interruption. test("Resume move finishes an interrupted move and clears its marker", async ({ page, }, testInfo) => { test.setTimeout(90_000); await resetData("one-youtube-channel-with-data"); const root = testInfo.outputPath("resume-root"); const target = join(root, SLUG, "media"); // The channel was tiered in place by the move's preflight before it was // killed: its two big files in `media/`, linked from `data/`. await tierByHand(mediaLink(), { "audio.mp3": AUDIO, "transcript.live_chat.json": "[]", }); // The partial copy: the video dir is there with ONE of its two files. await mkdir(join(target, VIDEO), { recursive: true }); await copyFile( join(mediaLink(), VIDEO, "audio.mp3"), join(target, VIDEO, "audio.mp3"), ); // AND A STALE SCRATCH DIR the source no longer has — the 2026-10-01 case: a // transcriber's `.audio.mp3.parakeet/` copied mid-transcription, then deleted // from the source when the transcription finished. The resume used to copy // everything else and refuse on the counts, every time; its mirror pass // (release 16 slice RM) removes it from the copy. const scratch = join(target, VIDEO, ".audio.mp3.parakeet"); await mkdir(scratch, { recursive: true }); await writeFile(join(scratch, "meta.json"), "{}"); await writeFile(join(scratch, "win-0000.json"), "[]"); await writeFile( resolvePath(`test-transcripts/channels/${SLUG}/.relocating.json`), JSON.stringify( { target, direction: "out", startedAt: new Date().toISOString(), phase: "copy", scope: "media", }, null, 2, ), ); // Same wait the other cases make: the Resume action refuses while the channel // has a queued or running job, and resetData's cache invalidation can leave // one behind for a moment. await quiet(page); await page.goto(channelStage(SLUG, "storage")); // In transition, with both escapes offered and neither move available. await expect(page.getByLabel(/^media location: Media moving/)).toBeVisible(); await expect( page.getByRole("button", { name: "Clear marker" }), ).toBeVisible(); const resume = page.getByRole("button", { name: "Resume move" }); await expect(resume).toBeEnabled(); await resume.click(); // THE CONTROLLER SAYS SO ITSELF. The root came from the marker's target, not // from any form on the page, and the job's summary line is where "this was a // continuation" is stated. await expect(page.getByLabel("Resume move output")).toContainText( "resumed an interrupted move", { timeout: 60_000 }, ); // Finished like any other move: link, config, no marker. expect( await pathExists(`test-transcripts/channels/${SLUG}/.relocating.json`), ).toBe(false); expect((await lstat(mediaLink())).isSymbolicLink()).toBe(true); expect( ( await readJson<{ mediaDir?: string }>( `test-transcripts/channels/${SLUG}/config.json`, ) ).mediaDir, ).toBe(target); // And the bytes are readable through the same URLs as ever. const file = await page.request.get( `${baseUrl}/api/channels/${SLUG}/videos/${VIDEO}/files/transcript.en.vtt`, ); expect(file.status()).toBe(200); const audio = await page.request.get( `${baseUrl}/api/channels/${SLUG}/videos/${VIDEO}/files/audio.mp3`, ); expect(audio.status()).toBe(200); // The stale scratch dir is gone from the copy, and the copy is the source's // two media files and nothing else. expect(await existsAbs(scratch)).toBe(false); expect((await readdir(join(target, VIDEO))).sort()).toEqual([ "audio.mp3", "transcript.live_chat.json", ]); }); // --------------------------------------------------------------------------- // Release 16 slice RM — a move holds the channel's writers and mirrors its copy // --------------------------------------------------------------------------- const OPS_AUTH = { authorization: "Bearer test-worker-token" }; async function existsAbs(p: string): Promise { return stat(p).then( () => true, () => false, ); } // The inspect memo (5 s) and the registry, dropped the way resetData drops // them: a marker planted by hand after a page has rendered is otherwise read // as "in place" for up to five seconds. async function forgetCaches(): Promise { await fetch(`${baseUrl}/api/test/invalidate-cache`).catch(() => {}); } async function jobLog( page: Page, id: string, ): Promise<{ status: string; content: string }> { const res = await page.request.get(`${baseUrl}/api/jobs/${id}/log`); return (await res.json()) as { status: string; content: string }; } // THE 2026-09-30 SHAPE. A move asked for while the channel is quiet waits in // the relocation queue behind another; while it waits, a transcription starts // on the channel. The move used to start its copy over it. Now its first step // asks again and refuses, naming the job — and touches nothing. // // The holder is /api/test/stuck-job on the relocation queue, released after // 4 s; the writer is a fake running "Transcribe all" on this channel, on a // video (`?slug=&task=`). The move goes in through the ops API so the two // enqueues are milliseconds apart, well inside the holder's 4 s. test("a move that starts while a job writes into the channel refuses, naming the job", async ({ page, }, testInfo) => { test.setTimeout(90_000); await resetData("one-youtube-channel-with-data"); await generateReport(page, SLUG); await quiet(page); const root = testInfo.outputPath("busy-root"); await mkdir(root, { recursive: true }); const holder = await page.request.get( `${baseUrl}/api/test/stuck-job?queue=relocate&releaseAfterMs=4000`, ); expect(holder.ok()).toBe(true); const queued = await page.request.post(`${baseUrl}/api/ops/relocate`, { headers: OPS_AUTH, data: { slugs: [SLUG], root }, }); expect(queued.ok()).toBe(true); const { jobIds } = (await queued.json()) as { jobIds: string[] }; expect(jobIds).toHaveLength(1); const moveId = jobIds[0]; const writer = await page.request.get( `${baseUrl}/api/test/stuck-job?queue=${encodeURIComponent("rm-writer")}&slug=${SLUG}&task=${VIDEO}`, ); expect(writer.ok()).toBe(true); const writerId = ((await writer.json()) as { id: string }).id; await expect .poll(async () => (await jobLog(page, moveId)).status, { timeout: 30_000 }) .toBe("failed"); expect((await jobLog(page, moveId)).content).toContain( `Cannot move the media of "${SLUG}" now: a transcription of ${VIDEO} is ` + `running (Transcribe all, job ${writerId}) — wait for it or cancel it. ` + `Nothing has been touched.`, ); // NOTHING WAS TOUCHED: no marker, no copy, no `media` made, the text a real // directory. expect( await pathExists(`test-transcripts/channels/${SLUG}/.relocating.json`), ).toBe(false); expect(await existsAbs(join(root, SLUG))).toBe(false); expect(await existsAbs(mediaLink())).toBe(false); expect((await lstat(dataDir())).isDirectory()).toBe(true); // And the panel says it before anyone clicks: the move is blocked, and the // sentence names the writer. `.first()`: the clip-window card on the same // panel repeats the panel's blocked reason. await page.goto(channelStage(SLUG, "storage")); await expect( page.getByText( new RegExp( `before moving its media\\. Now: a transcription of ${VIDEO} is running`, ), ).first(), ).toBeVisible(); }); // A CANCEL IS A REQUEST, NOT AN EXIT (the review's L2). A job asked to stop // keeps writing until its function returns — a transcriber finishes its // window — and the registry stamps its end only then. Until that moment it is // a writer "stopping", and the move waits for it; after it, the move is free. // // The writer is the stuck-job route's fake Transcribe all on this channel's // video, cancelled from /jobs; its function "returns" when the spec releases // it through the route, once the refusal is seen (it used to be a 25 s timer). test("a job cancelled but still stopping holds the move until it has stopped", async ({ page, }, testInfo) => { test.setTimeout(120_000); await resetData("one-youtube-channel-with-data"); await generateReport(page, SLUG); await quiet(page); const root = testInfo.outputPath("stopping-root"); await mkdir(root, { recursive: true }); const writer = await page.request.get( `${baseUrl}/api/test/stuck-job?queue=${encodeURIComponent("rm-stopping")}&slug=${SLUG}&task=${VIDEO}`, ); expect(writer.ok()).toBe(true); const writerId = ((await writer.json()) as { id: string }).id; await page.goto("/jobs"); const row = page.locator(`tr[data-job-id="${writerId}"]`).first(); const cancel = row.getByRole("button", { name: /^Cancel$/ }); await expect(async () => { if (await cancel.isVisible()) await cancel.click({ timeout: 2_000 }); await expect(row).toContainText("cancelled", { timeout: 2_000 }); }).toPass({ timeout: 20_000 }); // Cancelled, and still stopping: the panel names it, and Preview refuses. await page.goto(channelStage(SLUG, "storage")); await expect( page .getByText( `Now: a transcription of ${VIDEO} is stopping (Transcribe all, job ${writerId}).`, ) .first(), ).toBeVisible(); await page.getByLabel("destination root").fill(root); await page.getByRole("button", { name: "Preview", exact: true }).click(); await expect( page.getByRole("alert").filter({ hasText: /is stopping/ }), ).toContainText( `a transcription of ${VIDEO} is stopping (Transcribe all, job ${writerId}) — wait for it to stop`, { timeout: 15_000 }, ); // Its function returns now. const released = await page.request.get(`${baseUrl}/api/test/stuck-job?release=${writerId}`); expect(((await released.json()) as { released: boolean }).released).toBe(true); // Once it has stopped, nothing holds the move. await expect(async () => { await page.goto(channelStage(SLUG, "storage")); await expect(page.getByText(/is stopping/)).toHaveCount(0); }).toPass({ timeout: 45_000 }); await page.getByLabel("destination root").fill(root); await page.getByRole("button", { name: "Preview", exact: true }).click(); await expect(page.getByLabel("relocation preview")).toBeVisible({ timeout: 15_000, }); }); // THE HOLD, WHERE THE OPERATOR LOOKS. A marker stands — a move running, or one // interrupted — so every lane skips the channel and its media jobs refuse; the // rack's tier cell and the channel's Storage panel say so in one sentence, and // both stop saying it when the marker goes (here: Clear marker, the abandon). test("the rack and the Storage panel show the hold while a marker stands, and it lifts with it", async ({ page, }, testInfo) => { test.setTimeout(90_000); await resetData("one-youtube-channel-with-data"); await generateReport(page, SLUG); await quiet(page); const target = join(testInfo.outputPath("hold-root"), SLUG, "media"); await writeFile( resolvePath(`test-transcripts/channels/${SLUG}/.relocating.json`), JSON.stringify({ target, direction: "out", startedAt: new Date().toISOString(), phase: "copy", scope: "media", }), ); await forgetCaches(); await page.goto("/channels"); const chip = page.getByLabel(`media hold for ${SLUG}`); await expect(chip).toContainText("media held"); await expect(chip).toContainText( "held: its media is moving (a move is in progress or was interrupted)", ); await page.goto(channelStage(SLUG, "storage")); const hold = page.getByLabel("media hold", { exact: true }); await expect(hold).toContainText( "Held: its media is moving (a move is in progress or was interrupted)", ); await expect(hold).toContainText( "when the move completes, or its marker is cleared below", ); // A MEDIA move holds the media only (release 17). await expect(hold).toContainText("Its text stays readable"); // Abandoned: the marker goes, and the hold with it. The click is retried // until the marker is gone: one that lands before hydration does nothing. const clear = page.getByRole("button", { name: "Clear marker" }); await expect(async () => { if (await clear.isVisible()) await clear.click({ timeout: 2_000 }); expect( await pathExists(`test-transcripts/channels/${SLUG}/.relocating.json`), ).toBe(false); }).toPass({ timeout: 20_000 }); await forgetCaches(); await page.goto(channelStage(SLUG, "storage")); await expect(page.getByLabel("media hold", { exact: true })).toHaveCount(0); await page.goto("/channels"); await expect(page.getByLabel(`media hold for ${SLUG}`)).toHaveCount(0); }); // RECONCILE AND RESUME — the remediation for a move whose verification failed // (the ruling's last bullet). The destination copy holds an extra the source // does not (a transcriber's scratch file) and a file that differs from the // source's: the job lists them by kind, makes the copy match the source, and // finishes the move. The operator deletes nothing by hand. test("Reconcile and resume settles an extra and a changed file on the destination", async ({ page, }, testInfo) => { test.setTimeout(90_000); await resetData("one-youtube-channel-with-data"); const root = testInfo.outputPath("reconcile-root"); const target = join(root, SLUG, "media"); // Tiered in place by the interrupted move's preflight. await tierByHand(mediaLink(), { "audio.mp3": AUDIO, "transcript.live_chat.json": '[{"fresh":true}]', }); await mkdir(join(target, VIDEO), { recursive: true }); // The full copy, timestamps and all, as a copy pass leaves it… for (const name of ["audio.mp3", "transcript.live_chat.json"]) { const src = join(mediaLink(), VIDEO, name); await copyFile(src, join(target, VIDEO, name)); const { atime, mtime } = await stat(src); await utimes(join(target, VIDEO, name), atime, mtime); } // …then an extra, and a changed file. const scratch = join(target, VIDEO, ".audio.mp3.parakeet"); await mkdir(scratch, { recursive: true }); await writeFile(join(scratch, "meta.json"), "{}"); // A different LENGTH as well as different words: rsync's quick check // compares size and a one-second mtime, and both files were written within // the same second. await writeFile( join(target, VIDEO, "transcript.live_chat.json"), '[{"stale":true,"from":"an older copy"}]', ); await writeFile( resolvePath(`test-transcripts/channels/${SLUG}/.relocating.json`), JSON.stringify({ target, direction: "out", startedAt: new Date().toISOString(), phase: "copy", scope: "media", }), ); await quiet(page); await page.goto(channelStage(SLUG, "storage")); const reconcile = page.getByRole("button", { name: "Reconcile and resume" }); await expect(reconcile).toBeEnabled(); await reconcile.click(); const out = page.getByLabel("Reconcile and resume output"); await expect(out).toContainText("reconciled and resumed an interrupted move", { timeout: 60_000, }); // WHAT IT FOUND, BY KIND, before it changed anything. await expect(out).toContainText( /Reconciling: the destination copy differs from the source — \d+ extra on the destination \([^)]*\.audio\.mp3\.parakeet/, ); await expect(out).toContainText( /changed \([^)]*transcript\.live_chat\.json/, ); // Finished: link, config, no marker; the copy is the source's two files. expect( await pathExists(`test-transcripts/channels/${SLUG}/.relocating.json`), ).toBe(false); expect((await lstat(mediaLink())).isSymbolicLink()).toBe(true); expect(await existsAbs(scratch)).toBe(false); expect((await readdir(join(target, VIDEO))).sort()).toEqual([ "audio.mp3", "transcript.live_chat.json", ]); const file = await page.request.get( `${baseUrl}/api/channels/${SLUG}/videos/${VIDEO}/files/transcript.live_chat.json`, ); expect(file.status()).toBe(200); expect(await file.text()).not.toContain("stale"); }); // A MOVE MUST NOT MATERIALISE THE MOUNTPOINT. // // `mkdir(target, {recursive: true})` builds every missing level, so a move at a // location whose drive is not mounted used to create `//data` on // the ROOT FILESYSTEM, rsync into it, and rewrite the channel's symlink — the // media silently moved onto the disk the move existed to free, while the real // copy sat on the unplugged platter and `` was now non-empty, so the // platter could no longer mount there. // // The preview and the job ask the SAME question (relocationRootProblem calls // the guard), so the refusal the operator reads before committing is the one // the job would have given. test("a move to an unmounted root refuses before it creates anything", async ({ page, }, testInfo) => { test.setTimeout(90_000); await resetData("one-youtube-channel-with-data"); await generateReport(page, SLUG); await quiet(page); // Deliberately NOT created. An unmounted mountpoint whose parent does not // exist either is the sharpest version: mkdir -p would have made both. const root = join(testInfo.outputPath("never-mounted"), "platter"); await writeSettings({ adminTitle: "Test Admin", minFreeDiskGB: 0, storage: { locations: [{ id: "cold", label: "Cold", root, autoRepoint: false }], defaultLocationId: "cold", }, }); await page.goto(channelStage(SLUG, "storage")); // BY NAME, not as a typed root: with a location configured the panel offers // the select and hides the free-text box (`custom` is false), which is the // shape an operator actually meets — and it is the shape that carries the // location id into the refusal. await page.getByLabel("destination location").selectOption("cold"); // EXACT. The panel has a second preview since the clip-window card joined it // ("Preview eviction"), and getByRole's name match is a case-insensitive // SUBSTRING by default — so the bare name resolves to two buttons and the // strict-mode violation reads as "the button vanished". await page.getByRole("button", { name: "Preview", exact: true }).click(); // FILTERED, because Next ships its own `role="alert"` route announcer on // every page and a bare getByRole("alert") is a strict-mode violation that // reads as "the message never appeared". const alert = page .getByRole("alert") .filter({ hasText: /destination root/ }); await expect(alert).toContainText(/does not exist or is not a directory/, { timeout: 15_000, }); // It names the location, so the operator knows WHICH drive to plug in. await expect(alert).toContainText('location "cold"'); // And the move stays gated: no preview, no Move. await expect(page.getByLabel("relocation preview")).toHaveCount(0); await expect(page.getByRole("button", { name: "Move media" })).toBeDisabled(); // NOTHING WAS CREATED on the way to finding out — that IS the bug. expect(await pathExists(root)).toBe(false); expect(await pathExists(join(root, SLUG))).toBe(false); // The channel is untouched in the corpus — not even tiered: the root is // refused before the preview's tiering runs. expect((await lstat(dataDir())).isDirectory()).toBe(true); expect(await existsAbs(mediaLink())).toBe(false); }); // EVICTING THIS CHANNEL'S FETCHED CLIP WINDOWS, from the panel that already // says how many bytes the channel holds. // // The card is the SAME component /storage renders corpus-wide, mounted with a // slug — so the gates, the wording and the by-age caveat are one definition and // cannot drift. What this spec pins is the half that is new: the sweep is // scoped to this channel, and the snapshot figure the panel reads follows the // deletion (the per-channel run carries a `channelSlug`, which is what queues // the regen; a corpus-wide run deliberately queues none). test("the Storage panel evicts this channel's old clip windows", async ({ page, }) => { test.setTimeout(120_000); await resetData("one-youtube-channel-with-data"); await writeSettings({ minFreeDiskGB: 0 }); const clipsDir = resolvePath( `test-transcripts/channels/${SLUG}/data/${VIDEO}/clips`, ); await mkdir(clipsDir, { recursive: true }); const old = join(clipsDir, "10.00-40.00.mp4"); const recent = join(clipsDir, "60.00-70.00.mp4"); for (const [file, ageDays, size] of [ [old, 90, 4096], [recent, 1, 1024], ] as const) { await writeFile(file, "x".repeat(size)); await writeFile( file.replace(/\.mp4$/, ".json"), JSON.stringify({ requestedBy: "umtool", reason: "e2e" }), ); const when = new Date(Date.now() - ageDays * 24 * 60 * 60 * 1000); await utimes(file, when, when); } // The report is what measures `totalClipsBytes`, so it has to run AFTER the // windows exist — and be waited out, because the card is gated on the same // busy reason the moves are. await generateReport(page, SLUG); await quiet(page); const clipsBytesOf = async () => ( await readJson<{ totalClipsBytes?: number }>( `test-transcripts/channels/${SLUG}/snapshot.json`, ) ).totalClipsBytes ?? 0; const before = await clipsBytesOf(); expect(before).toBeGreaterThanOrEqual(5120); await page.goto(channelStage(SLUG, "storage")); // SCOPED, and the card says which scope it is in. "across the corpus" here // would be a lie about what the button does. await expect(page.getByLabel("clip windows bytes")).toContainText( "in this channel", ); await expect(page.getByLabel("clip eviction caveat")).toContainText( "by age only", ); // --- the dry run lists the window and deletes nothing ------------------- await page.getByLabel("clip eviction age").selectOption("30"); await expect( page.getByRole("button", { name: "Evict fetched windows" }), ).toBeDisabled(); await page.getByRole("button", { name: "Preview eviction" }).click(); await expect(page.getByLabel("Preview eviction output")).toContainText( /Would evict 1 window/, { timeout: 60_000 }, ); expect(await pathExists(old)).toBe(true); // --- and the real run removes it ---------------------------------------- const evict = page.getByRole("button", { name: "Evict fetched windows" }); await expect(evict).toBeEnabled(); await evict.click(); await expect(page.getByLabel("Evict fetched windows output")).toContainText( /Evicted 1 window/, { timeout: 60_000 }, ); expect(await pathExists(old)).toBe(false); expect(await pathExists(recent)).toBe(true); // THE PANEL'S OWN NUMBER FOLLOWS. A one-channel run carries `channelSlug`, // so the stale report is regenerated behind it; polled because that regen is // a queued job, not part of the eviction. await expect.poll(clipsBytesOf, { timeout: 60_000 }).toBeLessThan(before); }); // SYNC ALL SKIPS A CHANNEL WHOSE DRIVE IS NOT THERE, and says which. // // This is the sharpest case for the guard in AGENTS.md: every enumerator of // `data/` swallows ENOENT as "this channel has no videos", so a sync of a // channel on an unmounted platter reads the whole back catalogue as // undownloaded — and hands the download lane an instruction to re-fetch it all // onto the volume that was too full to hold it in the first place. // // `inspectChannelMedia` is the one module that can tell "nothing downloaded" // from "drive not mounted". A skip is not a failure: the bulk readout reports // both numbers and names the channel with the inspector's own sentence. test("Sync all skips a channel whose media drive is not mounted", async ({ page, }, testInfo) => { test.setTimeout(90_000); await resetData("one-youtube-channel-with-data"); await writeSettings({ minFreeDiskGB: 0 }); // A relocation whose drive went away: the `media` link and the config agree // with each other and with nothing on disk. Deliberately NOT created — an // unmounted mountpoint whose parent is missing too is the honest version. // The text stays in `data/` on the corpus disk, as it always does now. const target = join(testInfo.outputPath("never-mounted"), SLUG, "media"); await symlink(target, mediaLink()); await writeChannelConfig(SLUG, { mediaDir: target }); await page.goto("/channels"); await page.getByRole("button", { name: "sync every channel" }).click(); const result = page.getByLabel("sync all result"); await expect(result).toContainText(/Queued 0 . skipped 1/, { timeout: 15_000, }); // THE REASON NAMES THE DRIVE, which is the only thing the operator can act // on — "skipped 1" alone would read as a bug in the sweep. await expect(result).toHaveAttribute( "title", new RegExp(`${SLUG}: media unreachable:.*drive not mounted`), ); }); // THE RETIRED LAYOUT (release 17). A channel whose whole `data/` was moved // before the media tier — an absolute `data` link and `config.dataDir`, the old // mover's work — is `legacy`: its text is on the far drive too, so it is held // by both guards until `archilyzer storage migrate-tier` brings the text home. // The row says "Media layout retired", the Storage panel refuses to move it // with the same sentence, and a media job refused on it names the command. test("a legacy channel reads Media layout retired, and a media job's refusal names migrate-tier", async ({ page, }, testInfo) => { test.setTimeout(90_000); await resetData("one-youtube-channel-with-data"); await writeSettings({ minFreeDiskGB: 0 }); const target = join(testInfo.outputPath("legacy-root"), SLUG, "data"); await mkdir(join(target, VIDEO), { recursive: true }); for (const name of await readdir(join(dataDir(), VIDEO))) { await copyFile(join(dataDir(), VIDEO, name), join(target, VIDEO, name)); } await rm(dataDir(), { recursive: true, force: true }); await symlink(target, dataDir()); await writeChannelConfig(SLUG, { dataDir: target }); await forgetCaches(); await page.goto("/channels"); await expect( page.getByLabel(/^media location: Media layout retired/), ).toBeVisible(); await page.goto(channelStage(SLUG, "storage")); await expect(page.getByLabel("move back refused")).toContainText( `run archilyzer storage migrate-tier ${SLUG}`, ); await page.goto("/channels"); await page.getByRole("button", { name: "sync every channel" }).click(); const result = page.getByLabel("sync all result"); await expect(result).toContainText(/Queued 0 . skipped 1/, { timeout: 15_000, }); await expect(result).toHaveAttribute( "title", new RegExp(`${SLUG}: .*archilyzer storage migrate-tier ${SLUG}`), ); }); // THE TEXT OUTLIVES ITS MEDIA DRIVE (release 17): a digest reads and writes // the text tier, so it runs on a channel whose media is relocated onto a drive // that is not there — the job is asked the text guard, not the media one, and // the digest lane makes the same decision (autoRunner.ts isChannelHeldForLane, // pinned in its unit tests). The ollama stub answers, as in digest.spec.ts. test("a digest runs on a channel whose media drive is away", async ({ page, }, testInfo) => { test.setTimeout(120_000); const CHANNEL = "digest-channel"; const DIGEST_VIDEO = "digestvid0001"; await resetData(null); await writeSettings({ adminTitle: "Test Admin", maxTranscriptPageBytes: 8388608, sleepBetweenDownloadsSeconds: 0, minFreeDiskGB: 0, digest: { localAppId: "ollama-direct", remoteAppId: "claude-code", sections: ["chapters"], }, }); // Relocated media, renamed away: the `media` link dangles; `data/` is real. const target = join(testInfo.outputPath("away-root"), CHANNEL, "media"); await writeChannelConfig(CHANNEL, { mediaDir: target }); await writeDigestVideo({ channelSlug: CHANNEL, videoId: DIGEST_VIDEO }); await symlink( target, resolvePath(`test-transcripts/channels/${CHANNEL}/media`), ); await symlink( join("..", "..", "media", DIGEST_VIDEO, "audio.mp3"), resolvePath( `test-transcripts/channels/${CHANNEL}/data/${DIGEST_VIDEO}/audio.mp3`, ), ); await forgetCaches(); await generateReport(page, CHANNEL); await page.goto("/channels"); await expect(page.getByLabel(`media hold for ${CHANNEL}`)).toContainText( "media held", ); await page.goto(channelStage(CHANNEL, "digest")); await page.getByRole("button", { name: "Digest channel" }).click(); await expect(page.getByLabel("Digest channel output")).toContainText( "Digest batch:", { timeout: 60_000 }, ); expect( await pathExists( `test-transcripts/channels/${CHANNEL}/data/${DIGEST_VIDEO}/ai-digest.json`, ), ).toBe(true); });