Archilyzer · Source

archilyzer

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

commit 3aef0f277756c926a2ddc1172a6608d98b2eed5c
parent 03ee88077e31c70dfff08d7d1b7b6378b6141658
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Thu, 17 Sep 2026 20:54:59 -0400

channels: the bulk move picks a place by name

The selection deck's MEDIA group is a <select> over the configured locations with the root shown beside it, and "Another root…" keeping the free-text box for the one-off. bulkRelocateChannelMediaAction takes a destination rather than a root and resolves it server-side, so a page rendered before a re-point cannot aim a whole batch at a root that has moved.

resolveMoveDestination lives in channels/lib/moveDestination.ts rather than in either actions file, for the reason lib/relocationJob.ts and lib/queueForSlugs.ts live there: both callers carry "use server", where every non-type export is a server action, so a shared resolver cannot live in one.

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

Diffstat:
Meditor/app/channels/[slug]/components/stages/StorageStage.tsx | 2+-
Meditor/app/channels/[slug]/storageActions.ts | 45++++++++++++---------------------------------
Meditor/app/channels/bulkStorageActions.ts | 50++++++++++++++++++++++++++++----------------------
Meditor/app/channels/components/ChannelSelectionDeck.tsx | 108++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
Meditor/app/channels/components/ChannelsTable.tsx | 43+++++++++++++++++++++++++++++--------------
Aeditor/app/channels/lib/moveDestination.ts | 42++++++++++++++++++++++++++++++++++++++++++
Meditor/app/channels/page.tsx | 19+++++++++++--------
7 files changed, 206 insertions(+), 103 deletions(-)

