Archilyzer · Source

archilyzer

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

commit 46801ea4dca7abc8c3cfd2e7968210f104dd3514
parent 6a84cc393b08c5afacabd5f3604eadbddd7a8565
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Thu, 17 Sep 2026 13:42:51 -0400

channels: "busy" means jobs AND in-flight lane units, in one place

Three copies of the same guard asked the job registry and only the job
registry. The auto-queue lanes run their units in-process and make no job
record — operationBatch.ts says so deliberately, because 119,600 job records
would evict the registry — so the digest unit that raced the omnimirror copy
was invisible to all three.

channelMediaBusyReason() asks the registry and every lane report
(`getAutoRunnerStatus(kind).inFlight[]` carries the channelSlug). Four map
reads, no disk, no await, so it is render-cheap. The caller supplies the verb,
so the same sentence reads as an instruction in the panel and as a skip reason
in the bulk bar.

Server-side only: it reaches the controller, which imports execa. `next build`
is the proof nothing client-reachable touches it.

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

Diffstat:
Meditor/app/channels/[slug]/page.tsx | 19+++++++++----------
Meditor/app/channels/[slug]/storageActions.ts | 29+++++++----------------------
Meditor/app/channels/bulkStorageActions.ts | 23+++++------------------
Aeditor/app/channels/lib/mediaBusy.ts | 53+++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 74 insertions(+), 50 deletions(-)

