Archilyzer · Source

archilyzer

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

commit 784060a03e6d15d4f31f878f0b8edcf055aab153
parent c3fccb430c6f4719a482adbb0c1332946b6ed6b7
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Fri, 11 Sep 2026 12:16:29 -0400

editor: moving a page of channels to the cold drive is one job each

ONE JOB PER CHANNEL, on that channel's own `channelQueueKey`. No batch
controller, and deliberately not: the queue already serializes a channel against
its own downloads and transcriptions, which is the ordering that matters, and a
cross-channel batch would have to reinvent cancellation, resume and the
per-channel log that already exist.

EVERY CHECK RUNS TWICE, AND THAT IS THE POINT. At enqueue the action refuses
what it can see now — not a channel, social, already relocated, mid-transition,
busy, or a root that resolves inside the corpus. What it cannot see is the
destination twenty minutes from now, so each job re-runs its whole preflight
when it STARTS. A root that fills up partway through a selection refuses the
remainder one job at a time, with the reason in that job's own log, instead of
the batch failing at the moment the first job was enqueued. That is also why the
bar has no preview gate where the per-channel panel has one: a preview measures
one tree against one volume, and for a batch the useful answer is not the sum of
the previews, because the root fills as the jobs run.

ALREADY RELOCATED IS A SKIP, not a failure, including to a different root —
selecting the whole page and pressing Move is the ordinary use, and the channels
already on the platter are exactly the ones that should drop out quietly. The
result is `{queued, skipped: [{slug, reason}]}`, the shape
`syncAllChannelsAction` and the group buttons already return, rendered by the
same "Queued N · skipped M · view jobs" with the per-slug reasons on the title.

The bar's box wins over `settings.storage.mediaRoot`; blank falls back to it.
Neither writes it — the settings page is still the one writer.

`enqueueRelocation` is lifted out of storageActions.ts unchanged, for the reason
lib/queueForSlugs.ts exists: a "use server" file's every export is an endpoint,
so a shared helper cannot live in one. Both callers keep their own guards; what
is shared is only the job record's shape, which is what must not drift between a
single move and a bulk one.

tsc clean in common, editor, export and mcp.

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

Diffstat:
Meditor/app/channels/[slug]/storageActions.ts | 53++++-------------------------------------------------
Aeditor/app/channels/bulkStorageActions.ts | 108+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/channels/components/ChannelStorageBulkBar.tsx | 121+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/channels/components/ChannelsTable.tsx | 10++++++++++
Aeditor/app/channels/lib/relocationJob.ts | 67+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/channels/page.tsx | 3+++
6 files changed, 313 insertions(+), 49 deletions(-)

