commit ca3d6ea1fd8df84330ca4ae19e092cffa09f617f
parent a587e2abcc7e3147a0147095cf32bdbebc4b7ca5
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Thu, 17 Sep 2026 13:43:20 -0400
storage: a location is an entity, not a root string
A cold drive was one absolute path in settings (`storage.mediaRoot`). That
string cannot answer either question the operator actually has when the
platter comes up somewhere else: is it here, and how do I point the channels
at it without ssh and hand edits.
lib/storageLocations.ts is the pure half — the StorageLocation /
StorageSettings types, `locationOfDataDir` (a channel is on L iff its dataDir
is under L.root; nested roots allowed, longest wins), `defaultLocationRoot`,
and `migrateMediaRootToLocations`. Pure because it is the ONE storage module
a "use client" file may import; everything that shells out lands next door.
paths.ts gains findmntBin (FINDMNT_BIN) and udisksctlBin (UDISKSCTL_BIN)
beside rsyncBin — the two binaries the probe will use.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
2 files changed, 166 insertions(+), 0 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/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",
+ };
+}