Archilyzer · Source

archilyzer

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

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:
Meditor/app/channels/[slug]/page.tsx | 3++-
Meditor/app/channels/bulkStorageActions.ts | 4+++-
Meditor/app/channels/page.tsx | 3++-
Meditor/app/settings/actions.ts | 20+++++---------------
Meditor/app/settings/components/SettingsForm.tsx | 25+++++++++++++++++++------
Meditor/e2e/channel-storage.spec.ts | 14++++++++++++--
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&apos;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&apos;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