diff --git a/editor/app/channels/[slug]/page.tsx b/editor/app/channels/[slug]/page.tsx @@ -50,6 +50,7 @@ import { DIGEST_REMOTE_QUEUE, } from "yt-dlp-transcript-common/lib/queueKeys"; import { getRegistry } from "yt-dlp-transcript-common/jobs/registry"; +import { channelMediaBusyReason } from "../lib/mediaBusy"; import { listSites } from "yt-dlp-transcript-common/lib/site"; import { sortGroups } from "yt-dlp-transcript-common/lib/channelGroups"; import { ChannelFormClient } from "../components/ChannelFormClient"; @@ -521,19 +522,17 @@ export default async function ChannelDetailPage({ // bytes that could disagree with the status rendered beside it. const marker = media.marker ?? null; const freeBytes = await getFreeBytes(volumeDir); - const activeJobs = runningJobs.filter( - (j) => j.status === "running" || j.status === "queued", - ).length; // The SAME two conditions storageActions.ts refuses on, stated here as // prose so the button is off with a reason rather than off and silent — // and stated in the action too, because a disabled button is a courtesy - // and the server is the guard. + // and the server is the guard. "Busy" is jobs AND in-flight auto-queue + // units, which make no job record — see lib/mediaBusy.ts. + const busy = channelMediaBusyReason(slug, "moving its media"); const blockedReason = - activeJobs > 0 - ? `Finish or cancel ${activeJobs} running/queued job(s) for this channel before moving its media.` - : marker - ? `A relocation (${marker.direction}) to ${marker.target} is in flight, or was interrupted at phase "${marker.phase}". A channel in transition is not moved again from here — the running job finishes it, and an interrupted one is resumed by rerunning the move.` - : null; + busy ?? + (marker + ? `A relocation (${marker.direction}) to ${marker.target} is in flight, or was interrupted at phase "${marker.phase}". A channel in transition is not moved again from here — the running job finishes it, and an interrupted one is resumed by rerunning the move.` + : null); return ( <StorageStage slug={slug} @@ -547,7 +546,7 @@ export default async function ChannelDetailPage({ blockedReason={blockedReason} // A marker with no job behind it is a stale marker: the run that // wrote it is gone, and nothing else will ever clear it. - canClearMarker={marker !== null && activeJobs === 0} + canClearMarker={marker !== null && busy === null} // 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} diff --git a/editor/app/channels/[slug]/storageActions.ts b/editor/app/channels/[slug]/storageActions.ts @@ -9,7 +9,9 @@ // a job record, a streamed log, a queue slot and a cancel button like every // other long action in the editor. // -// THE ACTIVE-JOBS GUARD IS THE RENAME'S, deliberately copied rather than shared: +// THE BUSY GUARD IS THE RENAME'S, widened: channelMediaBusyReason() asks the job +// registry AND the auto-queue lanes, because a lane's units make no job record — +// see lib/mediaBusy.ts. The rename's version is the ancestor: // renameChannelAction refuses while the channel has running or queued jobs // because the in-memory registry keys by slug and those jobs would be orphaned // by the move. A relocation has the same hazard with a sharper edge — a download @@ -24,7 +26,6 @@ import { revalidatePath } from "next/cache"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; -import { getRegistry } from "yt-dlp-transcript-common/jobs/registry"; import type { StreamActionResult } from "yt-dlp-transcript-common/jobs/streamCommand"; import { clearRelocationMarker } from "yt-dlp-transcript-common/lib/channelMedia"; import { @@ -33,6 +34,7 @@ import { type RelocationPreview, } from "yt-dlp-transcript-common/controller/relocateChannelMedia"; import { enqueueRelocation } from "../lib/relocationJob"; +import { channelMediaBusyReason } from "../lib/mediaBusy"; export type PreviewRelocationResult = | { ok: true; preview: RelocationPreview } @@ -60,23 +62,6 @@ export async function previewRelocationAction( } } -// The rename's guard (editor/app/channels/actions.ts), returning the shape -// StreamActionLog already renders rather than an ActionResult. -function activeJobsRefusal(slug: string, what: 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 ( - `Finish or cancel ${active.length} running/queued job(s) for this channel ` + - `before ${what}.` - ); -} - export async function relocateChannelMediaAction( slug: string, root: string, @@ -92,7 +77,7 @@ export async function relocateChannelMediaAction( root: trimmed, }); if (rootProblem) return { ok: false, error: rootProblem }; - const refusal = activeJobsRefusal(slug, "moving its media"); + const refusal = channelMediaBusyReason(slug, "moving its media"); if (refusal) return { ok: false, error: refusal }; return enqueueRelocation({ slug, direction: "out", root: trimmed }); } @@ -100,7 +85,7 @@ export async function relocateChannelMediaAction( export async function moveChannelMediaBackAction( slug: string, ): Promise<StreamActionResult> { - const refusal = activeJobsRefusal(slug, "moving its media back"); + const refusal = channelMediaBusyReason(slug, "moving its media back"); if (refusal) return { ok: false, error: refusal }; return enqueueRelocation({ slug, direction: "back" }); } @@ -123,7 +108,7 @@ export async function moveChannelMediaBackAction( export async function clearRelocationMarkerAction( slug: string, ): Promise<{ ok: true } | { ok: false; error: string }> { - const refusal = activeJobsRefusal(slug, "clearing its relocation marker"); + const refusal = channelMediaBusyReason(slug, "clearing its relocation marker"); if (refusal) return { ok: false, error: refusal }; try { await clearRelocationMarker(getPaths(), slug); diff --git a/editor/app/channels/bulkStorageActions.ts b/editor/app/channels/bulkStorageActions.ts @@ -31,13 +31,13 @@ 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 { 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"; +import { channelMediaBusyReason } from "./lib/mediaBusy"; // The same shape syncAllChannelsAction and the per-group stage buttons return, // so the bar renders it the same way they do. @@ -55,21 +55,6 @@ async function isDirectory(p: string): Promise<boolean> { } } -// 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, @@ -105,8 +90,10 @@ export async function bulkRelocateChannelMediaAction( if (isSocialChannel(config)) { return "social channel — it has no downloaded media"; } - const jobs = activeJobsRefusal(slug); - if (jobs) return jobs; + // Jobs AND in-flight auto-queue units — the lanes write into `data/` + // and make no job record. See lib/mediaBusy.ts. + const busy = channelMediaBusyReason(slug); + if (busy) return busy; const media = await inspectChannelMedia(paths, slug, config); // NOTHING TO MOVE IS A SKIP, NOT A JOB. A channel that has downloaded // nothing has no `data/` at all, and inspect() calls that `in-place` — diff --git a/editor/app/channels/lib/mediaBusy.ts b/editor/app/channels/lib/mediaBusy.ts @@ -0,0 +1,53 @@ +// IS ANYTHING WRITING INTO THIS CHANNEL'S MEDIA RIGHT NOW? +// +// One question, one answer, three callers: the per-channel Storage panel's +// actions, the bulk move from /channels, and the channel page that greys the +// buttons out with a reason. They each used to ask the job registry and only +// the job registry — and the registry is HALF the truth. +// +// THE OMNIMIRROR INCIDENT (2026-09-13) is the half that was missing. The +// auto-queue lanes run their units IN-PROCESS: "the auto-queue runner's +// per-video units are IN-PROCESS and make no job record" (operationBatch.ts's +// header says so, and that is deliberate — 119,600 job records would evict the +// registry). So a digest unit was writing a sidecar into `data/` while the +// relocate job's rsync was already past that directory; nothing in the registry +// named the channel, the move was allowed, and the copy's verify refused at the +// last step with 131 GB on the far side. +// +// `getAutoRunnerStatus(kind).inFlight[]` is the in-process half: one entry per +// reserved unit, each carrying the `channelSlug` it belongs to. Asking every +// lane is four map reads — no disk, no await — so this is cheap enough to call +// on a render. +// +// SERVER-SIDE ONLY. It reaches into the controller (which imports execa, +// transitively), so nothing reachable from a `"use client"` file may import it; +// `next build` is what proves that. + +import { getRegistry } from "yt-dlp-transcript-common/jobs/registry"; +import { getAutoRunnerStatus } from "yt-dlp-transcript-common/controller/autoRunner"; +import { LANES } from "yt-dlp-transcript-common/lib/autoQueueTypes"; + +// A sentence naming what is holding the channel, or null when nothing is. The +// caller supplies the verb (`"moving its media"`) so the same reason reads as +// an instruction in every panel it appears in. +export function channelMediaBusyReason( + slug: string, + what?: string, +): string | null { + const jobs = getRegistry() + .list() + .filter( + (j) => + j.channelSlug === slug && + (j.status === "running" || j.status === "queued"), + ).length; + const units = LANES.flatMap((kind) => getAutoRunnerStatus(kind).inFlight) + .filter((u) => u.channelSlug === slug).length; + if (jobs === 0 && units === 0) return null; + + const parts: string[] = []; + if (jobs > 0) parts.push(`${jobs} running/queued job(s)`); + if (units > 0) parts.push(`${units} auto-queue unit(s) in flight`); + const subject = `${parts.join(" and ")} for this channel`; + return what ? `Finish or cancel ${subject} before ${what}.` : subject; +}