import path from "node:path"; import type { FieldDocs } from "./fieldDocs"; import type { StorageHealthSettings } from "./storageHealthTimings"; // 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 may name at all — and then for its TYPES ONLY, which are // erased. It imports `node:path`, so a runtime call from a client component // would pull that into a browser bundle; the two server pages that need a // location's NAME for a row call `locationLabelOfDataDir` themselves and ship // the string. 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 at all (`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.mediaDir` is under `L.root` // (release 17: the media tier is what a location holds; a `legacy` channel is // placed by its retired `config.dataDir` until it is migrated). // 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. // THE SYNTHETIC ROW'S ID: the corpus volume itself, assembled where it is // rendered and NEVER stored in `settings.storage.locations`. A stored entry // under this id would be deletable, and worse, `locationOfDataDir` would then // match every unrelocated channel — breaking the one rule the whole design // rests on (a channel is on location L iff its `mediaDir` is under `L.root`, // and an in-place channel has no `mediaDir`). // // It is HERE rather than beside the row that uses it because `lib/settings.ts` // has to refuse it as a stored id, and lib may not import controller. // `controller/storageLocations.ts` re-exports both, where callers name them. export const INTERNAL_LOCATION_ID = "internal"; export const INTERNAL_LOCATION_LABEL = "Internal (in place)"; // Each field is documented in STORAGE_VOLUME_FIELD_DOCS below (rendered into SETTINGS.md). export type StorageVolume = { uuid: string; fstype?: string; label?: string; mountpoint: string; relPath: string; }; export const STORAGE_VOLUME_FIELD_DOCS: FieldDocs = { uuid: "Filesystem UUID, the one stable name a disk has across mountpoints. " + "This is what makes \"the platter came up somewhere else\" a recoverable " + "situation.", fstype: "Filesystem type reported by the probe (e.g. \"ext4\"). Informational; omitted when unknown.", label: "Filesystem label reported by the probe. Informational; omitted when unknown.", mountpoint: "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.", relPath: "The location root's path RELATIVE to `mountpoint` (see there). Invariant: `root === join(mountpoint, relPath)`.", }; // Each field is documented in STORAGE_LOCATION_FIELD_DOCS below (rendered into SETTINGS.md). export type StorageLocation = { id: string; label: string; root: string; autoRepoint: boolean; volume?: StorageVolume; }; export const STORAGE_LOCATION_FIELD_DOCS: FieldDocs = { id: "/^[a-z0-9][a-z0-9-]{0,63}$/, unique within the list. Stable: it is " + "what `defaultLocationId` and every form and action refer to.", label: "Human name. Blank sanitizes to the id.", root: "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.", autoRepoint: "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.", volume: "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.", }; // Each field is documented in STORAGE_SETTINGS_FIELD_DOCS below (rendered into SETTINGS.md). export type StorageSettings = { locations: StorageLocation[]; defaultLocationId: string; savedVideosLocationId?: string; health?: StorageHealthSettings; }; export const STORAGE_SETTINGS_FIELD_DOCS: FieldDocs = { locations: "The named storage locations a channel's media may be relocated to — one entry per root, each with an id, label, root, `autoRepoint` and the learned volume identity. Order is display order. Managed on /storage.", defaultLocationId: "The location prefilled as the destination of a move. \"\" = no default.", savedVideosLocationId: "WHERE THE SAVED-VIDEO STORE IS, by location id. \"\" = in place, under " + "the corpus at `paths.savedVideosDir`.\n\n" + "A RECORD OF WHAT IS ON DISK, never an intention — the same contract as" + " a channel's `config.mediaDir`. It is written by the move, on success, " + "after the copy has verified and the symlink is in place; nothing else " + "writes it, and a reader that disagrees with the disk trusts the disk. " + "Optional so an older settings.json parses (and an older binary that " + "drops it leaves a store that still works, because the symlink is what " + "every reader follows).", health: "THE DRIVE-HEALTH TIMINGS: how long a read may take before a drive " + "counts as not answering, how often the health pass looks, how long " + "its look may take, how many clean looks clear a stall, and how many " + "reads may be on one drive at once. Edited on /storage (Drive health " + "timing). Absent = every default, and only a value that differs from " + "its default is written, so an untuned install follows a default " + "changed later. See `storage.health` below.", }; // 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 path sits on — a channel's `mediaDir` (or a legacy one's // retired `dataDir`) — or null when it sits on none (the ordinary case: an // unrelocated channel's media is inside the corpus). The name is historical: // it is a pure prefix test, and the movers ask it of any target. // // 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: `dir === root` is not a match. A channel's mediaDir is // always `//media`, 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 NAME of the location a channel's media is on, or undefined for none. // // The badge's projection, and the reason it lives here rather than beside the // badge: `MediaLocationBadge.tsx` is imported by `"use client"` files, so it may // take TYPES from this module but must never call into it — this file imports // `node:path`, which has no business in a browser bundle. The two server pages // that build channel rows call this and ship the resulting string. // // `label || id` is the same fallback the sanitizer applies on write and // `views/storage.ts` applies on render: a location whose label was blanked by a // hand edit is still named by something. export function locationLabelOfDataDir( dataDir: string | undefined, locations: StorageLocation[], ): string | undefined { if (!dataDir) return undefined; const found = locationOfDataDir(dataDir, locations); return found ? found.label || found.id : undefined; } // 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; if (r.locations !== undefined) return raw; // The drive-health timings are not about the locations: a block that spells // them and no `locations` (a hand edit) keeps them through the migration. const health = r.health !== undefined ? { health: r.health } : {}; const mediaRoot = typeof r.mediaRoot === "string" ? r.mediaRoot.trim() : ""; if (mediaRoot === "" || !path.isAbsolute(mediaRoot)) { return { locations: [], defaultLocationId: "", ...health }; } return { locations: [ { id: "default", label: "Default", root: mediaRoot, autoRepoint: false, }, ], defaultLocationId: "default", ...health, }; }