commit b353ed5b4b492c307de28eda1e9189caf96d166a
parent 85e9f5ead4dcedcf58125963747c8da6d208a346
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Thu, 17 Sep 2026 13:47:52 -0400
settings: the cold root becomes a list of locations, on read
storage.mediaRoot -> storage.{locations, defaultLocationId}.
migrateMediaRootToLocations runs at the existing hook in getSettings, handed
`parsed.storage` and not `merged.storage` for the same reason
migrateSweepsToLanes is handed `parsed`: merged has defaultStorage() folded
in and can no longer tell "no locations key" from "an empty list", and the
migration must only fire on the former.
The sanitizer keeps both of the old one’s refusals — existence is never
checked (an unmounted platter must not erase the operator’s choice) and a
relative root is dropped rather than resolved (it would name three different
drives from three cwds) — and adds: slug ids, unique, first wins; blank label
becomes the id; trailing slash stripped; volume identity shape-checked, half
a record is no record; defaultLocationId falls back to the first location.
AVAILABILITY IS NEVER PERSISTED. A refresh that wrote "available" would
rewrite settings.json, and so bump the pulse revision, every few seconds.
Rollback: an older binary sanitizes this block to { mediaRoot: "" } — one
string lost, nothing on disk moved. Copy settings.json first.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
2 files changed, 347 insertions(+), 66 deletions(-)
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/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,
);
});