commit b0b8e6b14be0077b53da53df2ee7156c2bc467a8
parent 043a3aa506835641cee357b2b68852a2f042c83d
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Thu, 17 Sep 2026 14:56:59 -0400
/storage: the locations, what is on them, and the one click that follows a disk
A server component over the pure view: the shell probes (memoised 10 s, so a
double render is one set of subprocesses), rolls the channels up, and hands
the payload to `buildStorageRows`. The client renders it and calls the
actions; it contains no rule, which is what makes the rules unit-testable
without a browser.
Five actions run inline — their whole output is a sentence. The sixth
(re-point) is a job, because it rewrites every channel symlink on the
location; `lib/repointJob.ts` is its one enqueue, separate from actions.ts
for the reason `channels/lib/relocationJob.ts` is separate: a helper exported
from a "use server" file is an unguarded endpoint.
The edit form drops the stored `volume` whenever the root changes. `volume`
carries the root's path RELATIVE to its mountpoint and a probe builds a
candidate root from it, so a relPath measured against the old root would make
the next Re-point button point every channel at a directory that does not
exist. The next Refresh relearns identity for one findmnt.
Every import the client makes from the storage modules is a TYPE import: the
controller and storageVolumes.ts both pull in execa, and `next build` is what
proves neither reaches the bundle.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
6 files changed, 879 insertions(+), 0 deletions(-)
diff --git a/editor/app/storage/actions.ts b/editor/app/storage/actions.ts
@@ -0,0 +1,267 @@
+"use server";
+
+import { revalidatePath } from "next/cache";
+import { getPaths } from "yt-dlp-transcript-common/lib/paths";
+import {
+ getSettings,
+ writeSettings,
+} from "yt-dlp-transcript-common/lib/settings";
+import type { StorageLocation } from "yt-dlp-transcript-common/lib/storageLocations";
+import {
+ mountByUuid,
+ probeLocation,
+} from "yt-dlp-transcript-common/lib/storageVolumes";
+import type { StreamActionResult } from "yt-dlp-transcript-common/jobs/streamCommand";
+import {
+ channelsOnLocation,
+ maybeAutoRepoint,
+ probeLocationMemo,
+ recordProbedIdentity,
+ resetStorageProbeMemo,
+} from "yt-dlp-transcript-common/controller/storageLocations";
+import { channelMediaBusyReason } from "../channels/lib/mediaBusy";
+import { enqueueRepointJob } from "./lib/repointJob";
+
+// THE SIX THINGS AN OPERATOR MAY DO TO A STORAGE LOCATION.
+//
+// Five of them are small settings writes or one subprocess and run INLINE:
+// their whole output is a sentence, and a queued job with a log would be a
+// worse way to show one. The sixth (re-point) rewrites every channel symlink on
+// the location and is a job, with a record, a log and a cancel button, like
+// every other long action in the editor.
+
+export type LocationResult = { ok: true; note?: string } | { ok: false; error: string };
+
+const ID_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
+
+export type LocationDraft = {
+ id: string;
+ label: string;
+ root: string;
+ autoRepoint: boolean;
+ makeDefault: boolean;
+};
+
+function normalizeRoot(root: string): string {
+ const trimmed = root.trim().replace(/\/+$/, "");
+ return trimmed === "" ? root.trim() : trimmed;
+}
+
+function validate(draft: LocationDraft): string | null {
+ if (!ID_RE.test(draft.id)) {
+ return `"${draft.id}" is not a valid id: lower-case letters, digits and dashes, starting with a letter or digit, 64 characters at most.`;
+ }
+ const root = normalizeRoot(draft.root);
+ if (!root.startsWith("/")) {
+ return `The root must be an absolute path (got "${draft.root}"). A relative root would name a different directory in every process that read it.`;
+ }
+ return null;
+}
+
+function revalidateStorage(): void {
+ revalidatePath("/storage");
+ revalidatePath("/channels");
+ revalidatePath("/");
+}
+
+export async function addStorageLocationAction(
+ draft: LocationDraft,
+): Promise<LocationResult> {
+ const problem = validate(draft);
+ if (problem) return { ok: false, error: problem };
+ const settings = getSettings();
+ if (settings.storage.locations.some((l) => l.id === draft.id)) {
+ return { ok: false, error: `A storage location "${draft.id}" already exists.` };
+ }
+ const location: StorageLocation = {
+ id: draft.id,
+ label: draft.label.trim() || draft.id,
+ root: normalizeRoot(draft.root),
+ autoRepoint: draft.autoRepoint,
+ };
+ const locations = [...settings.storage.locations, location];
+ await writeSettings({
+ ...settings,
+ storage: {
+ locations,
+ // The FIRST location is the default whether or not the box was ticked:
+ // with exactly one location, "no default" is never the answer anybody
+ // wanted, and a blank default silently disables every prefill.
+ defaultLocationId:
+ draft.makeDefault || locations.length === 1
+ ? draft.id
+ : settings.storage.defaultLocationId,
+ },
+ });
+ revalidateStorage();
+ return { ok: true, note: `Added "${location.label}" at ${location.root}.` };
+}
+
+export async function editStorageLocationAction(
+ id: string,
+ draft: LocationDraft,
+): Promise<LocationResult> {
+ const problem = validate({ ...draft, id });
+ if (problem) return { ok: false, error: problem };
+ const settings = getSettings();
+ const existing = settings.storage.locations.find((l) => l.id === id);
+ if (!existing) return { ok: false, error: `There is no storage location "${id}".` };
+ const root = normalizeRoot(draft.root);
+
+ // THE STORED IDENTITY IS DROPPED WHEN THE ROOT CHANGES, and this is the one
+ // rule in this file that is not obvious. `volume` records the mountpoint the
+ // root was under and the root's path RELATIVE to it, and a probe computes a
+ // candidate root from them (`join(newMountpoint, relPath)`). Keep a relPath
+ // measured against the old root and the next "mounted elsewhere" offers a
+ // candidate that is wrong by exactly the difference — a Re-point button that
+ // points every channel at a directory that does not exist. The next Refresh
+ // relearns identity from the root the operator just typed, which costs one
+ // findmnt and is always right.
+ const keepVolume = root === existing.root;
+ const updated: StorageLocation = {
+ ...existing,
+ label: draft.label.trim() || id,
+ root,
+ autoRepoint: draft.autoRepoint,
+ ...(keepVolume && existing.volume ? { volume: existing.volume } : {}),
+ };
+ if (!keepVolume) delete (updated as { volume?: unknown }).volume;
+
+ await writeSettings({
+ ...settings,
+ storage: {
+ locations: settings.storage.locations.map((l) => (l.id === id ? updated : l)),
+ defaultLocationId: draft.makeDefault
+ ? id
+ : settings.storage.defaultLocationId,
+ },
+ });
+ // The memo keys by id and carries the root it answered for, but clearing it
+ // makes the next render honest rather than merely correct.
+ resetStorageProbeMemo();
+ revalidateStorage();
+ return {
+ ok: true,
+ note: keepVolume
+ ? `Saved "${updated.label}".`
+ : `Saved "${updated.label}" at ${root}. The recorded volume identity was dropped — press Refresh to relearn it.`,
+ };
+}
+
+// DELETING A LOCATION MOVES NOTHING, and that is exactly why it is refused
+// while channels are on it: their `config.dataDir` would keep naming an
+// absolute path on a disk nothing in the corpus remembers the name of, which is
+// the situation this page exists to end.
+export async function deleteStorageLocationAction(
+ id: string,
+): Promise<LocationResult> {
+ const settings = getSettings();
+ const location = settings.storage.locations.find((l) => l.id === id);
+ if (!location) return { ok: false, error: `There is no storage location "${id}".` };
+ const rollups = await channelsOnLocation({
+ paths: getPaths(),
+ locations: [location],
+ });
+ const roll = rollups[id];
+ if (roll && roll.total > 0) {
+ return {
+ ok: false,
+ error:
+ `${roll.total} channel(s) still have their media under ${location.root} ` +
+ `(${roll.slugs.slice(0, 5).join(", ")}${roll.slugs.length > 5 ? ", …" : ""}). ` +
+ `Move them back in place, or onto another location, first.`,
+ };
+ }
+ const locations = settings.storage.locations.filter((l) => l.id !== id);
+ await writeSettings({
+ ...settings,
+ storage: {
+ locations,
+ defaultLocationId:
+ settings.storage.defaultLocationId === id
+ ? (locations[0]?.id ?? "")
+ : settings.storage.defaultLocationId,
+ },
+ });
+ resetStorageProbeMemo();
+ revalidateStorage();
+ return { ok: true, note: `Deleted "${location.label}".` };
+}
+
+// A FRESH PROBE, PAST THE MEMO. The operator pressing this has just done
+// something physical — plugged the disk in, mounted it — and is asking for an
+// answer taken afterwards.
+//
+// It writes IDENTITY and nothing else: availability is never persisted (a
+// refresh that stored "available" would rewrite settings.json, and so bump the
+// pulse revision every open tab polls, on every page load).
+export async function refreshStorageLocationAction(
+ id: string,
+): Promise<LocationResult> {
+ const paths = getPaths();
+ const settings = getSettings();
+ const location = settings.storage.locations.find((l) => l.id === id);
+ if (!location) return { ok: false, error: `There is no storage location "${id}".` };
+ const probe = await probeLocationMemo(location, paths, { refresh: true });
+ const wrote = await recordProbedIdentity({ locationId: id, probe });
+
+ // The opt-in. Off by default, and refused unless the full preflight passes —
+ // so this either starts the job the operator armed or says why it did not.
+ const auto = await maybeAutoRepoint({
+ paths,
+ location,
+ probe,
+ bins: paths,
+ isBusy: channelMediaBusyReason,
+ });
+ revalidateStorage();
+ if (auto.started) {
+ return {
+ ok: true,
+ note: `Auto re-point started: "${location.label}" → ${auto.newRoot}. Watch it on /jobs.`,
+ };
+ }
+ const identity = probe.identity.known
+ ? ` Identity: ${probe.identity.uuid} at ${probe.identity.mountpoint}.${wrote ? " (recorded)" : ""}`
+ : " Identity unknown — nothing here can ask (no findmnt, or a container).";
+ return { ok: true, note: `${location.label}: ${probe.status}.${identity}` };
+}
+
+// NEVER RETRIED, and the error is surfaced verbatim: a polkit denial under a
+// service session is the expected failure and the operator needs to read the
+// daemon's own words.
+export async function mountStorageLocationAction(
+ id: string,
+): Promise<LocationResult> {
+ const paths = getPaths();
+ const settings = getSettings();
+ const location = settings.storage.locations.find((l) => l.id === id);
+ if (!location) return { ok: false, error: `There is no storage location "${id}".` };
+ const uuid = location.volume?.uuid?.trim() ?? "";
+ if (!uuid) {
+ return {
+ ok: false,
+ error: `"${location.label}" has no recorded volume identity, so there is nothing to mount by UUID. Mount it from the host and press Refresh.`,
+ };
+ }
+ const result = await mountByUuid(uuid, paths);
+ if (!result.ok) return { ok: false, error: result.error ?? "udisksctl failed." };
+ // The mount changed the world the memo answered about.
+ resetStorageProbeMemo();
+ const after = await probeLocation(location, paths);
+ await recordProbedIdentity({ locationId: id, probe: after }).catch(() => {});
+ revalidateStorage();
+ return {
+ ok: true,
+ note: `Mounted ${uuid}${result.mountpoint ? ` at ${result.mountpoint}` : ""}. The location now reads ${after.status}.`,
+ };
+}
+
+export async function repointStorageLocationAction(
+ id: string,
+ newRoot: string,
+): Promise<StreamActionResult> {
+ const root = newRoot.trim();
+ if (!root) return { ok: false, error: "No new root given." };
+ return enqueueRepointJob({ locationId: id, newRoot: root });
+}
diff --git a/editor/app/storage/buildStorage.ts b/editor/app/storage/buildStorage.ts
@@ -0,0 +1,44 @@
+import { getPaths } from "yt-dlp-transcript-common/lib/paths";
+import { getSettings } from "yt-dlp-transcript-common/lib/settings";
+import { getRegistry } from "yt-dlp-transcript-common/jobs/registry";
+import { udisksctlAvailable } from "yt-dlp-transcript-common/lib/storageVolumes";
+import {
+ channelsOnLocation,
+ probeAllLocations,
+} from "yt-dlp-transcript-common/controller/storageLocations";
+import {
+ buildStorageRows,
+ type StorageRowsPayload,
+} from "yt-dlp-transcript-common/views/storage";
+
+// THE SHELL. `common/views/storage.ts` is pure and takes every live fact as an
+// argument; this is the file that actually goes and gets them — settings off
+// disk, one probe per location (memoised for 10 s, so a double render is one
+// set of subprocesses), the per-channel roll-up, the registry, and the clock.
+//
+// SERVER ONLY. It reaches `storageVolumes.ts` and the controller, both of which
+// import execa. Nothing `"use client"` may import this file; `next build` is
+// what proves it.
+export async function buildStorage(opts: { refresh?: boolean } = {}): Promise<
+ StorageRowsPayload
+> {
+ const paths = getPaths();
+ const settings = getSettings();
+ const locations = settings.storage.locations;
+ const [probes, rollups, udisksctl] = await Promise.all([
+ probeAllLocations(locations, paths, { refresh: opts.refresh }),
+ channelsOnLocation({ paths, locations }),
+ // Memoised per binary path inside storageVolumes, so this is one
+ // `--version` for the life of the process.
+ locations.length > 0 ? udisksctlAvailable(paths) : Promise.resolve(false),
+ ]);
+ return buildStorageRows({
+ locations,
+ defaultLocationId: settings.storage.defaultLocationId,
+ probes,
+ rollups,
+ registry: getRegistry(),
+ udisksctlAvailable: udisksctl,
+ now: Date.now(),
+ });
+}
diff --git a/editor/app/storage/components/LocationForm.tsx b/editor/app/storage/components/LocationForm.tsx
@@ -0,0 +1,161 @@
+"use client";
+
+import { useState } from "react";
+import type { LocationDraft, LocationResult } from "../actions";
+
+// ADD AND EDIT ARE ONE FORM, because they are one set of rules: the id shape,
+// the absolute root, the label that falls back to the id, and the opt-in. The
+// only difference is that an id is CHOSEN once and never changed — it is what
+// `defaultLocationId` and every action refer to — so in edit mode the field is
+// shown and disabled rather than hidden, which keeps the row readable.
+//
+// TYPES ONLY, FROM THE ACTION FILE. This is a "use client" file: it may not
+// reach `lib/storageVolumes.ts` or the storage controller (both import execa,
+// and `next build` fails on `node:child_process` in a client bundle). The
+// server actions it calls are the wire, and `LocationDraft` is a plain object.
+
+type Props = {
+ mode: "add" | "edit";
+ initial?: {
+ id: string;
+ label: string;
+ root: string;
+ autoRepoint: boolean;
+ isDefault: boolean;
+ };
+ submit: (draft: LocationDraft) => Promise<LocationResult>;
+ onDone?: () => void;
+ onCancel?: () => void;
+};
+
+export function LocationForm({ mode, initial, submit, onDone, onCancel }: Props) {
+ const [id, setId] = useState(initial?.id ?? "");
+ const [label, setLabel] = useState(initial?.label ?? "");
+ const [root, setRoot] = useState(initial?.root ?? "");
+ const [autoRepoint, setAutoRepoint] = useState(initial?.autoRepoint ?? false);
+ const [makeDefault, setMakeDefault] = useState(initial?.isDefault ?? false);
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState<string | null>(null);
+
+ async function run() {
+ setBusy(true);
+ setError(null);
+ const result = await submit({ id, label, root, autoRepoint, makeDefault });
+ setBusy(false);
+ if (!result.ok) {
+ setError(result.error);
+ return;
+ }
+ if (mode === "add") {
+ setId("");
+ setLabel("");
+ setRoot("");
+ setAutoRepoint(false);
+ setMakeDefault(false);
+ }
+ onDone?.();
+ }
+
+ return (
+ <div className="flex flex-col gap-3 rounded border border-border bg-muted/30 px-3 py-3">
+ <div className="grid gap-3 sm:grid-cols-3">
+ <label className="flex flex-col gap-1 text-sm">
+ <span className="font-medium">Id</span>
+ <input
+ type="text"
+ aria-label="location id"
+ value={id}
+ disabled={mode === "edit"}
+ onChange={(e) => setId(e.target.value)}
+ placeholder="platter"
+ className="rounded border border-border bg-card px-2 py-1 text-sm font-mono disabled:opacity-60"
+ />
+ <span className="text-xs text-muted-foreground">
+ Lower-case, stable. It never changes.
+ </span>
+ </label>
+ <label className="flex flex-col gap-1 text-sm">
+ <span className="font-medium">Label</span>
+ <input
+ type="text"
+ aria-label="location label"
+ value={label}
+ onChange={(e) => setLabel(e.target.value)}
+ placeholder="Platter"
+ className="rounded border border-border bg-card px-2 py-1 text-sm"
+ />
+ <span className="text-xs text-muted-foreground">
+ Blank uses the id.
+ </span>
+ </label>
+ <label className="flex flex-col gap-1 text-sm">
+ <span className="font-medium">Root</span>
+ <input
+ type="text"
+ aria-label="location root"
+ value={root}
+ onChange={(e) => setRoot(e.target.value)}
+ placeholder="/mnt/platter/archilyzer-media"
+ className="rounded border border-border bg-card px-2 py-1 text-sm font-mono"
+ />
+ <span className="text-xs text-muted-foreground">
+ An absolute directory. One root holds many channels, each at
+ <code><slug>/data</code>.
+ </span>
+ </label>
+ </div>
+ <div className="flex flex-wrap items-center gap-4 text-sm">
+ <label className="flex items-center gap-2">
+ <input
+ type="checkbox"
+ aria-label="auto re-point"
+ checked={autoRepoint}
+ onChange={(e) => setAutoRepoint(e.target.checked)}
+ />
+ <span>
+ Auto re-point
+ <span className="text-muted-foreground">
+ {" "}
+ — when the volume turns up at a different mountpoint, move the
+ links without asking (only if the preflight passes).
+ </span>
+ </span>
+ </label>
+ <label className="flex items-center gap-2">
+ <input
+ type="checkbox"
+ aria-label="default location"
+ checked={makeDefault}
+ onChange={(e) => setMakeDefault(e.target.checked)}
+ />
+ <span>Default destination for a move</span>
+ </label>
+ </div>
+ {error && (
+ <p role="alert" className="text-sm text-destructive">
+ {error}
+ </p>
+ )}
+ <div className="flex items-center gap-2">
+ <button
+ type="button"
+ onClick={run}
+ disabled={busy}
+ aria-label={mode === "add" ? "add storage location" : `save storage location: ${id}`}
+ className="px-3 py-1.5 rounded-md border border-border text-sm font-medium disabled:opacity-50"
+ >
+ {busy ? "Saving…" : mode === "add" ? "Add location" : "Save"}
+ </button>
+ {onCancel && (
+ <button
+ type="button"
+ onClick={onCancel}
+ className="px-3 py-1.5 rounded-md text-sm text-muted-foreground"
+ >
+ Cancel
+ </button>
+ )}
+ </div>
+ </div>
+ );
+}
diff --git a/editor/app/storage/components/StorageLocationsTable.tsx b/editor/app/storage/components/StorageLocationsTable.tsx
@@ -0,0 +1,326 @@
+"use client";
+
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+import { StreamActionLog } from "yt-dlp-transcript-common/components/StreamActionLog";
+import { formatBytes } from "yt-dlp-transcript-common/lib/format";
+import type {
+ StorageActionKind,
+ StorageRow,
+ StorageRowsPayload,
+} from "yt-dlp-transcript-common/views/storage";
+import { cancelJobAction } from "../../jobs/actions";
+import {
+ addStorageLocationAction,
+ deleteStorageLocationAction,
+ editStorageLocationAction,
+ mountStorageLocationAction,
+ refreshStorageLocationAction,
+ repointStorageLocationAction,
+} from "../actions";
+import { LocationForm } from "./LocationForm";
+
+// THE LOCATIONS, ONE CARD EACH, AND WHAT MAY BE DONE TO THEM.
+//
+// Every judgement on this screen was already made on the server:
+// `common/views/storage.ts` decided each row's status, its counts, which of the
+// five actions are offered and the sentence explaining each one that is not.
+// This file renders that and calls the actions. It contains no rule — which is
+// the point of the views layer, and what makes those rules unit-testable
+// without a browser.
+//
+// TYPE-ONLY IMPORTS FROM views/storage. It is a "use client" file: the storage
+// controller and `lib/storageVolumes.ts` both import execa, and a value import
+// of either would fail `next build` with `node:child_process` in the client
+// bundle.
+//
+// ⚠️ THE RE-POINT PANEL IS NEVER UNMOUNTED BY ITS OWN RESULT. StreamActionLog
+// holds its streamed log in React state and calls router.refresh() the instant
+// a run ends (plans/FACTS.md) — and that refresh re-renders this list with the
+// location's status flipped from `mounted-elsewhere` to `available`, i.e. with
+// the Re-point action no longer offered. A naive `{offered && <Log/>}` would
+// therefore delete the log of the run that just succeeded, at the exact moment
+// the operator wants to read it. Same shape as StorageStage: a `ranHere` flag
+// set inside the trigger keeps the panel mounted, and the now-cleared condition
+// is fed to `disabled` so the panel that stayed for its log is not a second Run
+// button.
+
+export function StorageLocationsTable({ payload }: { payload: StorageRowsPayload }) {
+ const router = useRouter();
+ const [adding, setAdding] = useState(false);
+
+ return (
+ <div className="flex flex-col gap-4">
+ <section
+ aria-label="storage locations"
+ className="flex flex-col gap-3"
+ >
+ {payload.rows.length === 0 && (
+ <p className="text-sm text-muted-foreground">
+ No storage locations yet. A location is a named place a channel’s
+ media may live — usually a second drive. Add one below, then move a
+ channel onto it from its Storage panel.
+ </p>
+ )}
+ {payload.rows.map((row) => (
+ <LocationCard key={row.id} row={row} onChanged={() => router.refresh()} />
+ ))}
+ </section>
+
+ <section className="flex flex-col gap-2">
+ <h2 className="text-base font-semibold">Add a location</h2>
+ {adding ? (
+ <LocationForm
+ mode="add"
+ submit={addStorageLocationAction}
+ onDone={() => {
+ setAdding(false);
+ router.refresh();
+ }}
+ onCancel={() => setAdding(false)}
+ />
+ ) : (
+ <div>
+ <button
+ type="button"
+ aria-label="add storage location"
+ onClick={() => setAdding(true)}
+ className="px-3 py-1.5 rounded-md border border-border text-sm font-medium"
+ >
+ Add location
+ </button>
+ </div>
+ )}
+ </section>
+ </div>
+ );
+}
+
+function actionOf(row: StorageRow, kind: StorageActionKind) {
+ return row.actions.find((a) => a.kind === kind);
+}
+
+function LocationCard({
+ row,
+ onChanged,
+}: {
+ row: StorageRow;
+ onChanged: () => void;
+}) {
+ const [editing, setEditing] = useState(false);
+ const [note, setNote] = useState<string | null>(null);
+ const [error, setError] = useState<string | null>(null);
+ const [busy, setBusy] = useState(false);
+ const [ranRepointHere, setRanRepointHere] = useState(false);
+
+ const refresh = actionOf(row, "refresh");
+ const repoint = actionOf(row, "repoint");
+ const mount = actionOf(row, "mount");
+ const edit = actionOf(row, "edit");
+ const del = actionOf(row, "delete");
+
+ async function run(fn: () => Promise<{ ok: true; note?: string } | { ok: false; error: string }>) {
+ setBusy(true);
+ setError(null);
+ setNote(null);
+ const result = await fn();
+ setBusy(false);
+ if (result.ok) {
+ setNote(result.note ?? null);
+ onChanged();
+ } else {
+ setError(result.error);
+ }
+ }
+
+ return (
+ <article
+ aria-label={`storage location: ${row.id}`}
+ className="flex flex-col gap-3 rounded-xl border border-border bg-card px-4 py-3"
+ >
+ <div className="flex flex-wrap items-center gap-3">
+ <h2 className="text-base font-semibold">{row.label}</h2>
+ <span
+ aria-label="location status"
+ className={`rounded-full border px-2 py-0.5 text-xs font-medium ${
+ row.status === "available"
+ ? "border-border bg-muted"
+ : "border-destructive/50 bg-destructive/5 text-destructive"
+ }`}
+ >
+ {row.statusLabel}
+ </span>
+ {row.isDefault && (
+ <span className="rounded-full border border-border px-2 py-0.5 text-xs text-muted-foreground">
+ default
+ </span>
+ )}
+ {row.autoRepoint && (
+ <span className="rounded-full border border-border px-2 py-0.5 text-xs text-muted-foreground">
+ auto re-point
+ </span>
+ )}
+ </div>
+
+ <dl className="grid grid-cols-[max-content_1fr] gap-x-4 gap-y-1 text-sm">
+ <dt className="text-muted-foreground">Root</dt>
+ <dd className="font-mono text-xs break-all" aria-label="location root path">
+ {row.root}
+ </dd>
+ <dt className="text-muted-foreground">Volume</dt>
+ <dd className="text-xs" aria-label="location identity">
+ {row.identity ?? "unknown — nothing here can ask (no findmnt, or a container)"}
+ </dd>
+ <dt className="text-muted-foreground">Channels</dt>
+ <dd aria-label="location channels">{row.channelsText}</dd>
+ <dt className="text-muted-foreground">Free</dt>
+ <dd aria-label="location free space">
+ {row.freeBytes === undefined ? "—" : formatBytes(row.freeBytes)}
+ </dd>
+ <dt className="text-muted-foreground">Last probe</dt>
+ <dd aria-label="location last probe">{formatAge(row.lastProbeAgeMs)}</dd>
+ </dl>
+
+ {row.warning && (
+ <p
+ role="status"
+ aria-label="location warning"
+ className="text-xs rounded border border-border bg-muted px-3 py-2"
+ >
+ {row.warning}
+ </p>
+ )}
+ {row.busy && (
+ <p role="status" className="text-sm rounded border border-border bg-muted px-3 py-2">
+ {row.busy}
+ </p>
+ )}
+ {note && (
+ <p role="status" aria-label="location result" className="text-sm">
+ {note}
+ </p>
+ )}
+ {error && (
+ <p role="alert" aria-label="location error" className="text-sm text-destructive">
+ {error}
+ </p>
+ )}
+
+ <div className="flex flex-wrap items-center gap-2">
+ <button
+ type="button"
+ aria-label={`refresh ${row.id}`}
+ disabled={busy || !refresh?.offered}
+ title={refresh?.withheld}
+ onClick={() => run(() => refreshStorageLocationAction(row.id))}
+ className="px-3 py-1.5 rounded-md border border-border text-sm font-medium disabled:opacity-50"
+ >
+ {busy ? "Working…" : "Refresh"}
+ </button>
+ {mount?.offered && (
+ <button
+ type="button"
+ aria-label={`mount ${row.id}`}
+ disabled={busy}
+ onClick={() => run(() => mountStorageLocationAction(row.id))}
+ className="px-3 py-1.5 rounded-md border border-border text-sm font-medium disabled:opacity-50"
+ >
+ Mount
+ </button>
+ )}
+ <button
+ type="button"
+ aria-label={`edit ${row.id}`}
+ disabled={!edit?.offered}
+ title={edit?.withheld}
+ onClick={() => setEditing((v) => !v)}
+ className="px-3 py-1.5 rounded-md border border-border text-sm font-medium disabled:opacity-50"
+ >
+ {editing ? "Close" : "Edit"}
+ </button>
+ <button
+ type="button"
+ aria-label={`delete ${row.id}`}
+ disabled={busy || !del?.offered}
+ // The withheld sentence IS the tooltip. A greyed button with no
+ // reason is the thing this page exists not to be.
+ title={del?.withheld}
+ onClick={() => {
+ if (!confirm(`Delete the storage location "${row.label}"? No files are moved or deleted.`)) {
+ return;
+ }
+ void run(() => deleteStorageLocationAction(row.id));
+ }}
+ className="px-3 py-1.5 rounded-md border border-destructive text-destructive text-sm font-medium disabled:opacity-50"
+ >
+ Delete
+ </button>
+ </div>
+ {del && !del.offered && (
+ <p className="text-xs text-muted-foreground" aria-label="delete withheld">
+ {del.withheld}
+ </p>
+ )}
+
+ {editing && (
+ <LocationForm
+ mode="edit"
+ initial={{
+ id: row.id,
+ label: row.label,
+ root: row.root,
+ autoRepoint: row.autoRepoint,
+ isDefault: row.isDefault,
+ }}
+ submit={(draft) => editStorageLocationAction(row.id, draft)}
+ onDone={() => {
+ setEditing(false);
+ onChanged();
+ }}
+ onCancel={() => setEditing(false)}
+ />
+ )}
+
+ {(repoint?.offered || ranRepointHere) && (
+ <section className="flex flex-col gap-2 rounded border border-border bg-muted/30 px-3 py-3">
+ <h3 className="text-sm font-semibold">Re-point</h3>
+ <p className="text-xs text-muted-foreground">
+ The volume is mounted at a different place than this location’s
+ root. Re-pointing rewrites each channel’s <code>data/</code>{" "}
+ symlink and its <code>dataDir</code>, then the root.{" "}
+ <strong>No bytes move</strong> — the media is already there.
+ </p>
+ <StreamActionLog
+ key="repoint-log"
+ trigger={() => {
+ setRanRepointHere(true);
+ return repointStorageLocationAction(row.id, repoint?.newRoot ?? "");
+ }}
+ cancelAction={cancelJobAction}
+ buttonLabel={repoint?.label ?? "Re-point"}
+ runningLabel="Re-pointing…"
+ label="Re-point"
+ disabled={!repoint?.offered}
+ />
+ </section>
+ )}
+ {repoint && !repoint.offered && !ranRepointHere && row.status !== "available" && (
+ <p className="text-xs text-muted-foreground" aria-label="repoint withheld">
+ {repoint.withheld}
+ </p>
+ )}
+ </article>
+ );
+}
+
+// "4 s ago" / "3 m ago". Deliberately coarse: the number is there to say
+// whether the row is answering about now or about the last time anybody looked,
+// and a second-accurate age of a ten-second memo would be false precision.
+function formatAge(ms: number): string {
+ if (ms < 1_000) return "just now";
+ const s = Math.round(ms / 1000);
+ if (s < 90) return `${s} s ago`;
+ const m = Math.round(s / 60);
+ if (m < 90) return `${m} m ago`;
+ return `${Math.round(m / 60)} h ago`;
+}
diff --git a/editor/app/storage/lib/repointJob.ts b/editor/app/storage/lib/repointJob.ts
@@ -0,0 +1,43 @@
+import { revalidatePath } from "next/cache";
+import { getPaths } from "yt-dlp-transcript-common/lib/paths";
+import type { StreamActionResult } from "yt-dlp-transcript-common/jobs/streamCommand";
+import { enqueueRepoint } from "yt-dlp-transcript-common/controller/storageLocations";
+import { channelMediaBusyReason } from "../../channels/lib/mediaBusy";
+
+// ONE ENQUEUE OF THE RE-POINT JOB, for the two callers that have one: the
+// Re-point button on /storage and the auto path a Refresh can take.
+//
+// Deliberately NOT in `actions.ts`: that file carries "use server", which means
+// every non-type export in it is a server action — a shared helper cannot live
+// there, and exporting this one from a "use server" file would put an unguarded
+// enqueue on the wire under its own endpoint. Same reason
+// `channels/lib/relocationJob.ts` exists.
+//
+// WHAT THIS FILE ADDS over the controller's `enqueueRepoint` is exactly two
+// things the controller may not have: the editor's busy check (S1's
+// `channelMediaBusyReason`, which reads the job registry AND the auto-queue
+// lanes' in-flight units — a digest unit writing a sidecar makes no job record
+// and is the failure that cost the omnimirror move 131 GB), and the page
+// revalidations. The job record's shape stays in the controller so the boot
+// pass and this cannot drift.
+export async function enqueueRepointJob(opts: {
+ locationId: string;
+ newRoot: string;
+}): Promise<StreamActionResult> {
+ const paths = getPaths();
+ return enqueueRepoint({
+ paths,
+ locationId: opts.locationId,
+ newRoot: opts.newRoot,
+ bins: paths,
+ isBusy: channelMediaBusyReason,
+ afterDone: () => {
+ // Every channel on the location now reads from a different path, so the
+ // pages that draw a media location are stale. No snapshot regen — the
+ // kind is in NO_REGEN_KINDS, and a re-point changes no count.
+ revalidatePath("/storage");
+ revalidatePath("/channels");
+ revalidatePath("/");
+ },
+ });
+}
diff --git a/editor/app/storage/page.tsx b/editor/app/storage/page.tsx
@@ -0,0 +1,38 @@
+import type { Metadata } from "next";
+import Link from "next/link";
+import { buildStorage } from "./buildStorage";
+import { StorageLocationsTable } from "./components/StorageLocationsTable";
+
+// FORCE-DYNAMIC, and not as a formality. Every number on this page comes from a
+// probe of the machine taken when the page was asked for — whether a disk is
+// mounted, where, and how much room is on it. A cached render of that is a
+// screenshot of a drive's past, and the whole reason the page exists is that
+// the drive's past is not where the operator's media is.
+export const dynamic = "force-dynamic";
+
+export const metadata: Metadata = { title: "Storage" };
+
+export default async function StoragePage() {
+ const payload = await buildStorage();
+ return (
+ <div className="flex flex-col gap-6">
+ <div className="flex items-center justify-between flex-wrap gap-2">
+ <h1 className="text-2xl font-semibold">Storage</h1>
+ </div>
+
+ <p className="text-sm text-muted-foreground max-w-3xl">
+ A storage location is a named place a channel’s media may live —
+ usually a second drive. A channel is on a location when its{" "}
+ <code>dataDir</code> is under that location’s root; nothing is
+ tagged, so moving a channel on or off one is a move, not a setting. When
+ a drive comes back at a different mountpoint, <strong>re-point</strong>{" "}
+ the location: it rewrites every channel’s symlink and{" "}
+ <code>dataDir</code> and moves no bytes. Move media onto a location from
+ a channel’s <Link href="/channels" className="underline">Storage
+ panel</Link>.
+ </p>
+
+ <StorageLocationsTable payload={payload} />
+ </div>
+ );
+}