Archilyzer · Source

archilyzer

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

commit c1f15aa4fe43272e29b1d0b9255fd9008ac0778b
parent 1677a13d049059f4804c0ef64147575fa4b7815f
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Fri, 11 Sep 2026 12:04:22 -0400

settings: the cold root is typed once, in the one place that writes it

`storage.mediaRoot` is a DEFAULT and nothing more. relocateChannelMedia always
takes an explicit root and never reads settings, so this field cannot move any
bytes on its own: it is read by the two places an operator picks a root — the
channel's Storage panel, which prefills its destination box, and (next commit)
the /channels bulk move, which falls back to it.

ONE WRITER, and it is the settings form. `sanitizeStorage` is the last line
rather than the only one: the form rejects a relative path with a message, and
the sanitizer drops one to blank. Neither RESOLVES it — resolving would anchor
the default to whatever cwd the editor booted in, which is a different directory
under docker, under a worktree and under `pnpm dev`, so one settings.json would
name three different drives. Existence is never checked in either place, because
the whole point of a cold root is a drive that may not be mounted when settings
are read; dropping it then would erase the operator's choice on the next save.

`saveSettingsAction` builds a full SiteSettings literal, so the field is written
there; every other block in that literal is still preserved, not rebuilt.

The panel's prefill is an INITIAL value, not a controlled default: a finished run
calls router.refresh(), and a controlled default would throw away a root being
typed. It does not enable the move either — the preview gate is unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Diffstat:
Mcommon/lib/settings.ts | 50++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/lib/storageSettings.test.ts | 66++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/channels/[slug]/components/stages/StorageStage.tsx | 15++++++++++++++-
Meditor/app/channels/[slug]/page.tsx | 3+++
Meditor/app/settings/actions.ts | 15+++++++++++++++
Meditor/app/settings/components/SettingsForm.tsx | 6++++++
6 files changed, 154 insertions(+), 1 deletion(-)

