Archilyzer · Source

archilyzer

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

commit 1677a13d049059f4804c0ef64147575fa4b7815f
parent 964979cc3e701d2b7ccf6c28739667bcc412858f
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Fri, 11 Sep 2026 11:49:42 -0400

e2e: a relocated channel is indistinguishable from one in place

The spec moves the fixture channel's media to a root under the test's own tmp
dir and back, and what it actually pins is the claim the whole design rests on:
the assertions after the move are not about the move. They are the videos list
still listing the video and the per-file route still serving transcript.en.vtt,
through the same URLs, with the bytes somewhere else entirely — because the
on-disk contract channelDir/data/<id>/<file> is preserved and no reader was
changed.

Either side of it: nothing previewed means no move offered (asserted through
the hint, not the disabled button — the button is also disabled before
hydration, so toBeDisabled() alone would pass for the wrong reason); an
in-place channel draws NO badge; a relocated one draws it on /channels; the
config field is written only by the job, on success; and no parked copy or
marker is left behind, which is the step that actually frees the source volume.

The report is generated first and then waited out rather than skipped: the
panel refuses a move while the channel has a job, and generateReport leaves one
for a moment. The guard is real and the spec satisfies it instead of racing it.
There is nothing to wait for between the move out and the move back, because a
relocation queues no report.

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

Diffstat:
Mcommon/controller/relocateChannelMedia.ts | 15+++++++++++----
Mcommon/jobs/snapshotScheduler.ts | 13+++++++++++++
Meditor/app/channels/[slug]/storageActions.ts | 15++++++++-------
Aeditor/e2e/channel-storage.spec.ts | 188+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/e2e/helpers.ts | 1+
5 files changed, 221 insertions(+), 11 deletions(-)

