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:
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)}