Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

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:
Mcommon/lib/paths.ts | 11+++++++++++
Mcommon/lib/settings.ts | 147++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
Acommon/lib/storageLocations.test.ts | 114+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/lib/storageLocations.ts | 155+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/lib/storageSettings.test.ts | 266+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
Acommon/lib/storageVolumes.test.ts | 270+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/lib/storageVolumes.ts | 363+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/channels/[slug]/page.tsx | 3++-
Meditor/app/channels/bulkStorageActions.ts | 10++++++----
Meditor/app/channels/page.tsx | 3++-
Meditor/app/settings/actions.ts | 20+++++---------------
Meditor/app/settings/components/SettingsForm.tsx | 25+++++++++++++++++++------
Meditor/e2e/channel-storage.spec.ts | 14++++++++++++--
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&apos;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&apos;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