Archilyzer · Source

archilyzer

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

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:
Mcommon/lib/paths.ts | 11+++++++++++
Acommon/lib/storageLocations.ts | 155+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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", + }; +}