commit 0828507f6da65f21d831c1bf07e78977d76d72ea
parent 93d82150217ecd05d8c597ad9fba8113a37c10b5
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Sun, 20 Sep 2026 18:28:58 -0400
e2e: the internal row, the store's move, and the columns operators asked for
storage-locations gains three cases — the corpus volume as a first row with
nothing to configure and a link to its own list, a location whose channels have
no reports reading as unmeasured rather than empty, and the saved-video store
moving out, back, and resuming from a marker a killed copy left.
channels-storage-columns is new: Location and Size, free space once per volume
and never as a column, ?location= filtering, ?sort=size arriving from /storage,
and "free up N GB" ticking the in-place channel while leaving the one already on
the platter alone — moving that frees nothing on the disk being emptied.
channel-storage asserts the decile line the progress parse produces. The
fixture copy is over in one frame, so there is no honest way to catch a bar
mid-flight from a browser; the frame-by-frame behaviour is the unit tests'.
Every settings write here names minFreeDiskGB: 0 — a spec that does not inherit
the 5 GB floor and fails on a nearly full disk.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
3 files changed, 401 insertions(+), 0 deletions(-)
diff --git a/editor/e2e/channel-storage.spec.ts b/editor/e2e/channel-storage.spec.ts
@@ -116,6 +116,18 @@ test("relocate a channel's media to another root, and move it back", async ({
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+ % · /,
+ );
// data/ is a symlink now, and config.json records the target — written only
// by the job, on success, after the copy verified.
diff --git a/editor/e2e/channels-storage-columns.spec.ts b/editor/e2e/channels-storage-columns.spec.ts
@@ -0,0 +1,194 @@
+import { mkdir, rename, symlink } from "node:fs/promises";
+import { join } from "node:path";
+import { test, expect } from "@playwright/test";
+import { baseUrl } from "./baseUrl";
+import {
+ generateReport,
+ readJson,
+ resetData,
+ resolvePath,
+ writeChannelConfig,
+ writeSettings,
+} from "./helpers";
+
+// /channels AS THE STORAGE WORKING SURFACE.
+//
+// Operator ask, 2026-09-20, verbatim: "I'd like to see disk space and current
+// storage volume as columns on the channels menu, and let me filter by volume
+// for easy checking of things that may need moving or other processing."
+//
+// So: a Location column, a Size column, one free-space read-out per VOLUME (not
+// per row — 71 copies of one number is what the focus bar taught us not to do),
+// a `?location=` filter beside the existing `?site=`, and a helper that ticks
+// the largest in-place channels until a target is met.
+//
+// THE SIZES COME OFF THE REPORTS, so every test here generates one first. That
+// is not ceremony: a channel with no report has NO measured size, and the
+// distinction between "0 bytes" and "nobody measured" is the thing half of this
+// feature exists to keep straight.
+//
+// `minFreeDiskGB: 0` on every settings write — a spec that does not say so
+// inherits the 5 GB default and fails on a nearly full disk (repo memory,
+// 549d).
+
+const SLUG = "test-youtube";
+
+// 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.
+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 config = await readJson<Record<string, unknown>>(
+ `test-transcripts/channels/${slug}/config.json`,
+ );
+ await writeChannelConfig(slug, { ...config, dataDir: target });
+ await fetch(`${baseUrl}/api/test/invalidate-cache`).catch(() => {});
+ return target;
+}
+
+test("the Location and Size columns say which disk and how much", async ({
+ page,
+}, testInfo) => {
+ test.setTimeout(120_000);
+ await resetData("one-youtube-channel-with-data");
+ const root = testInfo.outputPath("platter");
+ await mkdir(root, { recursive: true });
+ await writeSettings({
+ adminTitle: "Test Admin",
+ minFreeDiskGB: 0,
+ storage: {
+ locations: [{ id: "cold", label: "Cold", root, autoRepoint: false }],
+ defaultLocationId: "cold",
+ },
+ });
+ await generateReport(page, SLUG);
+
+ await page.goto("/channels");
+ // IN PLACE READS "Internal", not blank. A channel whose media has never moved
+ // 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.
+ const size = page.getByLabel(`media size for ${SLUG}`);
+ await expect(size).not.toHaveText("—");
+ await expect(size).toContainText(/B|KB|MB/);
+
+ // --- moved, and the column follows it ------------------------------------
+ await relocateOnDisk(SLUG, root);
+ await page.goto("/channels");
+ await expect(page.getByLabel(`media location for ${SLUG}`)).toContainText(
+ "Cold",
+ );
+
+ // --- the volume bar, once per volume -------------------------------------
+ const volumes = page.getByLabel("storage volumes");
+ await expect(volumes).toBeVisible();
+ await expect(volumes.getByLabel("volume cold")).toContainText("1 ch");
+ await expect(volumes.getByLabel("volume cold")).toContainText("free");
+ // FREE SPACE IS NOT A COLUMN. It is a fact about a disk; one read-out per
+ // volume, not one per row.
+ await expect(page.getByRole("columnheader", { name: /free/i })).toHaveCount(0);
+});
+
+test("a channel with no report reads as unmeasured, never as empty", async ({
+ page,
+}) => {
+ await resetData("one-youtube-channel-with-data");
+ await writeSettings({ adminTitle: "Test Admin", minFreeDiskGB: 0 });
+ // Deliberately NO generateReport. A zero here would rank a 400 GB channel
+ // bottom of the very list the operator opened to find it.
+ await page.goto("/channels");
+ await expect(page.getByLabel(`media size for ${SLUG}`)).toHaveText("—");
+});
+
+test("filter by volume, sort by size, and free up N GB", async ({
+ page,
+}, testInfo) => {
+ test.setTimeout(180_000);
+ await resetData("one-youtube-channel-with-data");
+ const root = testInfo.outputPath("platter");
+ await mkdir(root, { recursive: true });
+
+ // A second channel, so a filter has something to exclude and a sort has
+ // something to order.
+ const SECOND = "second-mover";
+ await writeChannelConfig(SECOND, {
+ handling: "youtube",
+ name: "Second Mover",
+ url: "https://www.youtube.com/@second",
+ });
+ await mkdir(
+ resolvePath(`test-transcripts/channels/${SECOND}/data/20240102_second0001`),
+ { recursive: true },
+ );
+ await writeSettings({
+ adminTitle: "Test Admin",
+ minFreeDiskGB: 0,
+ storage: {
+ locations: [{ id: "cold", label: "Cold", root, autoRepoint: false }],
+ defaultLocationId: "cold",
+ },
+ });
+ await generateReport(page, SLUG);
+ await generateReport(page, SECOND);
+ await relocateOnDisk(SECOND, root);
+
+ // --- ?location= ----------------------------------------------------------
+ await page.goto("/channels?location=internal");
+ await expect(page.getByLabel(`select ${SLUG}`)).toBeVisible();
+ await expect(page.getByLabel(`select ${SECOND}`)).toHaveCount(0);
+
+ await page.goto("/channels?location=cold");
+ await expect(page.getByLabel(`select ${SECOND}`)).toBeVisible();
+ await expect(page.getByLabel(`select ${SLUG}`)).toHaveCount(0);
+
+ // The chip is the filter, and it carries `aria-current` when it is the one
+ // in force — so the operator can see which list they are looking at.
+ await expect(
+ page.getByLabel("storage volumes").getByLabel("volume cold"),
+ ).toHaveAttribute("aria-current", "true");
+
+ // --- ?sort=size ----------------------------------------------------------
+ // /storage links here with it, so the "largest first" list it promised is the
+ // list that renders.
+ await page.goto("/channels?sort=size");
+ await expect(
+ page.getByRole("columnheader", { name: /^Size/ }),
+ ).toHaveAttribute("aria-sort", "descending");
+ // And clicking the header flips it, like every other sortable column.
+ await page.getByRole("button", { name: "sort by Size" }).click();
+ await expect(
+ page.getByRole("columnheader", { name: /^Size/ }),
+ ).toHaveAttribute("aria-sort", "ascending");
+
+ // --- free up N GB --------------------------------------------------------
+ await page.goto("/channels");
+ // Nothing ticked, so the deck is not there — which is why the control lives
+ // in the volume bar and not on the deck.
+ await expect(page.getByLabel("channel priority bulk")).toHaveCount(0);
+ await page.getByLabel("free up target GB").fill("1");
+ await page
+ .getByLabel("select largest channels to free up")
+ .click();
+
+ // It ticked the in-place channel and NOT the one already on the platter —
+ // moving that frees nothing on the disk being emptied.
+ await expect(page.getByLabel(`select ${SLUG}`)).toBeChecked();
+ await expect(page.getByLabel(`select ${SECOND}`)).not.toBeChecked();
+ // The deck is up, with the note beside the Move button that will act on it.
+ const deck = page.getByLabel("channel priority bulk");
+ await expect(deck).toBeVisible();
+ await expect(deck.getByLabel("free up selection note")).toContainText(
+ "selected",
+ );
+ // 1 GB is more than the fixture holds, so it says so rather than pretending.
+ await expect(deck.getByLabel("free up selection note")).toContainText(
+ "short of the target",
+ );
+});
diff --git a/editor/e2e/storage-locations.spec.ts b/editor/e2e/storage-locations.spec.ts
@@ -3,6 +3,8 @@ import { join } from "node:path";
import { test, expect, type Page } from "@playwright/test";
import { baseUrl } from "./baseUrl";
import {
+ generateReport,
+ pathExists,
readJson,
resetData,
resolvePath,
@@ -285,3 +287,196 @@ test("a volume that came up somewhere else is re-pointed in one click", async ({
"1 ok / 0 unreachable / 0 moving",
);
});
+
+// --- THE CORPUS VOLUME, AND THE SAVED-VIDEO STORE ---------------------------
+
+test("the corpus volume is a row of its own, with nothing to configure", async ({
+ page,
+}, testInfo) => {
+ test.setTimeout(120_000);
+ await resetData("one-youtube-channel-with-data");
+ const root = testInfo.outputPath("platter");
+ await mkdir(root, { recursive: true });
+ await writeSettings({
+ adminTitle: "Test Admin",
+ minFreeDiskGB: 0,
+ storage: {
+ locations: [{ id: "cold", label: "Cold", root, autoRepoint: false }],
+ defaultLocationId: "cold",
+ },
+ });
+ // The sizes come off the reports, so the row can only be honest after one.
+ await generateReport(page, SLUG);
+
+ await page.goto("/storage");
+ const internal = row(page, "internal");
+ await expect(internal).toBeVisible();
+ // IT IS FIRST. 523 GB on a disk with 67 left is not a footnote under the
+ // locations that were added to fix it.
+ const ids = await page
+ .getByLabel(/^storage location: /)
+ .evaluateAll((els) =>
+ els.map((e) => e.getAttribute("aria-label")?.replace("storage location: ", "")),
+ );
+ expect(ids[0]).toBe("internal");
+
+ await expect(internal.getByLabel("location status")).toHaveText("Available");
+ await expect(internal.getByLabel("location channels")).toContainText("1 ok");
+ await expect(internal.getByLabel("location media bytes")).not.toHaveText("—");
+ await expect(internal.getByLabel("location media bytes")).not.toContainText(
+ "size unknown",
+ );
+ // It links to its own list, largest first.
+ await expect(internal.getByLabel("list channels on internal")).toHaveAttribute(
+ "href",
+ "/channels?location=internal&sort=size",
+ );
+ // NOTHING TO REFRESH, RE-POINT, MOUNT, EDIT OR DELETE — and the sentence is
+ // said once rather than as five greyed buttons.
+ await expect(internal.getByLabel("refresh internal")).toHaveCount(0);
+ await expect(internal.getByLabel("delete internal")).toHaveCount(0);
+ await expect(internal.getByLabel("internal note")).toContainText(
+ "not a configured location",
+ );
+
+ // A LOCATION WITH CHANNELS BUT NO REPORTS SAYS SO. `0 B` on a 2 TB drive is a
+ // claim nobody measured.
+ const cold = row(page, "cold");
+ await expect(cold.getByLabel("location media bytes")).toHaveText("0 B");
+});
+
+test("the saved-video store moves onto a location, and back", async ({
+ page,
+}, testInfo) => {
+ test.setTimeout(180_000);
+ await resetData("one-youtube-channel-with-data");
+ const root = testInfo.outputPath("platter");
+ await mkdir(root, { recursive: true });
+ // Something to move. The store is <root>/<slug>/<videoId>/<container>.
+ const storeDir = resolvePath("test-transcripts/saved-videos");
+ await mkdir(join(storeDir, SLUG, VIDEO), { recursive: true });
+ await writeFile(
+ join(storeDir, SLUG, VIDEO, "source-media.mp4"),
+ "not really an mp4",
+ );
+ await writeSettings({
+ adminTitle: "Test Admin",
+ minFreeDiskGB: 0,
+ storage: {
+ locations: [{ id: "cold", label: "Cold", root, autoRepoint: false }],
+ defaultLocationId: "cold",
+ },
+ });
+
+ await page.goto("/storage");
+ const card = page.getByLabel("saved video store");
+ await expect(card).toBeVisible();
+ await expect(card.getByLabel("saved videos status")).toHaveText("In place");
+ await expect(card.getByLabel("saved videos location")).toHaveText(
+ "Internal (in place)",
+ );
+ await expect(card.getByLabel("saved videos bytes")).toContainText("1 file(s)");
+
+ // --- out ----------------------------------------------------------------
+ // NO selectOption: the only destination a store in place can go to is the one
+ // configured location, and the select opens on it. An option that would be a
+ // no-op is never offered (see savedVideosView), which is what makes the
+ // default correct in both directions here.
+ await expect(card.getByLabel("saved videos destination")).toHaveValue("cold");
+ await page.getByRole("button", { name: "Move the store" }).click();
+ await expect(page.getByLabel("Move the store output")).toContainText("Moved", {
+ timeout: 60_000,
+ });
+
+ // A symlink where the store was, the bytes on the "drive", and the record
+ // written only on success.
+ const target = join(root, "saved-videos");
+ expect(await readlink(storeDir)).toBe(target);
+ const after = await readJson<{
+ storage: { savedVideosLocationId?: string };
+ }>("test-settings.json");
+ expect(after.storage.savedVideosLocationId).toBe("cold");
+
+ // EVERY READER KEEPS WORKING, through the link, unchanged.
+ await page.goto("/saved-videos");
+ await expect(page.locator("body")).toContainText(SLUG);
+
+ // --- back ---------------------------------------------------------------
+ await page.goto("/storage");
+ const card2 = page.getByLabel("saved video store");
+ await expect(card2.getByLabel("saved videos status")).toHaveText(
+ "Relocated · reachable",
+ );
+ await expect(card2.getByLabel("saved videos location")).toHaveText("Cold");
+ // And on the way back the only destination is the corpus volume, for the same
+ // reason: a location-to-location move of the store is not offered.
+ await expect(card2.getByLabel("saved videos destination")).toHaveValue("");
+ await page.getByRole("button", { name: "Move the store" }).click();
+ await expect(page.getByLabel("Move the store output")).toContainText(
+ "Moved back",
+ { timeout: 60_000 },
+ );
+ const back = await readJson<{
+ storage: { savedVideosLocationId?: string };
+ }>("test-settings.json");
+ expect(back.storage.savedVideosLocationId).toBe(undefined);
+ // The target was reclaimed, and the store is a real directory again.
+ expect(await pathExists("test-transcripts/saved-videos/" + SLUG)).toBe(true);
+});
+
+// A KILLED COPY LEAVES A MARKER NOTHING WILL EVER CLEAR, and without Resume the
+// only fix is deleting a dotfile over SSH.
+test("an interrupted store move is resumable from the page", async ({
+ page,
+}, testInfo) => {
+ test.setTimeout(120_000);
+ await resetData("one-youtube-channel-with-data");
+ const root = testInfo.outputPath("platter");
+ await mkdir(root, { recursive: true });
+ const storeDir = resolvePath("test-transcripts/saved-videos");
+ await mkdir(join(storeDir, SLUG, VIDEO), { recursive: true });
+ await writeFile(
+ join(storeDir, SLUG, VIDEO, "source-media.mp4"),
+ "not really an mp4",
+ );
+ await writeSettings({
+ adminTitle: "Test Admin",
+ minFreeDiskGB: 0,
+ storage: {
+ locations: [{ id: "cold", label: "Cold", root, autoRepoint: false }],
+ defaultLocationId: "cold",
+ },
+ });
+ // What a killed copy leaves behind: a partial target and a phase-copy marker.
+ const target = join(root, "saved-videos");
+ await mkdir(join(target, SLUG, VIDEO), { recursive: true });
+ await writeFile(
+ resolvePath("test-transcripts/.relocating-saved-videos.json"),
+ JSON.stringify(
+ {
+ target,
+ direction: "out",
+ startedAt: new Date().toISOString(),
+ phase: "copy",
+ },
+ null,
+ 2,
+ ),
+ );
+ await fetch(`${baseUrl}/api/test/invalidate-cache`).catch(() => {});
+
+ await page.goto("/storage");
+ const card = page.getByLabel("saved video store");
+ await expect(card.getByLabel("saved videos status")).toHaveText(
+ "Move in flight",
+ );
+ await page.getByRole("button", { name: "Resume move" }).click();
+ await expect(page.getByLabel("Resume move output")).toContainText(
+ "resumed an interrupted move",
+ { timeout: 60_000 },
+ );
+ expect(await readlink(storeDir)).toBe(target);
+ expect(
+ await pathExists("test-transcripts/.relocating-saved-videos.json"),
+ ).toBe(false);
+});