diff --git a/editor/app/channels/[slug]/storageActions.ts b/editor/app/channels/[slug]/storageActions.ts @@ -24,20 +24,15 @@ import { revalidatePath } from "next/cache"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; -import { channelQueueKey } from "yt-dlp-transcript-common/lib/queueKeys"; import { getRegistry } from "yt-dlp-transcript-common/jobs/registry"; -import { - runManagedFunction, - type StreamActionResult, -} from "yt-dlp-transcript-common/jobs/streamCommand"; -import { formatBytes } from "yt-dlp-transcript-common/lib/format"; +import type { StreamActionResult } from "yt-dlp-transcript-common/jobs/streamCommand"; import { clearRelocationMarker } from "yt-dlp-transcript-common/lib/channelMedia"; import { previewRelocation, - relocateChannelMedia, relocationRootProblem, type RelocationPreview, } from "yt-dlp-transcript-common/controller/relocateChannelMedia"; +import { enqueueRelocation } from "../lib/relocationJob"; export type PreviewRelocationResult = | { ok: true; preview: RelocationPreview } @@ -99,7 +94,7 @@ export async function relocateChannelMediaAction( if (rootProblem) return { ok: false, error: rootProblem }; const refusal = activeJobsRefusal(slug, "moving its media"); if (refusal) return { ok: false, error: refusal }; - return runMove(slug, "out", trimmed); + return enqueueRelocation({ slug, direction: "out", root: trimmed }); } export async function moveChannelMediaBackAction( @@ -107,47 +102,7 @@ export async function moveChannelMediaBackAction( ): Promise<StreamActionResult> { const refusal = activeJobsRefusal(slug, "moving its media back"); if (refusal) return { ok: false, error: refusal }; - return runMove(slug, "back"); -} - -async function runMove( - slug: string, - direction: "out" | "back", - root?: string, -): Promise<StreamActionResult> { - const paths = getPaths(); - return runManagedFunction({ - kind: "relocate-channel-media", - queueKey: channelQueueKey(slug), - paths, - channelSlug: slug, - fn: async (onLog, signal) => { - const result = await relocateChannelMedia({ - paths, - slug, - direction, - root, - onLog, - signal, - }); - onLog( - `${direction === "out" ? "Moved" : "Moved back"} ${result.files} file(s) / ` + - `${formatBytes(result.bytes)} — ${result.target}` + - (result.resumed ? " (resumed an interrupted move)" : ""), - ); - // No snapshot regen — deliberately, and `relocate-channel-media` is in - // NO_REGEN_KINDS so the central hook does not arm one either. The move - // changes where the bytes are, not what they are: every count in the - // report is identical afterwards, and a regen would be a full walk of the - // channel to rewrite the same numbers under a newer timestamp. - // - // What DOES have to change is what the pages read per render — the badge, - // the location line, the free-space figure — so those are revalidated. - revalidatePath(`/channels/${slug}`); - revalidatePath("/channels"); - revalidatePath("/"); - }, - }); + return enqueueRelocation({ slug, direction: "back" }); } // THE LAST RESORT, and the only one of the three that is not a move. diff --git a/editor/app/channels/bulkStorageActions.ts b/editor/app/channels/bulkStorageActions.ts @@ -0,0 +1,108 @@ +"use server"; + +// MOVE SEVERAL CHANNELS' MEDIA TO THE COLD ROOT, FROM /channels. +// +// ONE JOB PER CHANNEL, on that channel's own `channelQueueKey`. There is no +// batch controller and there is deliberately not going to be one: the job queue +// already serializes a channel against its own downloads and transcriptions, +// which is the ordering that matters, and a cross-channel batch would have to +// reinvent cancellation, resume and the per-channel log that already exist. +// +// EVERY CHECK RUNS TWICE, AND THAT IS THE DESIGN. What this action refuses at +// ENQUEUE time is what it can see now — a social channel, a channel already +// relocated, one mid-transition, one with live jobs. What it cannot see is the +// state of the destination twenty minutes from now, so each job runs its own +// `previewRelocation`-equivalent preflight (space, writability, containment, +// movable state) when it STARTS. A root that fills up partway through a +// selection therefore refuses the remainder cleanly, one job at a time, instead +// of the whole bulk failing at the point the first one is enqueued. +// +// A skip is never a failure: the result names every slug that did not queue and +// why, and the bar renders both numbers. + +import path from "node:path"; +import { getPaths } from "yt-dlp-transcript-common/lib/paths"; +import { getSettings } from "yt-dlp-transcript-common/lib/settings"; +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"; +import { readChannelConfig } from "yt-dlp-transcript-common/controller/channels"; +import { relocationRootProblem } from "yt-dlp-transcript-common/controller/relocateChannelMedia"; +import { enqueueRelocation } from "./lib/relocationJob"; +import { queueForSlugs, type QueueOutcome } from "./lib/queueForSlugs"; + +// The same shape syncAllChannelsAction and the per-group stage buttons return, +// so the bar renders it the same way they do. +export type BulkRelocateResult = QueueOutcome; + +// The rename's guard, per channel — the copy in [slug]/storageActions.ts, for +// the reason stated there: a download writing into `data/` while its bytes are +// being copied out either fails the verify (safe) or is lost (not). +function activeJobsRefusal(slug: string): string | null { + const active = getRegistry() + .list() + .filter( + (j) => + j.channelSlug === slug && + (j.status === "running" || j.status === "queued"), + ); + if (active.length === 0) return null; + return `${active.length} running/queued job(s) for this channel`; +} + +export async function bulkRelocateChannelMediaAction( + slugs: string[], + root?: string, +): Promise<BulkRelocateResult> { + 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(); + if (!chosen) { + return { + queued: [], + skipped: slugs.map((slug) => ({ + slug, + reason: + "no destination root — set a default media root in Settings, 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 queueForSlugs(slugs, { + skip: async (slug) => { + const config = await readChannelConfig(paths, slug); + if (!config) return "channel not found"; + if (isSocialChannel(config)) { + return "social channel — it has no downloaded media"; + } + const jobs = activeJobsRefusal(slug); + if (jobs) return jobs; + const media = await inspectChannelMedia(paths, slug, config); + if (media.marker) { + return `a relocation (${media.marker.direction}) to ${media.marker.target} is already in flight`; + } + // ALREADY RELOCATED IS A SKIP, NOT A FAILURE — including to a DIFFERENT + // root. Selecting the whole page and pressing Move is the ordinary way + // this gets used, and the channels already on the platter are exactly the + // ones that should quietly drop out of the batch. + if (media.relocated) { + return `already relocated to ${media.target ?? "another root"}`; + } + // Containment is per-channel because the target is: `<root>/<slug>/data` + // 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 }), + }); +} diff --git a/editor/app/channels/components/ChannelStorageBulkBar.tsx b/editor/app/channels/components/ChannelStorageBulkBar.tsx @@ -0,0 +1,121 @@ +"use client"; + +// THE SELECTION BAR for moving several channels' media to the cold root. +// +// Appears only with a selection, the idiom SyncConsole's bulk bar established. +// The root box is seeded from settings.storage.mediaRoot and is the only thing +// on this bar that is not a button: one root for the whole batch, because a +// per-row destination is a per-row decision and that is what the channel's own +// Storage panel is for. +// +// NO PREVIEW GATE HERE, unlike that panel, and the asymmetry is deliberate: a +// preview measures ONE channel's tree against one volume, and the useful answer +// for a batch ("will all of these fit") is not the sum of the previews — the +// root fills up as the jobs run. Each job therefore re-checks space when it +// STARTS, so a root that fills partway refuses the remainder one job at a time +// with the reason in that job's log. What the bar owes the operator is the +// count and the skips, not a number that would be stale before the second job. + +import { useState, useTransition } from "react"; +import Link from "next/link"; +import { + bulkRelocateChannelMediaAction, + type BulkRelocateResult, +} from "../bulkStorageActions"; + +export function ChannelStorageBulkBar({ + slugs, + onClear, + defaultRoot, +}: { + slugs: string[]; + onClear: () => void; + // settings.storage.mediaRoot, read on the server. "" when none is configured, + // which leaves the box empty and the action refusing with that as the reason. + defaultRoot: string; +}) { + const [pending, startTransition] = useTransition(); + const [root, setRoot] = useState(defaultRoot); + const [result, setResult] = useState<BulkRelocateResult | null>(null); + const [error, setError] = useState<string | null>(null); + + if (slugs.length === 0) return null; + const trimmed = root.trim(); + + return ( + <div + aria-label="channel media bulk" + className="flex flex-wrap items-center gap-2 rounded border border-border bg-card px-3 py-2 text-sm" + > + <span className="text-muted-foreground"> + {slugs.length} selected + </span> + <input + type="text" + aria-label="bulk media root" + value={root} + disabled={pending} + onChange={(e) => setRoot(e.target.value)} + placeholder="/mnt/platter/archilyzer-media" + className="rounded-md border border-border bg-card px-2 py-1 text-xs font-mono disabled:opacity-50" + /> + <button + type="button" + disabled={pending || trimmed === ""} + 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={() => + startTransition(async () => { + setError(null); + setResult(null); + try { + setResult(await bulkRelocateChannelMediaAction(slugs, trimmed)); + } catch (e) { + setError((e as Error).message); + } + }) + } + className="rounded-md border border-border px-2 py-1 text-xs hover:bg-muted disabled:opacity-50" + > + {pending + ? "Queueing…" + : `Move media to ${trimmed === "" ? "…" : trimmed}`} + </button> + <button + type="button" + disabled={pending} + onClick={onClear} + className="rounded-md border border-border px-2 py-1 text-xs hover:bg-muted disabled:opacity-50" + > + Clear + </button> + {result && ( + <span + aria-label="bulk media move result" + className="text-xs text-muted-foreground" + title={ + result.skipped.length === 0 + ? undefined + : result.skipped + .map((s) => `${s.slug}: ${s.reason}`) + .join("\n") + } + > + Queued {result.queued.length} · skipped {result.skipped.length} ·{" "} + <Link href="/jobs" className="underline hover:text-foreground"> + view jobs + </Link> + </span> + )} + {error && ( + <span + role="alert" + aria-label="bulk media move error" + className="text-xs text-destructive" + > + {error} + </span> + )} + </div> + ); +} diff --git a/editor/app/channels/components/ChannelsTable.tsx b/editor/app/channels/components/ChannelsTable.tsx @@ -22,6 +22,7 @@ import { MediaLocationBadge, type MediaBadgeInput, } from "../../components/MediaLocationBadge"; +import { ChannelStorageBulkBar } from "./ChannelStorageBulkBar"; // A row is a stat plus its pipeline bands, in column order. The bands are // projected on the server from the same snapshot the counts come from, so a @@ -187,6 +188,7 @@ export function ChannelsTable({ columns, sections = null, siteId, + defaultMediaRoot = "", }: { channels: ChannelRow[]; // Which pipelines to draw, in group order, resolved on the server from the @@ -199,6 +201,9 @@ 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; }) { const [sort, setSort] = useState<SortState>(null); // Slugs ticked for a bulk edit. A Set of SLUGS, not indices, so a @@ -387,6 +392,11 @@ export function ChannelsTable({ </tbody> )} </table> + <ChannelStorageBulkBar + slugs={selectedSlugs} + onClear={() => setSelected(new Set())} + defaultRoot={defaultMediaRoot} + /> <div className="flex flex-col gap-1 px-3 py-2 md:px-0"> <BandLegend /> {columns.some((c) => c.id.startsWith("attribution-")) && ( diff --git a/editor/app/channels/lib/relocationJob.ts b/editor/app/channels/lib/relocationJob.ts @@ -0,0 +1,67 @@ +import { revalidatePath } from "next/cache"; +import { getPaths } from "yt-dlp-transcript-common/lib/paths"; +import { channelQueueKey } from "yt-dlp-transcript-common/lib/queueKeys"; +import { + runManagedFunction, + type StreamActionResult, +} from "yt-dlp-transcript-common/jobs/streamCommand"; +import { formatBytes } from "yt-dlp-transcript-common/lib/format"; +import { relocateChannelMedia } from "yt-dlp-transcript-common/controller/relocateChannelMedia"; +import type { RelocationDirection } from "yt-dlp-transcript-common/lib/channelMedia"; + +// ONE ENQUEUE OF THE RELOCATION JOB, for the two callers that have one: the +// per-channel Storage panel ([slug]/storageActions.ts) and the /channels bulk +// move (bulkStorageActions.ts). +// +// Deliberately NOT in either of those files: both carry "use server", which +// means every non-type export in them is a server action — a shared helper +// cannot live there, and exporting this one from a "use server" file would put +// an unguarded enqueue on the wire under its own endpoint. Same reason +// lib/queueForSlugs.ts exists. +// +// THE GUARDS ARE THE CALLERS'. This function refuses nothing: each caller runs +// its own active-jobs check and its own root check before reaching here, and the +// controller re-checks everything at run time regardless. What is shared is only +// the job record's shape — kind, queue key, channel slug, the summary line and +// the three revalidations — because those are what must not drift between a +// single move and a bulk one. +export async function enqueueRelocation(opts: { + slug: string; + direction: RelocationDirection; + root?: string; +}): Promise<StreamActionResult> { + const { slug, direction, root } = opts; + const paths = getPaths(); + return runManagedFunction({ + kind: "relocate-channel-media", + queueKey: channelQueueKey(slug), + paths, + channelSlug: slug, + fn: async (onLog, signal) => { + const result = await relocateChannelMedia({ + paths, + slug, + direction, + root, + onLog, + signal, + }); + onLog( + `${direction === "out" ? "Moved" : "Moved back"} ${result.files} file(s) / ` + + `${formatBytes(result.bytes)} — ${result.target}` + + (result.resumed ? " (resumed an interrupted move)" : ""), + ); + // No snapshot regen — deliberately, and `relocate-channel-media` is in + // NO_REGEN_KINDS so the central hook does not arm one either. The move + // changes where the bytes are, not what they are: every count in the + // report is identical afterwards, and a regen would be a full walk of the + // channel to rewrite the same numbers under a newer timestamp. + // + // What DOES have to change is what the pages read per render — the badge, + // the location line, the free-space figure — so those are revalidated. + revalidatePath(`/channels/${slug}`); + revalidatePath("/channels"); + revalidatePath("/"); + }, + }); +} diff --git a/editor/app/channels/page.tsx b/editor/app/channels/page.tsx @@ -219,6 +219,9 @@ export default async function ChannelsPage({ columns={columns} sections={sections} siteId={activeSite?.siteId} + // The configured cold root, for the bulk bar's box. Read here, not + // in the client component — the settings page is its one writer. + defaultMediaRoot={getSettings().storage.mediaRoot} /> <p className="text-xs text-muted-foreground"