commit cbeada8ea65ecd5d5077a5dec0a6e9dce6824bc6
parent 4a519d575571eb5b134b2689bd9c895e660130a0
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Thu, 17 Sep 2026 14:09:00 -0400
Merge storage/locations-s2: a location is an entity, not a root string
S1 (runner hardening) fast-forwarded first; the one conflict was the import block of
bulkStorageActions.ts — S1 removed getRegistry, S2 added defaultLocationRoot; both kept as
the reviewer prescribed.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
13 files changed, 1306 insertions(+), 95 deletions(-)
diff --git a/common/lib/paths.ts b/common/lib/paths.ts
@@ -115,6 +115,15 @@ export type Paths = {
// (Phase 4 of the video-persistence feature). See
// common/controller/backupSavedVideos.ts.
rsyncBin: string;
+ // findmnt (util-linux): the read-only identity probe behind storage
+ // locations — which volume a root is on, and where a UUID is mounted now.
+ // See common/lib/storageVolumes.ts. Never required: every call fails open to
+ // "identity unknown", which is also what a container answers.
+ findmntBin: string;
+ // udisksctl: the ONE storage subprocess that changes the machine — mounting
+ // an attached-but-unmounted volume by UUID, offered on /storage. Optional in
+ // the same way findmnt is; without it the button is simply not offered.
+ udisksctlBin: string;
// gallery-dl, the primary X/Twitter post fetcher (see
// common/social/xGalleryDlFetcher.ts). A light headless subprocess — the same
// shape the codebase already manages for yt-dlp and whisper. NOT bundled;
@@ -224,6 +233,8 @@ export function getPaths(): Paths {
ffmpegBin: process.env.FFMPEG_BIN ?? "ffmpeg",
ffprobeBin: process.env.FFPROBE_BIN ?? "ffprobe",
rsyncBin: process.env.RSYNC_BIN ?? "rsync",
+ findmntBin: process.env.FINDMNT_BIN ?? "findmnt",
+ udisksctlBin: process.env.UDISKSCTL_BIN ?? "udisksctl",
galleryDlBin: process.env.GALLERY_DL_BIN ?? "gallery-dl",
parakeetBin:
process.env.PARAKEET_STITCH_BIN ??
diff --git a/common/lib/settings.ts b/common/lib/settings.ts
@@ -29,6 +29,12 @@ import {
migrateHeldToLanes,
migrateSweepsToLanes,
} from "./laneMigration";
+import {
+ migrateMediaRootToLocations,
+ type StorageLocation,
+ type StorageSettings,
+ type StorageVolume,
+} from "./storageLocations";
// The four SANITIZERS still come from the engine. They are the auto-queue's
// half of the settings schema and belong in lib/ with the rest of it, but that
// move is phase 3 slice 4 (one schema, one writer) — not a rename. Recorded in
@@ -840,45 +846,120 @@ export function sanitizeSavedVideoBackup(
};
}
-// Where relocated channel media goes by default.
+// Where relocated channel media goes: the named locations.
//
-// ONE FIELD, and deliberately no more. A channel's media is moved by
-// common/controller/relocateChannelMedia.ts, which ALWAYS takes an explicit
-// root: this setting is never read there. It is read by the two places an
-// operator picks a root — the channel's Storage panel (which prefills its input
-// with it) and the /channels bulk move (which falls back to it when the bulk
-// bar's input is blank) — so "the cold drive" is typed once instead of once per
-// channel. It is not a policy: a relocated channel is not thereby
-// deprioritized, and nothing auto-relocates anything because this is set.
-export type StorageSettings = {
- // Absolute directory holding relocated channels, one `<slug>/data` under it.
- // Blank = no default; every move then names its own root.
- mediaRoot: string;
-};
+// This used to be ONE FIELD, `mediaRoot` — a single absolute string, the cold
+// drive, typed once. It grew into a list of entities because a root alone
+// cannot answer the two questions the operator actually has: is that disk here,
+// and if it came up somewhere else, how do I point the channels at it without
+// ssh and hand edits? A location carries an id, a label, the root, an opt-in
+// `autoRepoint`, and the volume identity learned at its last probe.
+//
+// Still NOT a policy: a channel on a location is not thereby deprioritized, and
+// nothing auto-relocates anything because a location exists.
+//
+// AVAILABILITY IS NEVER STORED HERE. A refresh that wrote "available" would
+// rewrite settings.json — and so bump the pulse revision — every few seconds.
+// The probe (common/lib/storageVolumes.ts) is computed per request; only the
+// `volume` identity is ever written back, and only when it changed.
+//
+// The types live in lib/storageLocations.ts, which is pure: a `"use client"`
+// file may import them, and must not reach storageVolumes.ts (execa).
+export type { StorageLocation, StorageVolume, StorageSettings };
export function defaultStorage(): StorageSettings {
- return { mediaRoot: "" };
+ return { locations: [], defaultLocationId: "" };
+}
+
+const LOCATION_ID_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
+
+function sanitizeVolume(value: unknown): StorageVolume | undefined {
+ if (!value || typeof value !== "object") return undefined;
+ const v = value as Record<string, unknown>;
+ const uuid = typeof v.uuid === "string" ? v.uuid.trim() : "";
+ const mountpoint =
+ typeof v.mountpoint === "string" ? v.mountpoint.trim() : "";
+ // No uuid is no identity, and no mountpoint means `root === join(mountpoint,
+ // relPath)` cannot hold — either way the record is not usable for finding the
+ // volume again, so it is dropped rather than half-kept.
+ if (!uuid || !mountpoint) return undefined;
+ const relPath = typeof v.relPath === "string" ? v.relPath.trim() : "";
+ const fstype = typeof v.fstype === "string" ? v.fstype.trim() : "";
+ const label = typeof v.label === "string" ? v.label.trim() : "";
+ return {
+ uuid,
+ ...(fstype ? { fstype } : {}),
+ ...(label ? { label } : {}),
+ mountpoint,
+ relPath,
+ };
}
// Coerce a raw settings.storage value into a clean StorageSettings.
//
-// EXISTENCE IS NOT CHECKED, on purpose: the whole point of a cold root is that
-// it is a drive that may not be mounted when settings are read, and a sanitizer
-// that dropped the field on an unmounted platter would silently erase the
-// operator's choice on the next save.
+// EXISTENCE IS NOT CHECKED, on purpose: the whole point of a cold location is
+// that it is a drive that may not be mounted when settings are read, and a
+// sanitizer that dropped the root on an unmounted platter would silently erase
+// the operator's choice on the next save.
+//
+// ABSOLUTENESS *IS* checked, and a location with a relative root is DROPPED
+// rather than resolved. Resolving it would anchor the location to whatever cwd
+// the reader booted in — a different directory under docker, under a worktree,
+// and under `pnpm dev` — so the same settings.json would name three different
+// drives. The location form rejects a relative path with a message before it
+// ever gets here; this is the last line, not the only one.
+//
+// NESTED ROOTS ARE ALLOWED. "/mnt/platter" and "/mnt/platter/archive" may both
+// be locations; `locationOfDataDir` resolves a channel to the LONGEST matching
+// root. Nothing here rejects the nesting, because the operator who arranges a
+// disk that way means it.
+//
+// A STALE `mediaRoot` SITTING BESIDE `locations` IS IGNORED — it is not merged
+// back in as an extra location. `migrateMediaRootToLocations` reads it exactly
+// once, when `locations` is absent; after that the list is the whole truth, and
+// resurrecting a root the operator deleted would be a bug, not a kindness.
//
-// ABSOLUTENESS *IS* checked, and a relative value is dropped to blank rather
-// than resolved. Resolving it would anchor the default to whatever cwd the
-// editor happened to boot in — a different directory under docker, under a
-// worktree, and under `pnpm dev` — so the same settings.json would name three
-// different drives. The settings form rejects a relative path with a message
-// before it ever gets here; this is the last line, not the only one.
+// ROLLBACK: an older binary sanitizes this block to `{ mediaRoot: "" }` — the
+// locations are dropped and the single cold root comes back blank. One string
+// lost, nothing on disk moved. `cp settings.json settings.json.pre-storage-
+// locations` before the upgrade and a downgrade is a file copy.
export function sanitizeStorage(value: unknown): StorageSettings {
const d = defaultStorage();
if (!value || typeof value !== "object") return d;
const r = value as Record<string, unknown>;
- const mediaRoot = typeof r.mediaRoot === "string" ? r.mediaRoot.trim() : "";
- return { mediaRoot: path.isAbsolute(mediaRoot) ? mediaRoot : "" };
+ const rawList = Array.isArray(r.locations) ? r.locations : [];
+ const locations: StorageLocation[] = [];
+ const seen = new Set<string>();
+ for (const entry of rawList) {
+ if (!entry || typeof entry !== "object") continue;
+ const e = entry as Record<string, unknown>;
+ const id = typeof e.id === "string" ? e.id.trim() : "";
+ if (!LOCATION_ID_RE.test(id) || seen.has(id)) continue;
+ const rawRoot = typeof e.root === "string" ? e.root.trim() : "";
+ if (!path.isAbsolute(rawRoot)) continue;
+ // "/mnt/platter/" and "/mnt/platter" are one root; "/" stays "/".
+ const stripped = rawRoot.replace(/\/+$/, "");
+ const root = stripped === "" ? "/" : stripped;
+ const label = typeof e.label === "string" ? e.label.trim() : "";
+ const volume = sanitizeVolume(e.volume);
+ seen.add(id);
+ locations.push({
+ id,
+ label: label || id,
+ root,
+ autoRepoint: e.autoRepoint === true,
+ ...(volume ? { volume } : {}),
+ });
+ }
+ const wanted =
+ typeof r.defaultLocationId === "string" ? r.defaultLocationId.trim() : "";
+ // A default naming a location that is gone falls back to the first one, not
+ // to "": with a location configured, "no default" is never the answer the
+ // operator wanted, and a blank default silently disables every prefill.
+ const defaultLocationId = locations.some((l) => l.id === wanted)
+ ? wanted
+ : (locations[0]?.id ?? "");
+ return { locations, defaultLocationId };
}
// 4 hours. Measured: videos over this are 8.2% of the corpus by count but hold
@@ -1472,7 +1553,17 @@ export function getSettings(): SiteSettings {
merged.socialLinks = parseSocialLinks(merged.socialLinks);
merged.homepageUrl = normalizeHomepageUrl(merged.homepageUrl);
merged.savedVideoBackup = sanitizeSavedVideoBackup(merged.savedVideoBackup);
- merged.storage = sanitizeStorage(merged.storage);
+ // THE COLD ROOT, ON READ. `storage.mediaRoot` — one absolute string — becomes
+ // a one-entry location list. Handed `parsed.storage` rather than
+ // `merged.storage` for the same reason `migrateSweepsToLanes` is handed
+ // `parsed`: `merged` has had `defaultStorage()` folded in and can no longer
+ // tell "the file has no locations key" from "the file has an empty list", and
+ // the migration must only fire on the former. See lib/storageLocations.ts.
+ merged.storage = sanitizeStorage(
+ migrateMediaRootToLocations(
+ (parsed as Record<string, unknown>).storage ?? merged.storage,
+ ),
+ );
merged.buildPipeline = sanitizeBuildPipeline(merged.buildPipeline);
merged.digest = sanitizeDigest(merged.digest);
merged.diarization = sanitizeDiarization(merged.diarization);
diff --git a/common/lib/storageLocations.test.ts b/common/lib/storageLocations.test.ts
@@ -0,0 +1,114 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import {
+ defaultLocationRoot,
+ locationOfDataDir,
+ migrateMediaRootToLocations,
+ type StorageLocation,
+} from "./storageLocations";
+
+function loc(id: string, root: string): StorageLocation {
+ return { id, label: id, root, autoRepoint: false };
+}
+
+const PLATTER = loc("platter", "/mnt/platter");
+const ARCHIVE = loc("archive", "/mnt/platter/archive");
+
+test("a channel is on the location its dataDir is under", () => {
+ assert.equal(
+ locationOfDataDir("/mnt/platter/alpha/data", [PLATTER])?.id,
+ "platter",
+ );
+ assert.equal(locationOfDataDir("/corpus/channels/alpha/data", [PLATTER]), null);
+ assert.equal(locationOfDataDir("", [PLATTER]), null);
+ // A sibling whose name merely starts the same is not under it.
+ assert.equal(locationOfDataDir("/mnt/platter-old/alpha/data", [PLATTER]), null);
+ // "Under" is strict: the root itself is not a channel's dataDir.
+ assert.equal(locationOfDataDir("/mnt/platter", [PLATTER]), null);
+});
+
+test("nested roots: the longest match wins, whatever the list order", () => {
+ const deep = "/mnt/platter/archive/alpha/data";
+ assert.equal(locationOfDataDir(deep, [PLATTER, ARCHIVE])?.id, "archive");
+ assert.equal(locationOfDataDir(deep, [ARCHIVE, PLATTER])?.id, "archive");
+ // Still the outer one for a channel that is not in the nested root.
+ assert.equal(
+ locationOfDataDir("/mnt/platter/beta/data", [PLATTER, ARCHIVE])?.id,
+ "platter",
+ );
+});
+
+test("trailing slashes on either side are one root", () => {
+ assert.equal(
+ locationOfDataDir("/mnt/platter/alpha/data/", [loc("p", "/mnt/platter/")])
+ ?.id,
+ "p",
+ );
+});
+
+test("defaultLocationRoot resolves the id, or blanks", () => {
+ assert.equal(
+ defaultLocationRoot({
+ locations: [PLATTER, ARCHIVE],
+ defaultLocationId: "archive",
+ }),
+ "/mnt/platter/archive",
+ );
+ // A default naming nothing (an empty list, or an id the sanitizer would have
+ // repaired) is "no default root", which every caller already handles.
+ assert.equal(
+ defaultLocationRoot({ locations: [], defaultLocationId: "" }),
+ "",
+ );
+ assert.equal(
+ defaultLocationRoot({ locations: [PLATTER], defaultLocationId: "gone" }),
+ "",
+ );
+});
+
+test("migration rule 1: a file that already spells locations is untouched", () => {
+ const already = {
+ locations: [{ id: "cold", label: "Cold", root: "/mnt/cold" }],
+ defaultLocationId: "cold",
+ // A stale mediaRoot beside it is NOT merged in as a second location.
+ mediaRoot: "/mnt/deleted",
+ };
+ assert.deepEqual(migrateMediaRootToLocations(already), already);
+ // An EMPTY list is also "already spelled" — the operator deleted them all.
+ const empty = { locations: [], defaultLocationId: "", mediaRoot: "/mnt/x" };
+ assert.deepEqual(migrateMediaRootToLocations(empty), empty);
+});
+
+test("migration rule 2: blank, missing or relative mediaRoot is no location", () => {
+ const none = { locations: [], defaultLocationId: "" };
+ assert.deepEqual(migrateMediaRootToLocations({}), none);
+ assert.deepEqual(migrateMediaRootToLocations({ mediaRoot: "" }), none);
+ assert.deepEqual(migrateMediaRootToLocations({ mediaRoot: " " }), none);
+ assert.deepEqual(migrateMediaRootToLocations({ mediaRoot: 7 }), none);
+ assert.deepEqual(migrateMediaRootToLocations({ mediaRoot: "platter" }), none);
+ assert.deepEqual(migrateMediaRootToLocations({ mediaRoot: "../up" }), none);
+ // Not an object at all: handed straight back for the sanitizer to default.
+ assert.equal(migrateMediaRootToLocations(undefined), undefined);
+ assert.equal(migrateMediaRootToLocations("/mnt/platter"), "/mnt/platter");
+});
+
+test("migration rule 3: an absolute mediaRoot becomes the default location", () => {
+ assert.deepEqual(migrateMediaRootToLocations({ mediaRoot: "/mnt/platter" }), {
+ locations: [
+ {
+ id: "default",
+ label: "Default",
+ root: "/mnt/platter",
+ // Never armed by a migration: re-point rewrites every channel symlink
+ // on the location and nobody asked for that.
+ autoRepoint: false,
+ },
+ ],
+ defaultLocationId: "default",
+ });
+});
+
+test("migration is idempotent", () => {
+ const once = migrateMediaRootToLocations({ mediaRoot: "/mnt/platter" });
+ assert.deepEqual(migrateMediaRootToLocations(once), once);
+});
diff --git a/common/lib/storageLocations.ts b/common/lib/storageLocations.ts
@@ -0,0 +1,155 @@
+import path from "node:path";
+
+// STORAGE LOCATIONS — the named places a channel's media may live.
+//
+// This module is PURE, and deliberately so: it is the only storage module a
+// `"use client"` file is allowed to import (for the types, and for
+// `locationOfDataDir` when a table projects "on Platter" from a channel's
+// `dataDir`). Anything that shells out — every identity and availability probe
+// — lives in `storageVolumes.ts`, which imports execa and must therefore never
+// be reachable from a client component (`next build` enforces that).
+//
+// The entity is stored in `settings.storage`. A channel is NOT tagged with its
+// location: it is on location L iff its `config.dataDir` is under `L.root`.
+// That is a derivation, not a field, which is why a re-point only has to
+// rewrite the location's root and each channel's symlink — there is no second
+// copy of the association to keep in step, and `ChannelConfig`'s whitelisted
+// round-trip (`channelConfig.ts`) needs no new key.
+
+export type StorageVolume = {
+ // Filesystem UUID, the one stable name a disk has across mountpoints. This is
+ // what makes "the platter came up somewhere else" a recoverable situation.
+ uuid: string;
+ fstype?: string;
+ label?: string;
+ // Where the volume was mounted at the last successful probe, and the path of
+ // the location's root RELATIVE to that mountpoint. Invariant:
+ // `root === join(mountpoint, relPath)`. Keeping the two halves is what lets a
+ // probe compute a candidate root when the volume reappears elsewhere.
+ mountpoint: string;
+ relPath: string;
+};
+
+export type StorageLocation = {
+ // /^[a-z0-9][a-z0-9-]{0,63}$/, unique within the list. Stable: it is what
+ // `defaultLocationId` and every form and action refer to.
+ id: string;
+ // Human name. Blank sanitizes to the id.
+ label: string;
+ // Absolute directory, trailing "/" stripped. NEVER existence-checked on read
+ // — the whole point of a cold location is a drive that may not be mounted
+ // when settings are parsed.
+ root: string;
+ // Opt-in: when the volume is found mounted somewhere else, re-point without
+ // asking (if the preflight passes). Off by default — re-point rewrites every
+ // channel symlink on the location, and that is not something to do silently
+ // unless the operator asked for it.
+ autoRepoint: boolean;
+ // Identity learned at the last successful probe. Optional because a location
+ // may never have been probed, and because in a container block devices are
+ // invisible and identity is permanently unknown.
+ volume?: StorageVolume;
+};
+
+export type StorageSettings = {
+ locations: StorageLocation[];
+ // The location prefilled as the destination of a move. "" = no default.
+ defaultLocationId: string;
+};
+
+// Strip trailing slashes so "/mnt/platter/" and "/mnt/platter" are one root.
+// The sanitizer does this on write too; this is here so a hand-edited
+// settings.json still compares correctly.
+function normalizeRoot(root: string): string {
+ const trimmed = root.trim();
+ if (trimmed === "") return "";
+ const stripped = trimmed.replace(/\/+$/, "");
+ // "/" strips to "" — keep it as "/", which is a legitimate (if daft) root.
+ return stripped === "" ? "/" : stripped;
+}
+
+// Which location a channel's `dataDir` sits on, or null when it sits on none
+// (the ordinary case: an unrelocated channel's data is inside the corpus).
+//
+// NESTED ROOTS ARE ALLOWED and the LONGEST match wins. "/mnt/platter" and
+// "/mnt/platter/archive" can both be locations; a channel under the latter is
+// on the latter, not on both and not on whichever the operator happened to add
+// first. Two locations sharing one root is a misconfiguration the sanitizer
+// does not forbid; the first in the list wins it.
+//
+// "Under" is strict: `dataDir === root` is not a match. A channel's dataDir is
+// always `<root>/<slug>/data`, so equality only ever means a misconfiguration.
+export function locationOfDataDir(
+ dataDir: string,
+ locations: StorageLocation[],
+): StorageLocation | null {
+ const dir = normalizeRoot(dataDir);
+ if (dir === "") return null;
+ let best: StorageLocation | null = null;
+ for (const loc of locations) {
+ const root = normalizeRoot(loc.root);
+ if (root === "" || root === dir) continue;
+ const prefix = root === "/" ? "/" : `${root}/`;
+ if (!dir.startsWith(prefix)) continue;
+ if (!best || normalizeRoot(best.root).length < root.length) best = loc;
+ }
+ return best;
+}
+
+// The root of the default location, or "" when there is none. This is the
+// one-line replacement for every `settings.storage.mediaRoot` read: the Storage
+// panel's prefill, the bulk move's fallback, the selection deck's box.
+export function defaultLocationRoot(storage: StorageSettings): string {
+ const found = storage.locations.find(
+ (l) => l.id === storage.defaultLocationId,
+ );
+ return found ? found.root : "";
+}
+
+// THE RETIRED FIELD, ON READ. `settings.storage.mediaRoot` — one absolute
+// string, the single cold root — becomes a one-entry location list. Same shape
+// as the lane migrations in `laneMigration.ts` and for the same reason: the
+// editor is not the only reader of settings.json (bin/ scripts, the MCP server
+// and the export build all call getSettings), so a migration that only ran when
+// someone opened a page would give two readers two different answers.
+//
+// Four rules:
+//
+// 1. IT ONLY RUNS WHEN `locations` IS ABSENT. A file that already spells the
+// new shape is returned untouched, and a stale `mediaRoot` sitting beside
+// it is ignored (the sanitizer drops it) — not merged in as a second
+// location, which would resurrect a root the operator deleted.
+// 2. BLANK (or missing, or not a string) → the EMPTY list. "No default root"
+// is a legitimate state and it must not become a location named "".
+// 3. ABSOLUTE → exactly one location, `{ id: "default", label: "Default",
+// root, autoRepoint: false }`, and it is the default. `autoRepoint` is
+// false because migration must never arm a behaviour nobody asked for.
+// 4. RELATIVE → treated as blank, for the reason `sanitizeStorage` never
+// resolved one: it would anchor the location to whatever cwd the reader
+// booted in, and the same settings.json would then name three directories.
+//
+// Idempotent: run it on its own output and rule 1 returns it unchanged.
+//
+// Takes and returns `unknown` because it runs on the PARSED block, before
+// sanitizing — `merged` has had defaults folded in and can no longer tell
+// "absent" from "default".
+export function migrateMediaRootToLocations(raw: unknown): unknown {
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return raw;
+ const r = raw as Record<string, unknown>;
+ if (r.locations !== undefined) return raw;
+ const mediaRoot = typeof r.mediaRoot === "string" ? r.mediaRoot.trim() : "";
+ if (mediaRoot === "" || !path.isAbsolute(mediaRoot)) {
+ return { locations: [], defaultLocationId: "" };
+ }
+ return {
+ locations: [
+ {
+ id: "default",
+ label: "Default",
+ root: mediaRoot,
+ autoRepoint: false,
+ },
+ ],
+ defaultLocationId: "default",
+ };
+}
diff --git a/common/lib/storageSettings.test.ts b/common/lib/storageSettings.test.ts
@@ -1,4 +1,4 @@
-// THE COLD-STORAGE DEFAULT, and the two things its sanitizer refuses to do.
+// THE STORAGE BLOCK, and the things its sanitizer refuses to do.
//
// Run with: node_modules/.bin/tsx --test common/lib/storageSettings.test.ts
//
@@ -6,61 +6,251 @@
// module scope (see controller/laneForOperation.test.ts), and sanitizeStorage is
// a pure function that needs none of that seam. Naming the file after the block
// keeps it that way.
+//
+// The migration's own rules live in storageLocations.test.ts; what is asserted
+// here is the pair — migrate, then sanitize — which is the composition
+// getSettings performs on every read.
import { test } from "node:test";
import assert from "node:assert/strict";
import { defaultStorage, sanitizeStorage } from "./settings";
+import { migrateMediaRootToLocations } from "./storageLocations";
+
+const NONE = { locations: [], defaultLocationId: "" };
-test("absent, blank and ill-typed all mean no default root", () => {
- assert.deepEqual(sanitizeStorage(undefined), { mediaRoot: "" });
- assert.deepEqual(sanitizeStorage(null), { mediaRoot: "" });
- assert.deepEqual(sanitizeStorage("/mnt/platter"), { mediaRoot: "" });
- assert.deepEqual(sanitizeStorage({}), { mediaRoot: "" });
- assert.deepEqual(sanitizeStorage({ mediaRoot: "" }), { mediaRoot: "" });
- assert.deepEqual(sanitizeStorage({ mediaRoot: " " }), { mediaRoot: "" });
- assert.deepEqual(sanitizeStorage({ mediaRoot: 7 }), { mediaRoot: "" });
- assert.deepEqual(defaultStorage(), { mediaRoot: "" });
+test("absent, blank and ill-typed all mean no locations", () => {
+ assert.deepEqual(sanitizeStorage(undefined), NONE);
+ assert.deepEqual(sanitizeStorage(null), NONE);
+ assert.deepEqual(sanitizeStorage("/mnt/platter"), NONE);
+ assert.deepEqual(sanitizeStorage({}), NONE);
+ assert.deepEqual(sanitizeStorage({ locations: "nope" }), NONE);
+ assert.deepEqual(sanitizeStorage({ locations: [null, 7, "x"] }), NONE);
+ assert.deepEqual(defaultStorage(), NONE);
});
-test("an absolute root is kept, trimmed", () => {
- assert.deepEqual(sanitizeStorage({ mediaRoot: "/mnt/platter/media" }), {
- mediaRoot: "/mnt/platter/media",
- });
- assert.deepEqual(sanitizeStorage({ mediaRoot: " /mnt/platter/media \n" }), {
- mediaRoot: "/mnt/platter/media",
- });
+test("a location keeps its id, label, root and autoRepoint", () => {
+ const one = {
+ id: "platter",
+ label: "Platter",
+ root: "/mnt/platter/archilyzer-media",
+ autoRepoint: true,
+ };
+ assert.deepEqual(
+ sanitizeStorage({ locations: [one], defaultLocationId: "platter" }),
+ { locations: [one], defaultLocationId: "platter" },
+ );
});
-// DROPPED, NOT RESOLVED. Resolving would anchor the default to whatever cwd the
-// editor booted in — different under docker, under a worktree and under `pnpm
-// dev` — so one settings.json would name three different drives.
-test("a relative root is dropped rather than resolved", () => {
- assert.deepEqual(sanitizeStorage({ mediaRoot: "platter/media" }), {
- mediaRoot: "",
- });
- assert.deepEqual(sanitizeStorage({ mediaRoot: "../platter" }), {
- mediaRoot: "",
- });
- assert.deepEqual(sanitizeStorage({ mediaRoot: "./platter" }), {
- mediaRoot: "",
- });
+test("the id is a slug, unique, and a bad one drops the location", () => {
+ const ids = (v: unknown) => sanitizeStorage(v).locations.map((l) => l.id);
+ assert.deepEqual(
+ ids({
+ locations: [
+ { id: "Platter", root: "/a" }, // uppercase
+ { id: "-platter", root: "/b" }, // leading dash
+ { id: "cold drive", root: "/c" }, // space
+ { id: "", root: "/d" },
+ { id: 7, root: "/e" },
+ { id: "a".repeat(65), root: "/f" },
+ { id: "platter2", root: "/g" },
+ ],
+ }),
+ ["platter2"],
+ );
+ // First wins; the duplicate is dropped rather than overwriting it.
+ assert.deepEqual(
+ sanitizeStorage({
+ locations: [
+ { id: "cold", root: "/first" },
+ { id: "cold", root: "/second" },
+ ],
+ }).locations.map((l) => l.root),
+ ["/first"],
+ );
});
-// EXISTENCE IS NEVER CHECKED: the whole point of a cold root is a drive that may
-// be unmounted when settings are read, and a sanitizer that dropped it then
-// would erase the operator's choice on the next save.
+test("a blank label falls back to the id", () => {
+ const [l] = sanitizeStorage({
+ locations: [{ id: "cold", label: " ", root: "/mnt/cold" }],
+ }).locations;
+ assert.equal(l.label, "cold");
+});
+
+// ABSOLUTENESS IS CHECKED and a relative root is DROPPED, never resolved —
+// resolving would anchor the location to whatever cwd the reader booted in.
+test("a relative root drops the whole location", () => {
+ assert.deepEqual(
+ sanitizeStorage({
+ locations: [
+ { id: "rel", root: "platter/media" },
+ { id: "up", root: "../platter" },
+ { id: "dot", root: "./platter" },
+ { id: "blank", root: "" },
+ { id: "num", root: 7 },
+ ],
+ }),
+ NONE,
+ );
+});
+
+test("a trailing slash is stripped, and '/' survives as '/'", () => {
+ assert.deepEqual(
+ sanitizeStorage({
+ locations: [
+ { id: "a", root: " /mnt/platter/media/ " },
+ { id: "b", root: "/mnt/other///" },
+ { id: "c", root: "/" },
+ ],
+ }).locations.map((l) => l.root),
+ ["/mnt/platter/media", "/mnt/other", "/"],
+ );
+});
+
+// EXISTENCE IS NEVER CHECKED: the whole point of a cold location is a drive
+// that may be unmounted when settings are read, and a sanitizer that dropped it
+// then would erase the operator's choice on the next save.
test("a root that does not exist is kept", () => {
+ assert.equal(
+ sanitizeStorage({
+ locations: [{ id: "cold", root: "/definitely/not/mounted/anywhere" }],
+ }).locations[0].root,
+ "/definitely/not/mounted/anywhere",
+ );
+});
+
+// NESTED ROOTS ARE ALLOWED — locationOfDataDir resolves a channel to the
+// longest match. The sanitizer does not police the arrangement.
+test("nested roots are both kept", () => {
assert.deepEqual(
- sanitizeStorage({ mediaRoot: "/definitely/not/mounted/anywhere" }),
- { mediaRoot: "/definitely/not/mounted/anywhere" },
+ sanitizeStorage({
+ locations: [
+ { id: "platter", root: "/mnt/platter" },
+ { id: "archive", root: "/mnt/platter/archive" },
+ ],
+ }).locations.map((l) => l.id),
+ ["platter", "archive"],
+ );
+});
+
+test("the volume identity is shape-checked, and half a record is no record", () => {
+ const vol = (v: unknown) =>
+ sanitizeStorage({ locations: [{ id: "c", root: "/mnt/c", volume: v }] })
+ .locations[0].volume;
+ assert.deepEqual(
+ vol({
+ uuid: "u-1",
+ fstype: "ext4",
+ label: "PLATTER",
+ mountpoint: "/mnt/c",
+ relPath: "",
+ bogus: 1,
+ }),
+ {
+ uuid: "u-1",
+ fstype: "ext4",
+ label: "PLATTER",
+ mountpoint: "/mnt/c",
+ relPath: "",
+ },
+ );
+ // No uuid = no stable name to find the volume by; no mountpoint = the
+ // root === join(mountpoint, relPath) invariant cannot hold. Either drops it.
+ assert.equal(vol({ mountpoint: "/mnt/c", relPath: "" }), undefined);
+ assert.equal(vol({ uuid: "u-1", relPath: "" }), undefined);
+ assert.equal(vol("nope"), undefined);
+ assert.equal(vol(undefined), undefined);
+});
+
+test("defaultLocationId falls back to the first location, or blank", () => {
+ const two = [
+ { id: "hot", root: "/mnt/hot" },
+ { id: "cold", root: "/mnt/cold" },
+ ];
+ assert.equal(
+ sanitizeStorage({ locations: two, defaultLocationId: "cold" })
+ .defaultLocationId,
+ "cold",
+ );
+ // Names a location that is gone (or names nothing): with locations
+ // configured, "no default" is never what the operator wanted, and a blank
+ // default silently disables every prefill.
+ assert.equal(
+ sanitizeStorage({ locations: two, defaultLocationId: "deleted" })
+ .defaultLocationId,
+ "hot",
+ );
+ assert.equal(sanitizeStorage({ locations: two }).defaultLocationId, "hot");
+ // Nothing to fall back to.
+ assert.equal(
+ sanitizeStorage({ locations: [], defaultLocationId: "hot" })
+ .defaultLocationId,
+ "",
);
});
// Unknown keys are not carried through: the block is a closed shape, so a
// settings.json written by a newer build cannot smuggle a field back onto disk.
-test("only mediaRoot survives", () => {
+test("only the known keys survive, and a stale mediaRoot is ignored", () => {
+ assert.deepEqual(
+ sanitizeStorage({
+ locations: [{ id: "cold", root: "/mnt/a", tier: "cold", extra: 1 }],
+ defaultLocationId: "cold",
+ mediaRoot: "/mnt/deleted",
+ }),
+ {
+ locations: [
+ { id: "cold", label: "cold", root: "/mnt/a", autoRepoint: false },
+ ],
+ defaultLocationId: "cold",
+ },
+ );
+});
+
+// THE PAIR getSettings RUNS. Rules 1-3 are asserted on the migration itself in
+// storageLocations.test.ts; here they are asserted through the sanitizer, which
+// is the only form any reader ever sees.
+test("migrate-then-sanitize: an old mediaRoot becomes the default location", () => {
+ assert.deepEqual(
+ sanitizeStorage(
+ migrateMediaRootToLocations({ mediaRoot: "/mnt/platter/" }),
+ ),
+ {
+ locations: [
+ {
+ id: "default",
+ label: "Default",
+ root: "/mnt/platter",
+ autoRepoint: false,
+ },
+ ],
+ defaultLocationId: "default",
+ },
+ );
+});
+
+test("migrate-then-sanitize: blank, relative and absent mean no locations", () => {
+ for (const raw of [
+ { mediaRoot: "" },
+ { mediaRoot: " " },
+ { mediaRoot: "platter" },
+ {},
+ undefined,
+ ]) {
+ assert.deepEqual(sanitizeStorage(migrateMediaRootToLocations(raw)), NONE);
+ }
+});
+
+test("migrate-then-sanitize is idempotent, and never resurrects mediaRoot", () => {
+ const once = sanitizeStorage(
+ migrateMediaRootToLocations({ mediaRoot: "/mnt/platter" }),
+ );
+ const twice = sanitizeStorage(migrateMediaRootToLocations(once));
+ assert.deepEqual(twice, once);
+ // The stored block still carrying the retired key does NOT gain a second
+ // location on a later read: `locations` present means the migration is done.
+ const withStale = { ...once, mediaRoot: "/mnt/deleted" };
assert.deepEqual(
- sanitizeStorage({ mediaRoot: "/mnt/a", tier: "cold", extra: 1 }),
- { mediaRoot: "/mnt/a" },
+ sanitizeStorage(migrateMediaRootToLocations(withStale)),
+ once,
);
});
diff --git a/common/lib/storageVolumes.test.ts b/common/lib/storageVolumes.test.ts
@@ -0,0 +1,270 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import path from "node:path";
+import { tmpdir } from "node:os";
+import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
+import { probeLocation, type VolumeBins } from "./storageVolumes";
+import type { StorageLocation } from "./storageLocations";
+
+// THE FAKE FINDMNT. A real one would need a real disk; this one is a node
+// script that reads a control file next to itself and answers the three
+// questions probeLocation asks (`-T <path>`, `-S UUID=`, `--fstab -S UUID=`).
+// Same idea as the e2e fixtures' fake bins (editor/e2e/fixtures/bin), scoped to
+// one mkdtemp so nothing leaks between tests.
+const FAKE = `#!/usr/bin/env node
+import { readFileSync } from "node:fs";
+import path from "node:path";
+const control = JSON.parse(
+ readFileSync(path.join(import.meta.dirname, "control.json"), "utf8"),
+);
+const argv = process.argv.slice(2);
+if (control.sleepMs) {
+ // Busy-wait: a sleeping child that ignores SIGTERM is not what we are
+ // testing, and Atomics.wait is the portable blocking sleep.
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, control.sleepMs);
+}
+if (argv.includes("--fstab")) {
+ if (!control.fstab) process.exit(1);
+ process.stdout.write(control.fstab + "\\n");
+ process.exit(0);
+}
+if (argv.includes("-S")) {
+ if (!control.uuidTarget) process.exit(1);
+ process.stdout.write(control.uuidTarget + "\\n");
+ process.exit(0);
+}
+if (control.identity === null) process.exit(1);
+process.stdout.write(
+ JSON.stringify({ filesystems: [control.identity] }) + "\\n",
+);
+`;
+
+type Control = {
+ // What `findmnt -J -T <root>` answers; null = exit 1.
+ identity?: Record<string, unknown> | null;
+ // What `findmnt -rn -S UUID=… -o TARGET` answers; absent = exit 1 (nowhere).
+ uuidTarget?: string;
+ // What `findmnt --fstab -S UUID=…` answers; absent = exit 1 (not in fstab).
+ fstab?: string;
+ sleepMs?: number;
+};
+
+type Harness = {
+ dir: string;
+ bins: VolumeBins;
+ // Directory standing in for /dev/disk/by-uuid.
+ byUuidDir: string;
+ control: (c: Control) => Promise<void>;
+};
+
+async function withHarness(fn: (h: Harness) => Promise<void>): Promise<void> {
+ const dir = await mkdtemp(path.join(tmpdir(), "ttb-volumes-"));
+ const bin = path.join(dir, "fake-findmnt.mjs");
+ await writeFile(bin, FAKE);
+ await chmod(bin, 0o755);
+ const byUuidDir = path.join(dir, "by-uuid");
+ await mkdir(byUuidDir, { recursive: true });
+ try {
+ await fn({
+ dir,
+ byUuidDir,
+ bins: { findmntBin: bin, udisksctlBin: path.join(dir, "no-udisksctl") },
+ control: (c) =>
+ writeFile(path.join(dir, "control.json"), JSON.stringify(c)),
+ });
+ } finally {
+ await rm(dir, { recursive: true, force: true });
+ }
+}
+
+const UUID = "11111111-2222-3333-4444-555555555555";
+
+function loc(root: string, withVolume = false): StorageLocation {
+ return {
+ id: "platter",
+ label: "Platter",
+ root,
+ autoRepoint: false,
+ ...(withVolume
+ ? {
+ volume: {
+ uuid: UUID,
+ fstype: "ext4",
+ mountpoint: "/mnt/platter",
+ relPath: "archilyzer-media",
+ },
+ }
+ : {}),
+ };
+}
+
+test("available: the root is a directory, with identity and free bytes", async () => {
+ await withHarness(async (h) => {
+ const root = path.join(h.dir, "mnt", "platter", "archilyzer-media");
+ await mkdir(root, { recursive: true });
+ await h.control({
+ identity: {
+ target: path.join(h.dir, "mnt", "platter"),
+ fstype: "ext4",
+ label: "PLATTER",
+ uuid: UUID,
+ },
+ fstab: `UUID=${UUID}`,
+ });
+ const p = await probeLocation(loc(root), h.bins, {
+ byUuidDir: h.byUuidDir,
+ });
+ assert.equal(p.status, "available");
+ assert.equal(p.identity.known, true);
+ assert.equal(p.identity.known && p.identity.uuid, UUID);
+ assert.equal(p.identity.known && p.identity.label, "PLATTER");
+ // root === join(mountpoint, relPath), the invariant a re-point relies on.
+ assert.equal(p.identity.known && p.identity.relPath, "archilyzer-media");
+ // NEVER Infinity. getFreeBytes fails open to it (statfs unsupported, a
+ // permissions error, ENOTDIR) and that value does not survive JSON — it
+ // reaches a client component as `null`. Forcing the fail-open needs a
+ // filesystem this test cannot conjure, so the assertion is on the contract:
+ // a freeBytes that is present is a finite number.
+ assert.ok(Number.isFinite(p.freeBytes));
+ assert.ok((p.freeBytes ?? 0) > 0);
+ // In fstab and not an automount: nothing to warn about.
+ assert.equal(p.warning, undefined);
+ });
+});
+
+test("available: an automounted root warns, and names its fstab line", async () => {
+ await withHarness(async (h) => {
+ const root = path.join(h.dir, "auto");
+ await mkdir(root, { recursive: true });
+ await h.control({
+ // udisks mounts under /run/media/<user>/<uuid>. The mountpoint is what
+ // gives it away; the fstab query is not even made.
+ identity: { target: "/run/media/user/" + UUID, fstype: "ext4", uuid: UUID },
+ fstab: `UUID=${UUID}`,
+ });
+ const p = await probeLocation(loc(root), h.bins, {
+ byUuidDir: h.byUuidDir,
+ });
+ assert.equal(p.status, "available");
+ assert.match(p.warning ?? "", /automount/);
+ assert.match(p.warning ?? "", new RegExp(`UUID=${UUID} /mnt/platter ext4`));
+ });
+});
+
+test("available: a mount with no fstab entry warns too", async () => {
+ await withHarness(async (h) => {
+ const root = path.join(h.dir, "hand-mounted");
+ await mkdir(root, { recursive: true });
+ await h.control({
+ identity: { target: root, fstype: "ext4", uuid: UUID },
+ // No `fstab` key: the fake exits 1, as findmnt --fstab does.
+ });
+ const p = await probeLocation(loc(root), h.bins, {
+ byUuidDir: h.byUuidDir,
+ });
+ assert.equal(p.status, "available");
+ assert.match(p.warning ?? "", /may not be present at boot/);
+ });
+});
+
+test("mounted-elsewhere: the recorded uuid is up under another mountpoint", async () => {
+ await withHarness(async (h) => {
+ await h.control({ uuidTarget: "/run/media/user/" + UUID });
+ const p = await probeLocation(
+ loc(path.join(h.dir, "gone", "archilyzer-media"), true),
+ h.bins,
+ { byUuidDir: h.byUuidDir },
+ );
+ assert.equal(p.status, "mounted-elsewhere");
+ assert.equal(
+ p.candidateRoot,
+ `/run/media/user/${UUID}/archilyzer-media`,
+ );
+ // The sighting is live enough to write back as the location's volume.
+ assert.equal(p.identity.known && p.identity.mountpoint, `/run/media/user/${UUID}`);
+ assert.equal(p.identity.known && p.identity.fstype, "ext4");
+ assert.equal(p.freeBytes, undefined);
+ });
+});
+
+test("unmounted: the disk is attached but nothing has mounted it", async () => {
+ await withHarness(async (h) => {
+ await h.control({});
+ await writeFile(path.join(h.byUuidDir, UUID), "");
+ const p = await probeLocation(loc(path.join(h.dir, "gone"), true), h.bins, {
+ byUuidDir: h.byUuidDir,
+ });
+ assert.equal(p.status, "unmounted");
+ assert.equal(p.identity.known, false);
+ });
+});
+
+test("absent: the uuid is nowhere on this machine", async () => {
+ await withHarness(async (h) => {
+ await h.control({});
+ const p = await probeLocation(loc(path.join(h.dir, "gone"), true), h.bins, {
+ byUuidDir: h.byUuidDir,
+ });
+ assert.equal(p.status, "absent");
+ });
+});
+
+test("missing: no root and no identity to look for", async () => {
+ await withHarness(async (h) => {
+ await h.control({});
+ const p = await probeLocation(loc(path.join(h.dir, "gone")), h.bins, {
+ byUuidDir: h.byUuidDir,
+ });
+ assert.equal(p.status, "missing");
+ assert.equal(p.identity.known, false);
+ });
+});
+
+test("a findmnt that hangs times out, and the root stays available", async () => {
+ await withHarness(async (h) => {
+ const root = path.join(h.dir, "slow");
+ await mkdir(root, { recursive: true });
+ await h.control({
+ sleepMs: 2_000,
+ identity: { target: root, fstype: "ext4", uuid: UUID },
+ });
+ const p = await probeLocation(loc(root), h.bins, {
+ findmntTimeoutMs: 150,
+ byUuidDir: h.byUuidDir,
+ });
+ // THE POINT: a wedged automounter costs identity, never availability.
+ assert.equal(p.status, "available");
+ assert.equal(p.identity.known, false);
+ assert.equal(p.warning, undefined);
+ assert.ok((p.freeBytes ?? 0) > 0);
+ });
+});
+
+test("no findmnt at all: identity unknown, status still from stat", async () => {
+ await withHarness(async (h) => {
+ const root = path.join(h.dir, "container-bind-mount");
+ await mkdir(root, { recursive: true });
+ await h.control({});
+ const p = await probeLocation(
+ loc(root),
+ { findmntBin: path.join(h.dir, "definitely-not-installed"), udisksctlBin: "x" },
+ { byUuidDir: h.byUuidDir },
+ );
+ // This is the Docker case: no util-linux view of the volume, a perfectly
+ // healthy bind-mounted root. It must never read as unreachable.
+ assert.equal(p.status, "available");
+ assert.equal(p.identity.known, false);
+ });
+});
+
+test("a root that exists but is a file is not available", async () => {
+ await withHarness(async (h) => {
+ const root = path.join(h.dir, "not-a-dir");
+ await writeFile(root, "");
+ await h.control({});
+ const p = await probeLocation(loc(root), h.bins, {
+ byUuidDir: h.byUuidDir,
+ });
+ assert.equal(p.status, "missing");
+ });
+});
diff --git a/common/lib/storageVolumes.ts b/common/lib/storageVolumes.ts
@@ -0,0 +1,363 @@
+import path from "node:path";
+import { lstat, stat } from "node:fs/promises";
+import { execa } from "execa";
+import type { Paths } from "./paths";
+import { getFreeBytes } from "./diskSpace";
+import type { StorageLocation, StorageVolume } from "./storageLocations";
+
+// STORAGE VOLUME PROBES — is this location's disk here, and if not, where?
+//
+// SERVER-ONLY. It imports execa, so nothing reachable from a `"use client"`
+// file may import it (next build fails on `node:child_process`). The pure half
+// — types, `locationOfDataDir`, the migration — is `storageLocations.ts`, and
+// that is the one a client component may reach for.
+//
+// EVERY SUBPROCESS HERE FAILS OPEN. `reject: false`, a timeout, and a catch,
+// and on any failure the answer is "identity unknown" — never "unreachable".
+// The reason is Docker: block devices are invisible inside a container and the
+// media root is an identity bind-mount, so `findmnt` can legitimately know
+// nothing about a perfectly healthy root (RUNNING_IN_DOCKER.md §another drive).
+// A probe that turned "I could not ask" into "your disk is gone" would declare
+// every containerised corpus broken. Availability comes from `stat`, which is
+// the one thing that is always true; the subprocesses only ever ADD identity.
+
+export type StorageLocationStatus =
+ // The root is a directory right now. The only status with freeBytes.
+ | "available"
+ // The root is not there, but the recorded UUID is mounted somewhere else —
+ // `candidateRoot` is where the root would be after a re-point.
+ | "mounted-elsewhere"
+ // The recorded UUID has a /dev/disk/by-uuid node but is not mounted.
+ | "unmounted"
+ // The recorded UUID is not present on this machine at all.
+ | "absent"
+ // The root is not there and we have no identity to look for — nothing to say
+ // beyond "that path does not exist".
+ | "missing";
+
+// `known: false` is the fail-open answer and is NOT a problem report: it means
+// the probe could not ask (no findmnt, a container, a timeout), not that the
+// root is in a bad state.
+export type StorageIdentity =
+ | ({ known: true } & StorageVolume)
+ | { known: false };
+
+export type StorageLocationProbe = {
+ status: StorageLocationStatus;
+ identity: StorageIdentity;
+ // Only for "mounted-elsewhere": join(currentMountpoint, volume.relPath).
+ candidateRoot?: string;
+ // Only when available and the mount looks like it will not survive a reboot.
+ warning?: string;
+ // Only when available — `getFreeBytes` walks up on ENOENT, so on a missing
+ // root it would cheerfully report the PARENT volume's free space, which for
+ // an unmounted platter is the free space of the disk holding /run/media.
+ freeBytes?: number;
+};
+
+export type VolumeBins = Pick<Paths, "findmntBin" | "udisksctlBin">;
+
+// The plan's budget: a findmnt that has not answered in 3 s has hit a wedged
+// automounter or a hung NFS mount, and the right answer is "unknown", now.
+export const FINDMNT_TIMEOUT_MS = 3_000;
+// udisksctl talks to a daemon over D-Bus and then waits for a real mount.
+export const MOUNT_TIMEOUT_MS = 15_000;
+
+// The kernel's stable-name directory for volumes. A location's UUID has an
+// entry here whenever the disk is attached, mounted or not — which is the whole
+// difference between "unmounted" and "absent".
+export const BY_UUID_DIR = "/dev/disk/by-uuid";
+
+// Test seams ONLY, shared by probeLocation and mountByUuid so the by-uuid
+// directory is named in one place. Production callers pass nothing and get the
+// constants above; the unit tests shorten the findmnt timeout (so the "fake
+// binary that sleeps" case does not cost the suite three seconds) and point
+// `byUuidDir` at a tmp directory, which is the only way to exercise
+// unmounted-vs-absent without plugging a disk in.
+export type ProbeOptions = {
+ findmntTimeoutMs?: number;
+ byUuidDir?: string;
+};
+
+type Run = {
+ ok: boolean;
+ // undefined = the binary never ran to completion (not installed, or killed
+ // by the timeout). A NUMBER is an answer from findmnt itself, and the
+ // difference matters: `findmnt --fstab` exits 1 to MEAN "no such entry",
+ // which is a fact, while a missing binary means "I could not ask", which is
+ // not. Conflating them would warn about fstab on every location in a
+ // container.
+ exitCode: number | undefined;
+ stdout: string;
+ stderr: string;
+};
+
+async function run(
+ bin: string,
+ args: string[],
+ timeout: number,
+): Promise<Run> {
+ try {
+ const res = await execa(bin, args, {
+ buffer: true,
+ reject: false,
+ timeout,
+ });
+ const exitCode = res.timedOut ? undefined : (res.exitCode ?? undefined);
+ return {
+ ok: exitCode === 0,
+ exitCode,
+ stdout: typeof res.stdout === "string" ? res.stdout : "",
+ stderr: typeof res.stderr === "string" ? res.stderr : "",
+ };
+ } catch {
+ // ENOENT on the binary itself lands here in some execa paths. Same answer.
+ return { ok: false, exitCode: undefined, stdout: "", stderr: "" };
+ }
+}
+
+// `findmnt -J -T <path>` — the mount the path is ON (-T resolves a path, not
+// just a mountpoint), as JSON so a label with a space cannot be misparsed.
+async function identityOfPath(
+ root: string,
+ bins: VolumeBins,
+ timeoutMs: number,
+): Promise<StorageIdentity> {
+ const res = await run(
+ bins.findmntBin,
+ ["-J", "-T", root, "-o", "TARGET,SOURCE,FSTYPE,LABEL,UUID"],
+ timeoutMs,
+ );
+ if (!res.ok) return { known: false };
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(res.stdout);
+ } catch {
+ return { known: false };
+ }
+ const fs0 = (parsed as { filesystems?: unknown[] })?.filesystems?.[0] as
+ | Record<string, unknown>
+ | undefined;
+ if (!fs0) return { known: false };
+ const uuid = typeof fs0.uuid === "string" ? fs0.uuid : "";
+ const mountpoint = typeof fs0.target === "string" ? fs0.target : "";
+ // No mountpoint means we learned nothing usable; no UUID means the volume has
+ // no stable name to find it by later (tmpfs, overlay, a bind mount in a
+ // container) — in both cases identity stays unknown rather than half-filled.
+ if (!uuid || !mountpoint) return { known: false };
+ return {
+ known: true,
+ uuid,
+ fstype: typeof fs0.fstype === "string" ? fs0.fstype : undefined,
+ label: typeof fs0.label === "string" ? fs0.label : undefined,
+ mountpoint,
+ relPath: relativeUnder(mountpoint, root),
+ };
+}
+
+// The root's path relative to its mountpoint, "" when they are the same dir.
+// Never "..": if `root` is somehow not under `mountpoint` we keep "" rather
+// than inventing a traversal that a later join would follow off the volume.
+function relativeUnder(mountpoint: string, root: string): string {
+ const rel = path.relative(mountpoint, root);
+ if (rel === "" || rel.startsWith("..") || path.isAbsolute(rel)) return "";
+ return rel;
+}
+
+// `findmnt -rn -S UUID=<u> -o TARGET` — where that volume is mounted now, if
+// anywhere. Raw + no headings, one mountpoint per line; we take the first.
+async function mountpointOfUuid(
+ uuid: string,
+ bins: VolumeBins,
+ timeoutMs: number,
+): Promise<string> {
+ const res = await run(
+ bins.findmntBin,
+ ["-rn", "-S", `UUID=${uuid}`, "-o", "TARGET"],
+ timeoutMs,
+ );
+ if (!res.ok) return "";
+ const first = res.stdout
+ .split("\n")
+ .map((l) => l.trim())
+ .find((l) => l !== "");
+ return first ?? "";
+}
+
+// `findmnt --fstab -S UUID=<u>` exits 1 when the volume has no fstab entry —
+// that non-zero exit is the ANSWER, not a failure, which is why this reads
+// `exitCode` and not `ok`. FAILS OPEN TO "yes, it is in fstab" only when the
+// binary never ran: a missing findmnt must not warn about fstab on every
+// location on the page.
+async function isInFstab(
+ uuid: string,
+ bins: VolumeBins,
+ timeoutMs: number,
+): Promise<boolean> {
+ const res = await run(
+ bins.findmntBin,
+ ["--fstab", "-S", `UUID=${uuid}`],
+ timeoutMs,
+ );
+ if (res.exitCode === undefined) return true;
+ return res.exitCode === 0 && res.stdout.trim() !== "";
+}
+
+function automountWarning(
+ loc: StorageLocation,
+ identity: StorageIdentity,
+): string {
+ const uuid = identity.known ? identity.uuid : "<uuid>";
+ const fstype = (identity.known && identity.fstype) || "auto";
+ return (
+ `automount — may not be present at boot; add ` +
+ `\`UUID=${uuid} /mnt/${loc.id} ${fstype} nofail 0 2\` to /etc/fstab, ` +
+ `or rely on re-point`
+ );
+}
+
+// Probe one location. Read-only: it never mounts, never writes settings, and
+// never touches the corpus.
+export async function probeLocation(
+ loc: StorageLocation,
+ bins: VolumeBins,
+ opts: ProbeOptions = {},
+): Promise<StorageLocationProbe> {
+ const timeoutMs = opts.findmntTimeoutMs ?? FINDMNT_TIMEOUT_MS;
+ const root = loc.root.trim();
+
+ // AVAILABILITY IS `stat`, AND ONLY `stat`. A root that is a directory is
+ // available even when every identity probe below fails — see the header.
+ let isDir = false;
+ if (root !== "") {
+ try {
+ isDir = (await stat(root)).isDirectory();
+ } catch {
+ isDir = false;
+ }
+ }
+
+ if (isDir) {
+ const identity = await identityOfPath(root, bins, timeoutMs);
+ // getFreeBytes FAILS OPEN TO Infinity (statfs unsupported, a permissions
+ // error, ENOTDIR) — right for a download gate, wrong to carry as a number:
+ // Infinity does not survive JSON, so it reaches a client component as
+ // `null` and every byte formatter downstream gets a surprise. An
+ // unmeasurable root reports no free space at all, which is the honest
+ // answer and the one the field is already optional for.
+ const free = await getFreeBytes(root);
+ // Two ways a mount will not be there after a reboot: udisks put it under
+ // /run/media (or /media) because a human plugged it in, or there is no
+ // fstab entry naming its UUID. The fstab call is skipped when the
+ // mountpoint already says "automount" — same warning, one less subprocess.
+ let warning: string | undefined;
+ if (identity.known) {
+ const automounted =
+ identity.mountpoint.startsWith("/run/media/") ||
+ identity.mountpoint.startsWith("/media/");
+ if (automounted || !(await isInFstab(identity.uuid, bins, timeoutMs))) {
+ warning = automountWarning(loc, identity);
+ }
+ }
+ return {
+ status: "available",
+ identity,
+ ...(warning ? { warning } : {}),
+ ...(Number.isFinite(free) ? { freeBytes: free } : {}),
+ };
+ }
+
+ // The root is not a directory (absent, or — treated identically — a file).
+ // Without a recorded UUID there is nothing to look for.
+ const uuid = loc.volume?.uuid?.trim() ?? "";
+ if (!uuid) return { status: "missing", identity: { known: false } };
+
+ const target = await mountpointOfUuid(uuid, bins, timeoutMs);
+ if (target) {
+ const relPath = loc.volume?.relPath ?? "";
+ return {
+ status: "mounted-elsewhere",
+ // A live sighting of the recorded volume: uuid and mountpoint come from
+ // findmnt, fstype/label are carried over from the record (this query asks
+ // for TARGET only). This is what the re-point job writes back as the
+ // location's `volume`.
+ identity: {
+ known: true,
+ uuid,
+ fstype: loc.volume?.fstype,
+ label: loc.volume?.label,
+ mountpoint: target,
+ relPath,
+ },
+ candidateRoot: relPath ? path.join(target, relPath) : target,
+ };
+ }
+
+ // Not mounted. Is the disk even attached? `/dev/disk/by-uuid/<u>` is a
+ // symlink the kernel maintains; lstat it so a dangling link still counts as
+ // "the udev entry is there".
+ try {
+ await lstat(path.join(opts.byUuidDir ?? BY_UUID_DIR, uuid));
+ return { status: "unmounted", identity: { known: false } };
+ } catch {
+ return { status: "absent", identity: { known: false } };
+ }
+}
+
+// udisksctl availability, memoised per binary path. Same shape as a digest
+// app's probe (`digestApps.ts` claudeCode.probe): `--version`, reject:false,
+// short timeout. Memoised because /storage asks once per render and the answer
+// does not change while the process lives.
+const udisksctlProbes = new Map<string, Promise<boolean>>();
+
+export function udisksctlAvailable(bins: VolumeBins): Promise<boolean> {
+ const bin = bins.udisksctlBin;
+ const hit = udisksctlProbes.get(bin);
+ if (hit) return hit;
+ const probe = run(bin, ["--version"], FINDMNT_TIMEOUT_MS).then((r) => r.ok);
+ udisksctlProbes.set(bin, probe);
+ return probe;
+}
+
+// Test seam: the memo above outlives a test's fake bin otherwise.
+export function resetUdisksctlProbeCache(): void {
+ udisksctlProbes.clear();
+}
+
+export type MountResult = {
+ ok: boolean;
+ // The mountpoint udisksctl reported, when it said one.
+ mountpoint?: string;
+ // udisksctl's own words. Surfaced verbatim — a polkit denial under a service
+ // session is the expected failure and the operator needs to read it.
+ error?: string;
+};
+
+// Mount a volume by UUID. Offered only for an `unmounted` location, and only
+// when the binary resolves. NEVER RETRIED: a failure here is a policy or
+// hardware answer, and a second attempt just produces a second denial.
+export async function mountByUuid(
+ uuid: string,
+ bins: VolumeBins,
+ opts: ProbeOptions = {},
+): Promise<MountResult> {
+ if (!(await udisksctlAvailable(bins))) {
+ return {
+ ok: false,
+ error: `udisksctl not found (set UDISKSCTL_BIN) — mount ${uuid} by hand`,
+ };
+ }
+ const res = await run(
+ bins.udisksctlBin,
+ ["mount", "-b", path.join(opts.byUuidDir ?? BY_UUID_DIR, uuid)],
+ MOUNT_TIMEOUT_MS,
+ );
+ if (!res.ok) {
+ const said = res.stderr.trim() || res.stdout.trim();
+ return { ok: false, error: said || `udisksctl mount failed for ${uuid}` };
+ }
+ // "Mounted /dev/sdb1 at /run/media/user/<uuid>." — the trailing period is
+ // part of the message and not part of the path.
+ const m = /\bat\s+(.+?)\.?\s*$/m.exec(res.stdout.trim());
+ return { ok: true, mountpoint: m ? m[1] : undefined };
+}
diff --git a/editor/app/channels/[slug]/page.tsx b/editor/app/channels/[slug]/page.tsx
@@ -31,6 +31,7 @@ import { countPlaylist } from "yt-dlp-transcript-common/controller/channels";
import { loadFailedTranscriptions } from "yt-dlp-transcript-common/controller/failedTranscriptions";
import { savedVideoTotals } from "yt-dlp-transcript-common/controller/savedVideoInventory";
import { getSettings } from "yt-dlp-transcript-common/lib/settings";
+import { defaultLocationRoot } from "yt-dlp-transcript-common/lib/storageLocations";
import {
loadShardConfig,
type ShardConfig,
@@ -549,7 +550,7 @@ export default async function ChannelDetailPage({
canClearMarker={marker !== null && busy === null}
// The configured cold root, prefilled into the destination box so
// it is typed once in Settings instead of once per channel.
- defaultRoot={settings.storage.mediaRoot}
+ defaultRoot={defaultLocationRoot(settings.storage)}
/>
);
}
diff --git a/editor/app/channels/bulkStorageActions.ts b/editor/app/channels/bulkStorageActions.ts
@@ -31,6 +31,7 @@ import path from "node:path";
import { stat } from "node:fs/promises";
import { getPaths } from "yt-dlp-transcript-common/lib/paths";
import { getSettings } from "yt-dlp-transcript-common/lib/settings";
+import { defaultLocationRoot } from "yt-dlp-transcript-common/lib/storageLocations";
import { isSocialChannel } from "yt-dlp-transcript-common/lib/channelConfig";
import { inspectChannelMedia } from "yt-dlp-transcript-common/lib/channelMedia";
import { readChannelConfig } from "yt-dlp-transcript-common/controller/channels";
@@ -60,16 +61,17 @@ export async function bulkRelocateChannelMediaAction(
root?: string,
): Promise<BulkRelocateResult> {
const paths = getPaths();
- // The bar's own box wins; blank falls back to the configured cold root. The
- // settings page is the only writer of that value — this only reads it.
- const chosen = (root ?? "").trim() || getSettings().storage.mediaRoot.trim();
+ // The bar's own box wins; blank falls back to the DEFAULT LOCATION's root.
+ // /storage is the only writer of the location list — this only reads it.
+ const chosen =
+ (root ?? "").trim() || defaultLocationRoot(getSettings().storage).trim();
if (!chosen) {
return {
queued: [],
skipped: slugs.map((slug) => ({
slug,
reason:
- "no destination root — set a default media root in Settings, or type one here",
+ "no destination root — add a media location on /storage, or type one here",
})),
};
}
diff --git a/editor/app/channels/page.tsx b/editor/app/channels/page.tsx
@@ -30,6 +30,7 @@ import {
getSettings,
type SiteSettings,
} from "yt-dlp-transcript-common/lib/settings";
+import { defaultLocationRoot } from "yt-dlp-transcript-common/lib/storageLocations";
import { buildChannelBands } from "yt-dlp-transcript-common/views/pipeline/buildBands";
import { EXTERNAL_BAND_IDS } from "yt-dlp-transcript-common/views/pipeline/buildBands";
import {
@@ -355,7 +356,7 @@ export default async function ChannelsPage({
// The configured cold root, for the selection deck's root box. Read
// here, not in the client component — the settings page is its one
// writer.
- defaultMediaRoot={getSettings().storage.mediaRoot}
+ defaultMediaRoot={defaultLocationRoot(getSettings().storage)}
sites={sites.map((s) => ({
siteId: s.siteId,
title: s.siteTitle || s.siteId,
diff --git a/editor/app/settings/actions.ts b/editor/app/settings/actions.ts
@@ -1,6 +1,5 @@
"use server";
-import path from "node:path";
import { revalidatePath } from "next/cache";
import {
AUTO_REFRESH_INTERVAL_MAX_SECONDS,
@@ -55,10 +54,6 @@ export async function saveSettingsAction(
formData.get("autoRefreshIntervalSeconds") ?? "",
).trim();
const minFreeDiskRaw = String(formData.get("minFreeDiskGB") ?? "").trim();
- // The cold-storage default. Blank is a legitimate value ("no default root"),
- // so the only thing rejected is a non-absolute one — see sanitizeStorage for
- // why a relative root is never resolved.
- const mediaRoot = String(formData.get("mediaRoot") ?? "").trim();
const resumeMarginRaw = String(formData.get("resumeMarginGB") ?? "").trim();
const inlineTranscribeOnFallback =
formData.get("inlineTranscribeOnFallback") === "on";
@@ -157,13 +152,6 @@ export async function saveSettingsAction(
};
}
- if (mediaRoot !== "" && !path.isAbsolute(mediaRoot)) {
- return {
- ok: false,
- error: "Default media root must be an absolute path",
- };
- }
-
let socialInput: unknown;
try {
socialInput = JSON.parse(String(formData.get("socialLinksJson") ?? "[]"));
@@ -259,9 +247,11 @@ export async function saveSettingsAction(
// Preserve the saved-video backup config on an unrelated settings save (the
// Saved Videos page edits it). writeSettings re-sanitizes it regardless.
savedVideoBackup: getSettings().savedVideoBackup,
- // THIS FORM IS THE ONE WRITER of the cold-storage default. The Storage
- // panel and the /channels bulk move only read it.
- storage: { mediaRoot },
+ // NO LONGER THIS FORM'S. The storage block became a list of named
+ // locations edited on /storage; preserved here the way autoQueue and
+ // channelPriority are, so an unrelated settings save cannot erase it.
+ // writeSettings re-sanitizes it regardless.
+ storage: getSettings().storage,
buildPipeline,
// Each edited on its own operation page; preserved here. Slice 3 moved
// these four fieldsets to /operations/<id>, and with them the hidden
diff --git a/editor/app/settings/components/SettingsForm.tsx b/editor/app/settings/components/SettingsForm.tsx
@@ -1,6 +1,7 @@
"use client";
import { useActionState, useState } from "react";
+import Link from "next/link";
import {
saveSettingsAction,
type SaveResult,
@@ -131,12 +132,24 @@ export function SettingsForm({ initial }: Props) {
type="number"
hint="Extra headroom above the floor that a disk-stopped pipeline must see before it starts writing again. Without it the first resumed download drops free space back under the floor and the pipeline flaps. Default 2 GB. Set to 0 to resume at the floor."
/>
- <Field
- label="Default media root (cold storage)"
- name="mediaRoot"
- defaultValue={initial.storage.mediaRoot}
- hint="Absolute directory that holds channels moved off the corpus disk, one <slug>/data under it (e.g. /mnt/platter/archilyzer-media). Prefills the root on each channel's Storage panel and is what the bulk move on /channels uses when its own box is blank. Never checked for existence — the drive may be unmounted — and never resolved, so it must be absolute. Leave blank for no default."
- />
+ {/* THE COLD ROOT USED TO BE A TEXT BOX HERE. It is now a list of named
+ locations with an id, a label, a root, an opt-in auto re-point and a
+ volume identity — more than a settings field can hold, and edited on
+ its own page. The link points at a route that does not exist yet:
+ /storage is the next slice. That is deliberate rather than an
+ oversight — the field's REPLACEMENT lands with the field's removal, so
+ this form never carries an input whose value nothing reads. */}
+ <div className="flex flex-col gap-1">
+ <span className="text-sm font-medium">Media locations</span>
+ <span className="text-xs text-muted-foreground">
+ The places a channel's media can live — the cold drive and
+ anything beside it — are named, checked and re-pointed on{" "}
+ <Link href="/storage" className="underline">
+ Storage
+ </Link>
+ . Each channel's own Storage panel still runs the move.
+ </span>
+ </div>
<Field
label="Auto-refresh interval (seconds)"
name="autoRefreshIntervalSeconds"
diff --git a/editor/e2e/channel-storage.spec.ts b/editor/e2e/channel-storage.spec.ts
@@ -223,7 +223,14 @@ test("the /channels bulk move queues one job per channel and skips the rest", as
minFreeDiskGB: 0,
verifyAvailabilityBeforeClean: false,
syncScheduler: { fullSweepIntervalMinutes: 0 },
- storage: { mediaRoot: root },
+ // ONE LOCATION, and it is the default — the shape the old single
+ // `mediaRoot` string migrates into. `defaultLocationRoot` is what the
+ // Storage panel and the bulk bar read, so this is still what asserts the
+ // settings value reaches the client.
+ storage: {
+ locations: [{ id: "cold", label: "Cold", root, autoRepoint: false }],
+ defaultLocationId: "cold",
+ },
});
const PRE = "pre-moved";
@@ -324,7 +331,10 @@ test("a bulk move puts every job on one queue and skips a channel with nothing t
await mkdir(root, { recursive: true });
await writeSettings({
adminTitle: "Test Admin",
- storage: { mediaRoot: root },
+ storage: {
+ locations: [{ id: "cold", label: "Cold", root, autoRepoint: false }],
+ defaultLocationId: "cold",
+ },
});
// A second channel with real media, so the selection queues TWO moves — one