commit 4ef1ac325c013295fb199530acffbaebe3127349
parent b353ed5b4b492c307de28eda1e9189caf96d166a
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Thu, 17 Sep 2026 13:53:28 -0400
settings: the media-root box becomes a link, and three reads become one call
The form no longer carries `mediaRoot` — not the input, not the absolute-path
validation, not the write. In its place a note pointing at /storage, which is
the next slice: the link is deliberately ahead of the route so the field’s
replacement lands with the field’s removal and this form never holds an input
whose value nothing reads. `storage` joins autoQueue and channelPriority as a
block the settings save PRESERVES rather than rebuilds.
The three readers — the bulk move’s fallback, the channel Storage panel’s
prefill, the selection deck’s box — call defaultLocationRoot(settings.storage)
instead of reading a string field. Same value, same UI: with one migrated
location it is the same root it was yesterday.
channel-storage.spec.ts writes the new shape (one location, default) and keeps
every assertion — including the one that the settings value reaches the client.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
6 files changed, 43 insertions(+), 26 deletions(-)
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,
@@ -550,7 +551,7 @@ export default async function ChannelDetailPage({
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}
+ 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 { getRegistry } from "yt-dlp-transcript-common/jobs/registry";
import { isSocialChannel } from "yt-dlp-transcript-common/lib/channelConfig";
import { inspectChannelMedia } from "yt-dlp-transcript-common/lib/channelMedia";
@@ -77,7 +78,8 @@ export async function bulkRelocateChannelMediaAction(
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();
+ const chosen =
+ (root ?? "").trim() || defaultLocationRoot(getSettings().storage).trim();
if (!chosen) {
return {
queued: [],
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'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'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