diff --git a/editor/app/channels/[slug]/components/stages/StorageStage.tsx b/editor/app/channels/[slug]/components/stages/StorageStage.tsx @@ -14,8 +14,8 @@ import { previewRelocationAction, relocateChannelMediaAction, resumeRelocationAction, - type MoveDestination, } from "../../storageActions"; +import type { MoveDestination } from "../../../lib/moveDestination"; // WHERE THIS CHANNEL'S MEDIA LIVES, and the buttons that change it. // diff --git a/editor/app/channels/[slug]/storageActions.ts b/editor/app/channels/[slug]/storageActions.ts @@ -40,39 +40,12 @@ import { type RelocationPreview, } from "yt-dlp-transcript-common/controller/relocateChannelMedia"; import { enqueueRelocation } from "../lib/relocationJob"; +import { + resolveMoveDestination, + type MoveDestination, +} from "../lib/moveDestination"; import { channelMediaBusyReason } from "../lib/mediaBusy"; -// WHERE THE MOVE IS GOING, as the panel can say it. -// -// A destination is EITHER a configured location, named by its id, OR a root the -// operator typed. The difference is not cosmetic: for a location the client -// sends the ID AND NOTHING ELSE, and the root is looked up here, from the same -// settings.json /storage writes. A form that posted the root alongside the id -// would be a second copy of a fact that already has one home, and the copy the -// server trusted would be the one the browser had when the page was rendered — -// stale the moment a re-point moved the location. -export type MoveDestination = - { kind: "location"; locationId: string } | { kind: "custom"; root: string }; - -type ResolvedDestination = { root: string } | { error: string }; - -function resolveDestination(dest: MoveDestination): ResolvedDestination { - if (dest.kind === "custom") { - const root = dest.root.trim(); - return root ? { root } : { error: "Enter a destination root." }; - } - const id = dest.locationId.trim(); - const found = getSettings().storage.locations.find((l) => l.id === id); - if (!found) { - return { - error: - `No storage location "${id}" is configured. ` + - `Add it on /storage, or choose a different destination.`, - }; - } - return { root: found.root }; -} - export type PreviewRelocationResult = { ok: true; preview: RelocationPreview } | { ok: false; error: string }; @@ -84,7 +57,10 @@ export async function previewRelocationAction( slug: string, dest: MoveDestination, ): Promise<PreviewRelocationResult> { - const resolved = resolveDestination(dest); + const resolved = resolveMoveDestination( + dest, + getSettings().storage.locations, + ); if ("error" in resolved) return { ok: false, error: resolved.error }; try { const preview = await previewRelocation({ @@ -102,7 +78,10 @@ export async function relocateChannelMediaAction( slug: string, dest: MoveDestination, ): Promise<StreamActionResult> { - const resolved = resolveDestination(dest); + const resolved = resolveMoveDestination( + dest, + getSettings().storage.locations, + ); if ("error" in resolved) return { ok: false, error: resolved.error }; const root = resolved.root; // Relative, or inside the corpus. The job refuses both too — it is the guard — diff --git a/editor/app/channels/bulkStorageActions.ts b/editor/app/channels/bulkStorageActions.ts @@ -39,6 +39,10 @@ import { relocationRootProblem } from "yt-dlp-transcript-common/controller/reloc import { enqueueRelocation } from "./lib/relocationJob"; import { queueForSlugs, type QueueOutcome } from "./lib/queueForSlugs"; import { channelMediaBusyReason } from "./lib/mediaBusy"; +import { + resolveMoveDestination, + type MoveDestination, +} from "./lib/moveDestination"; // The same shape syncAllChannelsAction and the per-group stage buttons return, // so the bar renders it the same way they do. @@ -56,33 +60,36 @@ async function isDirectory(p: string): Promise<boolean> { } } +// THE DESTINATION IS A LOCATION ID, not a root — see lib/moveDestination.ts. +// The deck picks a name from the list /storage maintains and sends the id; the +// root is resolved HERE, from the settings, so a stale page cannot aim a batch +// at a root a re-point has moved. `__custom` and a typed root are still +// accepted, for the one-off; omitting the destination entirely falls back to +// the default location, which is what an older caller did. export async function bulkRelocateChannelMediaAction( slugs: string[], - root?: string, + dest?: MoveDestination, ): Promise<BulkRelocateResult> { const paths = getPaths(); - // The bar's own box wins; blank falls back to the DEFAULT LOCATION's root. - // /storage is the only writer of the location list — this only reads it. - const chosen = - (root ?? "").trim() || defaultLocationRoot(getSettings().storage).trim(); + const storage = getSettings().storage; + const resolved = dest + ? resolveMoveDestination(dest, storage.locations) + : { root: defaultLocationRoot(storage).trim() }; + const refuseAll = (reason: string): BulkRelocateResult => ({ + queued: [], + skipped: slugs.map((slug) => ({ slug, reason })), + }); + if ("error" in resolved) return refuseAll(resolved.error); + const chosen = resolved.root.trim(); if (!chosen) { - return { - queued: [], - skipped: slugs.map((slug) => ({ - slug, - reason: - "no destination root — add a media location on /storage, or type one here", - })), - }; + return refuseAll( + "no destination root — add a media location on /storage, or type one here", + ); } if (!path.isAbsolute(chosen)) { - return { - queued: [], - skipped: slugs.map((slug) => ({ - slug, - reason: `the destination root must be an absolute path (got "${chosen}")`, - })), - }; + return refuseAll( + `the destination root must be an absolute path (got "${chosen}")`, + ); } return queueForSlugs(slugs, { @@ -121,7 +128,6 @@ export async function bulkRelocateChannelMediaAction( // can resolve into one channel's directory and not another's. return relocationRootProblem({ paths, slug, root: chosen }); }, - run: (slug) => - enqueueRelocation({ slug, direction: "out", root: chosen }), + run: (slug) => enqueueRelocation({ slug, direction: "out", root: chosen }), }); } diff --git a/editor/app/channels/components/ChannelSelectionDeck.tsx b/editor/app/channels/components/ChannelSelectionDeck.tsx @@ -42,6 +42,7 @@ import { bulkRelocateChannelMediaAction, type BulkRelocateResult, } from "../bulkStorageActions"; +import type { MoveDestination } from "../lib/moveDestination"; import { useBarAction } from "./ChannelFocusBar"; const TIER_LABEL: Record<StoredChannelTier, string> = { @@ -59,29 +60,58 @@ const EYEBROW = // the eye reads three groups and not eight loose buttons. const GROUP = "flex items-center gap-2 pl-4 border-l border-border"; +// One configured destination, projected by the server that built the page. +// Label and root only — the deck names a place and sends its id; whether that +// drive is mounted is /storage's report, and a probe per location is not +// something a table of 67 rows should pay for on every render. +export type BulkDestination = { id: string; label: string; root: string }; + +// The select's escape hatch: the option that reveals the free-text root box. +// Not a legal location id, so it can never collide with one. +const CUSTOM = "__custom"; + export function ChannelSelectionDeck({ slugs, onClear, - defaultRoot, + destinations, + defaultLocationId, }: { slugs: string[]; onClear: () => void; - // settings.storage.mediaRoot, read on the server. "" when no cold root is - // configured, which leaves the box empty and the action refusing with that as - // the reason. - defaultRoot: string; + // The configured storage locations, read on the server. Empty when none are + // configured, which leaves the free-text box as the whole control and the + // action refusing with "no destination root" as the reason. + destinations: BulkDestination[]; + // Which one the select opens on — `settings.storage.defaultLocationId`. + defaultLocationId: string; }) { // Two runners, because they are two writers: the priority actions clear the // selection on success, the relocate queues jobs and keeps it. - const { pending: priorityPending, error: priorityError, run } = useBarAction(); + const { + pending: priorityPending, + error: priorityError, + run, + } = useBarAction(); const [tier, setTier] = useState<StoredChannelTier>("normal"); const [movePending, startMove] = useTransition(); - const [root, setRoot] = useState(defaultRoot); + const [destId, setDestId] = useState(() => + destinations.some((d) => d.id === defaultLocationId) + ? defaultLocationId + : (destinations[0]?.id ?? CUSTOM), + ); + const [root, setRoot] = useState(""); const [result, setResult] = useState<BulkRelocateResult | null>(null); const [moveError, setMoveError] = useState<string | null>(null); if (slugs.length === 0) return null; + const chosen = destinations.find((d) => d.id === destId) ?? null; + const custom = destId === CUSTOM || chosen === null; const trimmed = root.trim(); + // The id alone for a configured location; the typed root only for `__custom`. + const destination: MoveDestination = custom + ? { kind: "custom", root: trimmed } + : { kind: "location", locationId: chosen.id }; + const named = custom ? trimmed !== "" : true; const busy = priorityPending || movePending; return ( @@ -119,7 +149,9 @@ export function ChannelSelectionDeck({ <button type="button" disabled={busy} - onClick={() => run(() => setChannelTierAction(slugs, tier), onClear)} + onClick={() => + run(() => setChannelTierAction(slugs, tier), onClear) + } className="rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:opacity-90 disabled:opacity-50" > Apply tier @@ -140,24 +172,50 @@ export function ChannelSelectionDeck({ <div className={GROUP}> <span className={EYEBROW}>Media</span> - {/* ONE root for the whole batch: a per-row destination is a per-row - decision, and that is what the channel's own Storage panel is for. - The root lives in the box rather than in the button's label — - `Move media to /mnt/platter/archilyzer-media` is what used to - truncate to "Move media to…" and leave the operator unable to read - where the files were going. */} - <input - type="text" - aria-label="bulk media root" - value={root} - disabled={busy} - onChange={(e) => setRoot(e.target.value)} - placeholder="/mnt/platter/archilyzer-media" - className="w-full max-w-72 md:w-80 md:max-w-none rounded-md border border-border bg-card px-2 py-1 text-xs font-mono disabled:opacity-50" - /> + {/* ONE destination for the whole batch: a per-row destination is a + per-row decision, and that is what the channel's own Storage panel + is for. The destination is on the deck rather than in the button's + label — `Move media to /mnt/platter/archilyzer-media` is what used + to truncate to "Move media to…" and leave the operator unable to + read where the files were going. */} + {destinations.length > 0 && ( + <select + aria-label="bulk media location" + value={destId} + disabled={busy} + onChange={(e) => setDestId(e.target.value)} + className="rounded-md border border-border bg-card px-2 py-1 text-xs disabled:opacity-50" + > + {destinations.map((d) => ( + <option key={d.id} value={d.id}> + {d.label} + </option> + ))} + <option value={CUSTOM}>Another root…</option> + </select> + )} + {chosen && ( + <span + aria-label="bulk media destination" + className="font-mono text-[11px] text-muted-foreground break-all" + > + {chosen.root} + </span> + )} + {custom && ( + <input + type="text" + aria-label="bulk media root" + value={root} + disabled={busy} + onChange={(e) => setRoot(e.target.value)} + placeholder="/mnt/platter/archilyzer-media" + className="w-full max-w-72 md:w-80 md:max-w-none rounded-md border border-border bg-card px-2 py-1 text-xs font-mono disabled:opacity-50" + /> + )} <button type="button" - disabled={busy || trimmed === ""} + disabled={busy || !named} aria-label="move media for selected channels" title="One relocate job per channel, on that channel's own queue. Channels that are already relocated, mid-move, social, or busy are skipped with a reason." onClick={() => @@ -166,7 +224,7 @@ export function ChannelSelectionDeck({ setResult(null); try { setResult( - await bulkRelocateChannelMediaAction(slugs, trimmed), + await bulkRelocateChannelMediaAction(slugs, destination), ); } catch (e) { setMoveError((e as Error).message); diff --git a/editor/app/channels/components/ChannelsTable.tsx b/editor/app/channels/components/ChannelsTable.tsx @@ -7,10 +7,7 @@ import { bandSentence, type OperationBand, } from "yt-dlp-transcript-common/views/pipeline/band"; -import { - BandLegend, - StateBand, -} from "../../components/pipelines/StateBand"; +import { BandLegend, StateBand } from "../../components/pipelines/StateBand"; import type { ChannelGroupSection } from "yt-dlp-transcript-common/views/channelGroupSections"; import { ChannelGroupHeaderRow } from "./ChannelGroupHeaderRow"; import { ChannelAvailabilityButton } from "./ChannelAvailabilityButton"; @@ -23,7 +20,10 @@ import { MediaLocationBadge, type MediaBadgeInput, } from "../../components/MediaLocationBadge"; -import { ChannelSelectionDeck } from "./ChannelSelectionDeck"; +import { + ChannelSelectionDeck, + type BulkDestination, +} from "./ChannelSelectionDeck"; import { tierOrder, type PriorityOperation, @@ -52,7 +52,10 @@ export type ChannelRow = ChannelStat & { // How old this channel's report is. Every count and every band on this row is // projected from that report, so its age is the caveat on all of them — which // is why it belongs beside them rather than on a page of its own. - report: { generatedAt: string | null; state: "current" | "stale" | "missing" }; + report: { + generatedAt: string | null; + state: "current" | "stale" | "missing"; + }; // Where this channel's media physically is, from inspectChannelMedia on the // server. Null for an in-place channel — the overwhelming majority — so the // badge column is empty for them and the two that matter stand out. See @@ -223,7 +226,8 @@ export function ChannelsTable({ columns, sections = null, siteId, - defaultMediaRoot = "", + mediaDestinations = [], + defaultLocationId = "", sites = [], focusLabel = null, }: { @@ -238,9 +242,12 @@ export function ChannelsTable({ // pool. That path is today's flat table, unchanged. sections?: ChannelGroupSection[] | null; siteId?: string; - // settings.storage.mediaRoot, resolved on the server. Seeds the bulk bar's - // root box; "" when no cold root is configured. - defaultMediaRoot?: string; + // The configured storage locations, resolved on the server. The selection + // deck's destination list; empty when none are configured, which leaves the + // deck's free-text root box as the whole control. + mediaDestinations?: BulkDestination[]; + // `settings.storage.defaultLocationId` — which destination the deck opens on. + defaultLocationId?: string; // Every configured site, for the "Focus site" control. Not the same list as // the page's scope selector: a focus is corpus-wide, so it can name a site // whose channels are not the ones on screen. @@ -518,7 +525,8 @@ export function ChannelsTable({ <ChannelSelectionDeck slugs={selectedSlugs} onClear={() => setSelected(new Set())} - defaultRoot={defaultMediaRoot} + destinations={mediaDestinations} + defaultLocationId={defaultLocationId} /> </div> ); @@ -671,7 +679,9 @@ function ChannelTableRow({ <Td ariaLabel={`report age for ${c.slug}`} className={`whitespace-nowrap text-xs tabular-nums ${ - c.report.state === "current" ? "text-muted-foreground" : "text-warning" + c.report.state === "current" + ? "text-muted-foreground" + : "text-warning" }${dim}`} > {c.report.state === "current" @@ -703,7 +713,9 @@ function ChannelTableRow({ <div className="flex items-center gap-2"> <ChannelSyncButton slug={c.slug} disabled={!c.config.url} /> <ChannelAvailabilityButton slug={c.slug} disabled={!c.config.url} /> - <InlineActionButton variant={{ kind: "refreshReport", slug: c.slug }} /> + <InlineActionButton + variant={{ kind: "refreshReport", slug: c.slug }} + /> </div> </Td> </tr> @@ -772,7 +784,10 @@ function SortableTh({ > <span>{label}</span> {indicator && ( - <span aria-hidden="true" className="text-[10px] text-muted-foreground"> + <span + aria-hidden="true" + className="text-[10px] text-muted-foreground" + > {indicator} </span> )} diff --git a/editor/app/channels/lib/moveDestination.ts b/editor/app/channels/lib/moveDestination.ts @@ -0,0 +1,42 @@ +import type { StorageLocation } from "yt-dlp-transcript-common/lib/storageLocations"; + +// WHERE A MOVE IS GOING, as the two panels that start one can say it. +// +// A destination is EITHER a configured location, named by its id, OR a root the +// operator typed. The difference is not cosmetic: for a location the client +// sends the ID AND NOTHING ELSE, and the root is looked up on the server, from +// the same settings.json /storage writes. A form that posted the root alongside +// the id would be a second copy of a fact that already has one home, and the +// copy the server trusted would be the one the browser held when the page was +// rendered — stale the moment a re-point moved the location somewhere else. +// +// NOT IN EITHER ACTIONS FILE, for the reason lib/relocationJob.ts and +// lib/queueForSlugs.ts are not either: both carry "use server", where every +// non-type export is a server action. A shared resolver cannot live in one. +export type MoveDestination = + { kind: "location"; locationId: string } | { kind: "custom"; root: string }; + +export type ResolvedDestination = { root: string } | { error: string }; + +// The root, or the sentence explaining why there is not one. Pure: the caller +// hands it `getSettings().storage.locations`, so this is unit-testable and has +// no opinion about which process is asking. +export function resolveMoveDestination( + dest: MoveDestination, + locations: readonly StorageLocation[], +): ResolvedDestination { + if (dest.kind === "custom") { + const root = dest.root.trim(); + return root ? { root } : { error: "Enter a destination root." }; + } + const id = dest.locationId.trim(); + const found = locations.find((l) => l.id === id); + if (!found) { + return { + error: + `No storage location "${id}" is configured. ` + + `Add it on /storage, or choose a different destination.`, + }; + } + return { root: found.root }; +} diff --git a/editor/app/channels/page.tsx b/editor/app/channels/page.tsx @@ -30,10 +30,7 @@ import { getSettings, type SiteSettings, } from "yt-dlp-transcript-common/lib/settings"; -import { - defaultLocationRoot, - locationLabelOfDataDir, -} from "yt-dlp-transcript-common/lib/storageLocations"; +import { locationLabelOfDataDir } 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 { @@ -364,10 +361,16 @@ export default async function ChannelsPage({ columns={columns} sections={sections} siteId={activeSite?.siteId} - // 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={defaultLocationRoot(getSettings().storage)} + // The configured storage locations, for the selection deck's + // destination select. Read here, not in the client component — + // /storage is their one writer. No probe: the deck names a place and + // sends its id, and whether the drive is mounted is /storage's report. + mediaDestinations={settings.storage.locations.map((loc) => ({ + id: loc.id, + label: loc.label || loc.id, + root: loc.root, + }))} + defaultLocationId={settings.storage.defaultLocationId} sites={sites.map((s) => ({ siteId: s.siteId, title: s.siteTitle || s.siteId,