diff --git a/common/controller/relocateChannelMedia.ts b/common/controller/relocateChannelMedia.ts @@ -504,15 +504,22 @@ async function moveOut(args: { // media with nothing to spare puts the destination volume under the disk // gate's own floor the moment it arrives, so the channel's next download is // refused by the gate on the drive it was just moved to. The margin is the - // operator's configured one, so the two numbers cannot drift apart. - const marginGB = getSettings().resumeMarginGB; + // operator's configured one, so the two numbers cannot drift apart — and it + // is ZERO when the gate is switched off, because the margin exists to clear + // a bar that then does not exist. Demanding headroom for a gate nobody + // armed would refuse a move on a disk with room for it. + const settings = getSettings(); + const marginGB = + settings.minFreeDiskGB > 0 ? settings.resumeMarginGB : 0; const needed = measured.bytes + marginGB * BYTES_PER_GB; const free = await getFreeBytes(root); if (free < needed) { throw new Error( `Not enough space on ${root}: ${formatBytes(free)} free, ` + - `${formatBytes(measured.bytes)} to move plus a ${marginGB} GB resume ` + - `margin = ${formatBytes(needed)} required`, + `${formatBytes(measured.bytes)} to move` + + (marginGB > 0 + ? ` plus a ${marginGB} GB resume margin = ${formatBytes(needed)} required` + : " required"), ); } await mkdir(target, { recursive: true }); diff --git a/common/jobs/snapshotScheduler.ts b/common/jobs/snapshotScheduler.ts @@ -47,6 +47,19 @@ const NO_REGEN_KINDS = new Set<string>([ // than doing the backfill. The per-channel backfill job regenerates ONCE, at // job end. "backfill-channel", + // A RELOCATION MOVES BYTES, IT DOES NOT CHANGE THEM. Every video dir, every + // transcript and every mtime is identical afterwards — `rsync -a` preserves + // them, which is the same property that makes the LMDB index a no-op — so a + // regen would walk the whole channel to write a byte-identical snapshot with + // a newer generatedAt, claiming a measurement it did not take. On the 130 GB + // channel this exists for that is a 16-way walk of 11,000 video dirs for + // nothing, immediately after a job that just moved 130 GB. + // + // It also removes a race the operator would feel: the regen is a queued job + // for this channel, and the Storage panel refuses a move while the channel + // has one — so "move out, then move back" would be blocked by a report + // nobody needed. + "relocate-channel-media", ]); export function shouldRequestSnapshot(kind: string): boolean { diff --git a/editor/app/channels/[slug]/storageActions.ts b/editor/app/channels/[slug]/storageActions.ts @@ -30,7 +30,6 @@ import { runManagedFunction, type StreamActionResult, } from "yt-dlp-transcript-common/jobs/streamCommand"; -import { requestChannelSnapshot } from "yt-dlp-transcript-common/jobs/snapshotScheduler"; import { formatBytes } from "yt-dlp-transcript-common/lib/format"; import { clearRelocationMarker } from "yt-dlp-transcript-common/lib/channelMedia"; import { @@ -126,12 +125,14 @@ async function runMove( `${formatBytes(result.bytes)} — ${result.target}` + (result.resumed ? " (resumed an interrupted move)" : ""), ); - // The snapshot records what is in `data/`, and a move does not change - // that — but the badge, the free-space line and the media location on - // every page that draws them are read per render, so the pages that show - // them have to be told. The snapshot request is for the report's own - // freshness clock, not for a count this changed. - requestChannelSnapshot(paths, slug); + // No snapshot regen — deliberately, and `relocate-channel-media` is in + // NO_REGEN_KINDS so the central hook does not arm one either. The move + // changes where the bytes are, not what they are: every count in the + // report is identical afterwards, and a regen would be a full walk of the + // channel to rewrite the same numbers under a newer timestamp. + // + // What DOES have to change is what the pages read per render — the badge, + // the location line, the free-space figure — so those are revalidated. revalidatePath(`/channels/${slug}`); revalidatePath("/channels"); revalidatePath("/"); diff --git a/editor/e2e/channel-storage.spec.ts b/editor/e2e/channel-storage.spec.ts @@ -0,0 +1,188 @@ +import { lstat, mkdir, readdir } 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, +} from "./helpers"; + +// MOVING A CHANNEL'S MEDIA TO ANOTHER DIRECTORY, AND BACK. +// +// 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`); + +// 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<void> { + 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(90_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"); + + // --- before ------------------------------------------------------------- + await page.goto(channelStage(SLUG, "storage")); + await expect(page.getByLabel("media path")).toHaveText(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 root to enable the move."), + ).toBeVisible(); + await expect(moveButton).toBeDisabled(); + + await page.getByRole("button", { name: "Preview" }).click(); + const preview = page.getByLabel("relocation preview"); + await expect(preview).toBeVisible({ timeout: 15_000 }); + // The fixture is two files in one video dir. + await expect(page.getByLabel("bytes to move")).toContainText("2 file(s)"); + + // --- the move ----------------------------------------------------------- + await expect(moveButton).toBeEnabled(); + await moveButton.click(); + await expect(page.getByLabel("Move media output")).toContainText("Moved", { + timeout: 60_000, + }); + + // 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); + expect( + (await readJson<{ dataDir?: string }>( + `test-transcripts/channels/${SLUG}/config.json`, + )).dataDir, + ).toBe(target); + // 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. + const siblings = await readdir( + resolvePath(`test-transcripts/channels/${SLUG}`), + ); + expect(siblings.filter((n) => n.startsWith("data."))).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)); + await expect(page.getByText(VIDEO).first()).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`, + ); + expect(file.status()).toBe(200); + expect(await file.text()).toContain("WEBVTT"); + + // --- the badge ---------------------------------------------------------- + await page.goto("/channels"); + await expect( + page.getByLabel(/^media location: Media relocated/), + ).toBeVisible(); + + // --- back --------------------------------------------------------------- + await page.goto(channelStage(SLUG, "storage")); + await expect(page.getByLabel("media path")).toHaveText(target); + const backButton = page.getByRole("button", { + name: "Move back in place", + }); + await expect(backButton).toBeEnabled(); + await backButton.click(); + await expect(page.getByLabel("Move back in place output")).toContainText( + "Moved back", + { timeout: 60_000 }, + ); + + // A real directory again, the config field gone, and the target reclaimed. + expect((await lstat(dataDir())).isDirectory()).toBe(true); + expect( + (await readJson<{ dataDir?: string }>( + `test-transcripts/channels/${SLUG}/config.json`, + )).dataDir, + ).toBe(undefined); + const back = await page.request.get( + `${baseUrl}/api/channels/${SLUG}/videos/${VIDEO}/files/transcript.en.vtt`, + ); + expect(back.status()).toBe(200); +}); + +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 (data/)"); + // 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"); +}); diff --git a/editor/e2e/helpers.ts b/editor/e2e/helpers.ts @@ -352,6 +352,7 @@ export type ChannelStage = | "speakers" | "cleanup" | "diagnostics" + | "storage" | "danger"; // The channel overview with one stage panel open. Landing here is what the old