Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit a064ef7b07f218a2902bfef7bad7ebd1f2acaa86
parent b7688f22158827ee9ebc2d1fe9af48eedd10385a
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Fri,  2 Oct 2026 00:56:58 -0400

editor: e2e rebased on the media tier

channel-storage: a classic channel's audio and raw live chat are tiered by the
preview and moved; data/<id>/audio.mp3 and transcript.live_chat.json are links;
the videos list and the file route serve the transcript and stream the audio
(200, a range 206) through the link; with the media root renamed away the
video page still reads, the audio is 503 with retry-after, the rack shows
media held and the hold says the text stays readable; Move back leaves a real
media/. New: a legacy channel reads Media layout retired and Sync all's skip
names migrate-tier; a digest runs on a channel whose media drive is away.
storage-locations and channels-storage-columns build the relocated media
tier (media link, mediaDir) instead of the retired data link.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Diffstat:
Meditor/e2e/channel-storage.spec.ts | 395+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
Meditor/e2e/channels-storage-columns.spec.ts | 53+++++++++++++++++++++++++++++++++++++++++------------
Meditor/e2e/storage-locations.spec.ts | 52+++++++++++++++++++++++++++++++++-------------------
3 files changed, 390 insertions(+), 110 deletions(-)

diff --git a/editor/e2e/channel-storage.spec.ts b/editor/e2e/channel-storage.spec.ts @@ -3,6 +3,8 @@ import { lstat, mkdir, readdir, + readlink, + rename, rm, stat, symlink, @@ -21,11 +23,18 @@ import { 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/<slug>/media`, the big +// files (the audio, the raw live chat), each reached from `data/<id>/<name>` +// by a relative link. The text never leaves `data/` on the corpus disk. A +// classic channel (its audio still a real file in `data/<id>/`) 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 — @@ -52,6 +61,28 @@ 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 `<mediaDir>/<id>/<name>` and the +// RELATIVE link `data/<id>/<name> -> ../../media/<id>/<name>` — 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<string, string>, +): Promise<void> { + 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. @@ -80,17 +111,26 @@ async function quiet(page: Page): Promise<void> { test("relocate a channel's media to another root, and move it back", async ({ page, }, testInfo) => { - test.setTimeout(90_000); + 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, "data"); + const target = join(root, SLUG, "media"); + // A CLASSIC CHANNEL: its audio and its raw live chat are real files in + // `data/<id>/`, 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")); - await expect(page.getByLabel("media path")).toHaveText(dataDir()); + // 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); @@ -114,8 +154,15 @@ test("relocate a channel's media to another root, and move it back", async ({ await page.getByRole("button", { name: "Preview", exact: true }).click(); const preview = page.getByLabel("relocation preview"); await expect(preview).toBeVisible({ timeout: 15_000 }); - // The fixture is two files in one video dir. + // 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(); @@ -136,39 +183,59 @@ test("relocate a channel's media to another root, and move it back", async ({ /Copying… .* · \d+ % · /, ); - // data/ is a symlink now, and config.json records the target — written only - // by the job, on success, after the copy verified. - expect((await lstat(dataDir())).isSymbolicLink()).toBe(true); + // `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 readJson<{ dataDir?: string }>( - `test-transcripts/channels/${SLUG}/config.json`, - ) - ).dataDir, - ).toBe(target); + (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 - // channel on the volume the move exists to free, and no marker. + // 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("data."))).toEqual([]); + 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, and resolves through the link. - await page.goto(channelVideos(SLUG)); + // 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/<slug>/data/<id>/ - // and has no idea any of this happened. - const file = await page.request.get( - `${baseUrl}/api/channels/${SLUG}/videos/${VIDEO}/files/transcript.en.vtt`, - ); + // 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"); @@ -176,6 +243,34 @@ test("relocate a channel's media to another root, and move it back", async ({ 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, @@ -200,19 +295,24 @@ test("relocate a channel's media to another root, and move it back", async ({ { timeout: 60_000 }, ); - // A real directory again, the config field gone, and the target reclaimed. - expect((await lstat(dataDir())).isDirectory()).toBe(true); + // `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<{ dataDir?: string }>( + await readJson<{ mediaDir?: string }>( `test-transcripts/channels/${SLUG}/config.json`, ) - ).dataDir, + ).mediaDir, ).toBe(undefined); - const back = await page.request.get( - `${baseUrl}/api/channels/${SLUG}/videos/${VIDEO}/files/transcript.en.vtt`, - ); + 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 ({ @@ -223,7 +323,7 @@ test("the Configure form shows the media location read-only", async ({ // 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 (data/)"); + 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. @@ -233,8 +333,8 @@ test("the Configure form shows the media location read-only", async ({ // 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 `data` symlink plus -// `config.dataDir`, which is precisely what a finished move leaves behind — +// 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 @@ -280,16 +380,24 @@ test("the /channels bulk move queues one job per channel and skips the rest", as }); const PRE = "pre-moved"; - const preTarget = join(root, PRE, "data"); - await mkdir(join(preTarget, "20240102_pre1234567"), { recursive: true }); + 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(preTarget, "20240102_pre1234567", "transcript.en.vtt"), + join(preData, "transcript.en.vtt"), "WEBVTT\n\n00:00.000 --> 00:01.000\nhello\n", ); - await writeChannelConfig(PRE, { dataDir: preTarget }); + await symlink( + join("..", "..", "media", preVideo, "audio.mp3"), + join(preData, "audio.mp3"), + ); + await writeChannelConfig(PRE, { mediaDir: preTarget }); await symlink( preTarget, - resolvePath(`test-transcripts/channels/${PRE}/data`), + resolvePath(`test-transcripts/channels/${PRE}/media`), ); await page.goto("/channels"); @@ -341,30 +449,31 @@ test("the /channels bulk move queues one job per channel and skips the rest", as 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, `data/` is - // a link, and the videos list still lists the video through it. + // 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<{ dataDir?: string }>( + await readJson<{ mediaDir?: string }>( `test-transcripts/channels/${SLUG}/config.json`, ) - ).dataDir, - ).toBe(join(root, SLUG, "data")); - expect((await lstat(dataDir())).isSymbolicLink()).toBe(true); + ).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<{ dataDir?: string }>( + await readJson<{ mediaDir?: string }>( `test-transcripts/channels/${PRE}/config.json`, ) - ).dataDir, + ).mediaDir, ).toBe(preTarget); expect( ( - await lstat(resolvePath(`test-transcripts/channels/${PRE}/data`)) + await lstat(resolvePath(`test-transcripts/channels/${PRE}/media`)) ).isSymbolicLink(), ).toBe(true); expect( @@ -514,7 +623,7 @@ test("the Storage panel moves to a location picked by name", async ({ await expect(destination).toContainText("Cold"); await destination.selectOption("cold"); - const target = join(cold, SLUG, "data"); + 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 @@ -533,10 +642,10 @@ test("the Storage panel moves to a location picked by name", async ({ }); expect( ( - await readJson<{ dataDir?: string }>( + await readJson<{ mediaDir?: string }>( `test-transcripts/channels/${SLUG}/config.json`, ) - ).dataDir, + ).mediaDir, ).toBe(target); // AND THE BADGE READS THE NAME. `on Cold`, not sixty columns of absolute @@ -566,14 +675,18 @@ test("Resume move finishes an interrupted move and clears its marker", async ({ test.setTimeout(90_000); await resetData("one-youtube-channel-with-data"); const root = testInfo.outputPath("resume-root"); - const target = join(root, SLUG, "data"); + 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( - resolvePath( - `test-transcripts/channels/${SLUG}/data/${VIDEO}/transcript.en.vtt`, - ), - join(target, VIDEO, "transcript.en.vtt"), + 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 @@ -592,6 +705,7 @@ test("Resume move finishes an interrupted move and clears its marker", async ({ direction: "out", startedAt: new Date().toISOString(), phase: "copy", + scope: "media", }, null, 2, @@ -624,25 +738,29 @@ test("Resume move finishes an interrupted move and clears its marker", async ({ expect( await pathExists(`test-transcripts/channels/${SLUG}/.relocating.json`), ).toBe(false); - expect((await lstat(dataDir())).isSymbolicLink()).toBe(true); + expect((await lstat(mediaLink())).isSymbolicLink()).toBe(true); expect( ( - await readJson<{ dataDir?: string }>( + await readJson<{ mediaDir?: string }>( `test-transcripts/channels/${SLUG}/config.json`, ) - ).dataDir, + ).mediaDir, ).toBe(target); - // And the bytes are readable through the same URL as ever. + // 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 files and nothing else. + // two media files and nothing else. expect(await existsAbs(scratch)).toBe(false); expect((await readdir(join(target, VIDEO))).sort()).toEqual([ - "metadata.info.json", - "transcript.en.vtt", + "audio.mp3", + "transcript.live_chat.json", ]); }); @@ -721,11 +839,13 @@ test("a move that starts while a job writes into the channel refuses, naming the `Nothing has been touched.`, ); - // NOTHING WAS TOUCHED: no marker, no copy, the media a real directory. + // 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 @@ -814,7 +934,7 @@ test("the rack and the Storage panel show the hold while a marker stands, and it await resetData("one-youtube-channel-with-data"); await generateReport(page, SLUG); await quiet(page); - const target = join(testInfo.outputPath("hold-root"), SLUG, "data"); + const target = join(testInfo.outputPath("hold-root"), SLUG, "media"); await writeFile( resolvePath(`test-transcripts/channels/${SLUG}/.relocating.json`), JSON.stringify({ @@ -822,6 +942,7 @@ test("the rack and the Storage panel show the hold while a marker stands, and it direction: "out", startedAt: new Date().toISOString(), phase: "copy", + scope: "media", }), ); await forgetCaches(); @@ -841,6 +962,8 @@ test("the rack and the Storage panel show the hold while a marker stands, and it 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. @@ -869,11 +992,16 @@ test("Reconcile and resume settles an extra and a changed file on the destinatio test.setTimeout(90_000); await resetData("one-youtube-channel-with-data"); const root = testInfo.outputPath("reconcile-root"); - const target = join(root, SLUG, "data"); + 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 ["metadata.info.json", "transcript.en.vtt"]) { - const src = join(dataDir(), VIDEO, name); + 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); @@ -882,7 +1010,10 @@ test("Reconcile and resume settles an extra and a changed file on the destinatio const scratch = join(target, VIDEO, ".audio.mp3.parakeet"); await mkdir(scratch, { recursive: true }); await writeFile(join(scratch, "meta.json"), "{}"); - await writeFile(join(target, VIDEO, "transcript.en.vtt"), "WEBVTT\n\nstale\n"); + await writeFile( + join(target, VIDEO, "transcript.live_chat.json"), + '[{"stale":true}]', + ); await writeFile( resolvePath(`test-transcripts/channels/${SLUG}/.relocating.json`), JSON.stringify({ @@ -890,6 +1021,7 @@ test("Reconcile and resume settles an extra and a changed file on the destinatio direction: "out", startedAt: new Date().toISOString(), phase: "copy", + scope: "media", }), ); @@ -907,20 +1039,22 @@ test("Reconcile and resume settles an extra and a changed file on the destinatio 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\.en\.vtt/); + 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(dataDir())).isSymbolicLink()).toBe(true); + expect((await lstat(mediaLink())).isSymbolicLink()).toBe(true); expect(await existsAbs(scratch)).toBe(false); expect((await readdir(join(target, VIDEO))).sort()).toEqual([ - "metadata.info.json", - "transcript.en.vtt", + "audio.mp3", + "transcript.live_chat.json", ]); const file = await page.request.get( - `${baseUrl}/api/channels/${SLUG}/videos/${VIDEO}/files/transcript.en.vtt`, + `${baseUrl}/api/channels/${SLUG}/videos/${VIDEO}/files/transcript.live_chat.json`, ); expect(file.status()).toBe(200); expect(await file.text()).not.toContain("stale"); @@ -986,8 +1120,10 @@ test("a move to an unmounted root refuses before it creates anything", async ({ // 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 media is still a real directory in the corpus, unmoved. + // 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 @@ -1094,13 +1230,13 @@ test("Sync all skips a channel whose media drive is not mounted", async ({ test.setTimeout(90_000); await resetData("one-youtube-channel-with-data"); await writeSettings({ minFreeDiskGB: 0 }); - // A relocation whose drive went away: the 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. - const target = join(testInfo.outputPath("never-mounted"), SLUG, "data"); - await rm(dataDir(), { recursive: true, force: true }); - await symlink(target, dataDir()); - await writeChannelConfig(SLUG, { dataDir: target }); + // 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(); @@ -1115,3 +1251,104 @@ test("Sync all skips a channel whose media drive is not mounted", async ({ 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); +}); diff --git a/editor/e2e/channels-storage-columns.spec.ts b/editor/e2e/channels-storage-columns.spec.ts @@ -1,4 +1,4 @@ -import { mkdir, rename, symlink } from "node:fs/promises"; +import { mkdir, readdir, rename, symlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { test, expect } from "@playwright/test"; import { baseUrl } from "./baseUrl"; @@ -32,21 +32,47 @@ import { // 549d). const SLUG = "test-youtube"; +const VIDEO = "20240101_test1234567"; + +// THE SIZE COLUMN IS THE MEDIA TIER (release 17): the audio and the raw live +// chat, what a move carries. The fixture has none, so one is written. +async function giveAudio(slug: string, id: string, bytes = 4096) { + await writeFile( + resolvePath(`test-transcripts/channels/${slug}/data/${id}/audio.mp3`), + "x".repeat(bytes), + ); +} -// Put a channel's media on `root` the way a finished relocation leaves it: the -// real directory on the "drive", an absolute symlink at channels/<slug>/data, -// and config.dataDir naming the target. Built directly rather than by running a -// move — what is under test is the COLUMN, and a real rsync buys nothing here. +// Put a channel's media tier on `root` the way a finished relocation leaves it +// (release 17): each audio file moved to `<root>/<slug>/media/<id>/` with its +// relative link left in `data/<id>/`, an absolute symlink at +// channels/<slug>/media, and config.mediaDir naming the target. Built directly +// rather than by running a move — what is under test is the COLUMN, and a real +// rsync buys nothing here. async function relocateOnDisk(slug: string, root: string): Promise<string> { const channelDir = resolvePath(`test-transcripts/channels/${slug}`); - const target = join(root, slug, "data"); - await mkdir(join(root, slug), { recursive: true }); - await rename(join(channelDir, "data"), target); - await symlink(target, join(channelDir, "data")); + const target = join(root, slug, "media"); + await mkdir(target, { recursive: true }); + for (const id of await readdir(join(channelDir, "data"))) { + const names = await readdir(join(channelDir, "data", id)).catch( + () => [] as string[], + ); + if (!names.includes("audio.mp3")) continue; + await mkdir(join(target, id), { recursive: true }); + await rename( + join(channelDir, "data", id, "audio.mp3"), + join(target, id, "audio.mp3"), + ); + await symlink( + join("..", "..", "media", id, "audio.mp3"), + join(channelDir, "data", id, "audio.mp3"), + ); + } + await symlink(target, join(channelDir, "media")); const config = await readJson<Record<string, unknown>>( `test-transcripts/channels/${slug}/config.json`, ); - await writeChannelConfig(slug, { ...config, dataDir: target }); + await writeChannelConfig(slug, { ...config, mediaDir: target }); await fetch(`${baseUrl}/api/test/invalidate-cache`).catch(() => {}); return target; } @@ -66,6 +92,7 @@ test("the Location and Size columns say which disk and how much", async ({ defaultLocationId: "cold", }, }); + await giveAudio(SLUG, VIDEO); await generateReport(page, SLUG); await page.goto("/channels"); @@ -73,8 +100,8 @@ test("the Location and Size columns say which disk and how much", async ({ // is on the corpus volume, and that is a fact about it, not an absence. const location = page.getByLabel(`media location for ${SLUG}`); await expect(location).toContainText("Internal"); - // The fixture is two small files, so the figure is not zero and not "—": the - // report measured it. + // The fixture's audio is a small file, so the figure is not zero and not + // "—": the report measured it. const size = page.getByLabel(`media size for ${SLUG}`); await expect(size).not.toHaveText("—"); await expect(size).toContainText(/B|KB|MB/); @@ -131,6 +158,8 @@ test("filter by volume, sort by size, and free up N GB", async ({ resolvePath(`test-transcripts/channels/${SECOND}/data/20240102_second0001`), { recursive: true }, ); + await giveAudio(SLUG, VIDEO); + await giveAudio(SECOND, "20240102_second0001", 1024); await writeSettings({ adminTitle: "Test Admin", minFreeDiskGB: 0, diff --git a/editor/e2e/storage-locations.spec.ts b/editor/e2e/storage-locations.spec.ts @@ -26,8 +26,10 @@ import { // back at a different mountpoint, ONE button makes every channel on it readable // again, and it does so WITHOUT MOVING A BYTE. So the assertions after the // re-point are deliberately not about the re-point — they are `readlink`, -// `config.dataDir` and the transcript route serving the same URL it always did, -// with the media now reached through a different path entirely. +// `config.mediaDir` and the file route serving the same URLs it always did, +// with the media now reached through a different path entirely. (Release 17: +// what lives on a location is a channel's media tier, `channels/<slug>/media`; +// its text never leaves `data/` on the corpus disk.) // // THE DISK IS A FAKE BINARY. `FINDMNT_BIN` and `UDISKSCTL_BIN` point at // `e2e/fixtures/bin/fake-{findmnt,udisksctl}.mjs` (see editor/package.json), and @@ -41,7 +43,7 @@ const VIDEO = "20240101_test1234567"; const UUID = "e2e-platter-uuid"; const channelDir = () => resolvePath(`test-transcripts/channels/${SLUG}`); -const dataLink = () => join(channelDir(), "data"); +const mediaLink = () => join(channelDir(), "media"); const controlFile = () => resolvePath("test-transcripts/.fake-findmnt.json"); async function writeControl(control: { @@ -53,22 +55,27 @@ async function writeControl(control: { await fetch(`${baseUrl}/api/test/invalidate-cache`).catch(() => {}); } -// Put the fixture channel's media on `root`, the way a finished relocation -// leaves it: the real directory on the "drive", an absolute symlink at -// channels/<slug>/data, and config.dataDir naming the target. Built directly +// Put the fixture channel's media tier on `root`, the way a finished +// relocation leaves it: the audio in `<root>/<slug>/media/<id>/` on the +// "drive", its relative link in `data/<id>/`, an absolute symlink at +// channels/<slug>/media, and config.mediaDir naming the target. Built directly // rather than by running a move — what is under test here is the re-point, and // a real rsync first would buy nothing but a minute. async function relocateOnDisk(root: string): Promise<string> { - const target = join(root, SLUG, "data"); - await mkdir(join(root, SLUG), { recursive: true }); - await rename(dataLink(), target); - await symlink(target, dataLink()); + const target = join(root, SLUG, "media"); + await mkdir(join(target, VIDEO), { recursive: true }); + await writeFile(join(target, VIDEO, "audio.mp3"), "ID3audio"); + await symlink( + join("..", "..", "media", VIDEO, "audio.mp3"), + join(channelDir(), "data", VIDEO, "audio.mp3"), + ); + await symlink(target, mediaLink()); const config = await readJson<Record<string, unknown>>( `test-transcripts/channels/${SLUG}/config.json`, ); await writeFile( join(channelDir(), "config.json"), - JSON.stringify({ ...config, dataDir: target }, null, 2) + "\n", + JSON.stringify({ ...config, mediaDir: target }, null, 2) + "\n", ); await fetch(`${baseUrl}/api/test/invalidate-cache`).catch(() => {}); return target; @@ -138,7 +145,7 @@ test("the page lists a location with its status, counts and refusals", async ({ // DELETE IS REFUSED WHILE ANYBODY LIVES THERE. Deleting the location moves no // bytes — which is exactly why it is refused: it would erase the only record - // of which disk that channel's absolute dataDir belongs to. + // of which disk that channel's absolute mediaDir belongs to. await expect(cold.getByLabel("delete cold")).toBeDisabled(); await expect(cold.getByLabel("delete withheld")).toContainText( "1 channel(s) still have their media under", @@ -223,7 +230,7 @@ test("a volume that came up somewhere else is re-pointed in one click", async ({ // --- the disk comes back somewhere else --------------------------------- // The media moves with it, because it IS the disk. Nothing in the corpus - // changes: config.dataDir and the symlink still name the old mountpoint, + // changes: config.mediaDir and the symlink still name the old mountpoint, // which is precisely the breakage this page exists for. await rename(join(rootA, SLUG), join(rootB, SLUG)); await rm(rootA, { recursive: true, force: true }); @@ -235,7 +242,7 @@ test("a volume that came up somewhere else is re-pointed in one click", async ({ // is intact and its target is gone, which is the whole of the breakage. A // stat here follows the link and reports "no data/ at all", which is the // reading this module exists to stop anybody making. - expect(await readlink(dataLink())).toBe(targetA); + expect(await readlink(mediaLink())).toBe(targetA); await page.goto("/storage"); // The probe memo is ten seconds wide and keyed by id+root, neither of which @@ -261,17 +268,17 @@ test("a volume that came up somewhere else is re-pointed in one click", async ({ }); // The link and the config now name the new mountpoint... - const targetB = join(rootB, SLUG, "data"); + const targetB = join(rootB, SLUG, "media"); expect(targetA).not.toBe(targetB); await expect - .poll(async () => readlink(dataLink()), { timeout: 30_000 }) + .poll(async () => readlink(mediaLink()), { timeout: 30_000 }) .toBe(targetB); expect( ( - await readJson<{ dataDir?: string }>( + await readJson<{ mediaDir?: string }>( `test-transcripts/channels/${SLUG}/config.json`, ) - ).dataDir, + ).mediaDir, ).toBe(targetB); // ...and so does the location. const settings = await readJson<{ @@ -282,12 +289,19 @@ test("a volume that came up somewhere else is re-pointed in one click", async ({ ); // NOTHING ELSE NOTICED. The per-file route joins channelsDir/<slug>/data/<id> - // exactly as it always has and has no idea any of this happened. + // exactly as it always has and has no idea any of this happened: the + // transcript off the corpus disk, the audio through its link and the + // re-pointed `media` onto the drive at its new mountpoint. const file = await page.request.get( `${baseUrl}/api/channels/${SLUG}/videos/${VIDEO}/files/transcript.en.vtt`, ); expect(file.status()).toBe(200); expect(await file.text()).toContain("WEBVTT"); + const audio = await page.request.get( + `${baseUrl}/api/channels/${SLUG}/videos/${VIDEO}/files/audio.mp3`, + ); + expect(audio.status()).toBe(200); + expect(await audio.text()).toBe("ID3audio"); // And the page agrees: the channel is reachable again. await page.goto("/storage");