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:
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,