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:
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;
+}