diff --git a/common/lib/settings.ts b/common/lib/settings.ts @@ -214,6 +214,12 @@ export type SiteSettings = { // sync scheduler runs the backup on the configured cadence. See // common/controller/backupSavedVideos.ts. savedVideoBackup: SavedVideoBackupSettings; + // Where a channel's downloaded media goes when it is relocated off the corpus + // disk. A DEFAULT ONLY: the relocate controller never reads it and always + // takes an explicit root, so this is the value the per-channel Storage panel + // prefills and the /channels bulk move falls back to. Blank = no default. + // See StorageSettings. + storage: StorageSettings; // How the static export is built: "basic" reuses the single export/ tree and // serializes builds on one queue (the long-standing behavior); "docker" runs // each site's build in an isolated container for safe parallelism. The Docker @@ -820,6 +826,47 @@ export function sanitizeSavedVideoBackup( }; } +// Where relocated channel media goes by default. +// +// 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; +}; + +export function defaultStorage(): StorageSettings { + return { mediaRoot: "" }; +} + +// 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. +// +// 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. +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 : "" }; +} + // 4 hours. Measured: videos over this are 8.2% of the corpus by count but hold // 46% of all transcript tokens, so they are where a sweep's wall-clock actually // goes and where chunk-seam bugs live. @@ -1019,6 +1066,7 @@ function defaults(): SiteSettings { socialLinks: [], homepageUrl: "", savedVideoBackup: defaultSavedVideoBackup(), + storage: defaultStorage(), buildPipeline: defaultBuildPipeline(), // Must be listed here or the allowlist loop in getSettings() drops the key // entirely and the whole section is never read from disk. @@ -1404,6 +1452,7 @@ export function getSettings(): SiteSettings { merged.socialLinks = parseSocialLinks(merged.socialLinks); merged.homepageUrl = normalizeHomepageUrl(merged.homepageUrl); merged.savedVideoBackup = sanitizeSavedVideoBackup(merged.savedVideoBackup); + merged.storage = sanitizeStorage(merged.storage); merged.buildPipeline = sanitizeBuildPipeline(merged.buildPipeline); merged.digest = sanitizeDigest(merged.digest); merged.diarization = sanitizeDiarization(merged.diarization); @@ -1614,6 +1663,7 @@ export async function writeSettings(next: SiteSettings): Promise<void> { socialLinks, homepageUrl: normalizeHomepageUrl(next.homepageUrl), savedVideoBackup: sanitizeSavedVideoBackup(next.savedVideoBackup), + storage: sanitizeStorage(next.storage), buildPipeline: sanitizeBuildPipeline(next.buildPipeline), digest: sanitizeDigest(next.digest), diarization: sanitizeDiarization(next.diarization), diff --git a/common/lib/storageSettings.test.ts b/common/lib/storageSettings.test.ts @@ -0,0 +1,66 @@ +// THE COLD-STORAGE DEFAULT, and the two things its sanitizer refuses to do. +// +// Run with: node_modules/.bin/tsx --test common/lib/storageSettings.test.ts +// +// Its own file rather than a `settings.test.ts`: getSettings() memoizes paths at +// 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. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { defaultStorage, sanitizeStorage } from "./settings"; + +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("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", + }); +}); + +// 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: "", + }); +}); + +// 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 root that does not exist is kept", () => { + assert.deepEqual( + sanitizeStorage({ mediaRoot: "/definitely/not/mounted/anywhere" }), + { mediaRoot: "/definitely/not/mounted/anywhere" }, + ); +}); + +// 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", () => { + assert.deepEqual( + sanitizeStorage({ mediaRoot: "/mnt/a", tier: "cold", extra: 1 }), + { mediaRoot: "/mnt/a" }, + ); +}); diff --git a/editor/app/channels/[slug]/components/stages/StorageStage.tsx b/editor/app/channels/[slug]/components/stages/StorageStage.tsx @@ -56,6 +56,10 @@ type Props = { // each other, and reading the hatch off `blockedReason === null` would hide // it in exactly the state it exists for. canClearMarker: boolean; + // settings.storage.mediaRoot — the cold root the operator configured once, or + // "" when there is none. READ ONLY: this panel prefills its destination box + // with it and never writes it back. The settings page is the one writer. + defaultRoot: string; }; export function StorageStage({ @@ -66,6 +70,7 @@ export function StorageStage({ volumeDir, blockedReason, canClearMarker, + defaultRoot, }: Props) { return ( <div className="flex flex-col gap-6"> @@ -126,6 +131,7 @@ export function StorageStage({ slug={slug} canMoveOut={!location.relocated} blockedReason={blockedReason} + defaultRoot={defaultRoot} /> <MoveBack key="move-back" @@ -142,12 +148,19 @@ function MoveOut({ slug, canMoveOut, blockedReason, + defaultRoot, }: { slug: string; canMoveOut: boolean; blockedReason: string | null; + defaultRoot: string; }) { - const [root, setRoot] = useState(""); + // Seeded from settings.storage.mediaRoot, then owned by the operator. It is + // an initial value and NOT a controlled default: a router.refresh() (which + // every finished run triggers) must not throw away a root being typed. It + // also does not enable the move — the preview gate is unchanged, so a + // prefilled root still has to be previewed before Move lights up. + const [root, setRoot] = useState(defaultRoot); const [preview, setPreview] = useState<RelocationPreview | null>(null); // The root the preview above describes. Edit the input and the confirmation // goes stale — the numbers were measured against a different volume, and diff --git a/editor/app/channels/[slug]/page.tsx b/editor/app/channels/[slug]/page.tsx @@ -550,6 +550,9 @@ export default async function ChannelDetailPage({ // A marker with no job behind it is a stale marker: the run that // wrote it is gone, and nothing else will ever clear it. canClearMarker={marker !== null && activeJobs === 0} + // 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} /> ); } diff --git a/editor/app/settings/actions.ts b/editor/app/settings/actions.ts @@ -1,5 +1,6 @@ "use server"; +import path from "node:path"; import { revalidatePath } from "next/cache"; import { AUTO_REFRESH_INTERVAL_MAX_SECONDS, @@ -54,6 +55,10 @@ 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"; @@ -152,6 +157,13 @@ 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") ?? "[]")); @@ -242,6 +254,9 @@ 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 }, 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 @@ -132,6 +132,12 @@ export function SettingsForm({ initial }: Props) { 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." + /> + <Field label="Auto-refresh interval (seconds)" name="autoRefreshIntervalSeconds" defaultValue={String(initial.autoRefreshIntervalSeconds)}