commit 470b78d696e83fe5b9a3eff907f5fdcf0d0b4b94
parent 2b28e4f80f9171053c70f660f5d8c7ad0b2276e9
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Tue, 22 Sep 2026 16:03:08 -0400
editor: rename and delete ask the same busy question every other surface asks
Rename counted running/queued JOBS. The registry is half the truth — the
auto-queue lanes run their per-video units in-process and make no job record
(the omnimirror incident, lib/mediaBusy.ts) — so a digest unit writing a sidecar
into data/ was invisible to it, and renaming moves that directory.
Delete asked nothing at all, which is the worse of the two: an orphaned registry
entry is a nuisance, a delete racing a download for the tree loses bytes.
Both call channelMediaBusyReason now, with their own verb, and the Danger-zone
forms carry the reason so a form that cannot submit says why before it is filled
in rather than after.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
5 files changed, 179 insertions(+), 18 deletions(-)
diff --git a/editor/app/channels/[slug]/page.tsx b/editor/app/channels/[slug]/page.tsx
@@ -643,11 +643,20 @@ export default async function ChannelDetailPage({
/>
);
}
- case "danger":
+ case "danger": {
+ // THE SAME SENTENCE THE ACTIONS REFUSE WITH, one click earlier. Both
+ // actions ask this themselves — a disabled button is a courtesy and the
+ // server is the guard — but a Danger-zone form that submits, moves
+ // nothing and comes back with a paragraph is the worst place to learn
+ // that a lane was mid-write. The verb differs per form so the reason
+ // reads as an instruction in each.
+ const renameBusy = channelMediaBusyReason(slug, "renaming it");
+ const deleteBusy = channelMediaBusyReason(slug, "deleting it");
return (
<div className="flex flex-col gap-4">
<RenameChannelForm
slug={slug}
+ busyReason={renameBusy}
action={
renameChannelAction.bind(null, slug) as (
prev: ActionResult,
@@ -658,6 +667,7 @@ export default async function ChannelDetailPage({
<hr className="border-border" />
<DeleteChannelForm
slug={slug}
+ busyReason={deleteBusy}
action={
deleteChannelAction.bind(null, slug) as (
prev: ActionResult,
@@ -667,6 +677,7 @@ export default async function ChannelDetailPage({
/>
</div>
);
+ }
}
}
diff --git a/editor/app/channels/actions.ts b/editor/app/channels/actions.ts
@@ -21,6 +21,7 @@ import {
writeChannelConfig,
} from "yt-dlp-transcript-common/controller/channels";
import { renameChannel } from "yt-dlp-transcript-common/controller/renameChannel";
+import { channelMediaBusyReason } from "./lib/mediaBusy";
import {
excludedDownloadIdSet,
generateChannelSnapshot,
@@ -577,6 +578,15 @@ export async function deleteChannelAction(
error: `Type the channel slug "${slug}" exactly to confirm deletion`,
};
}
+ // THE RENAME'S GUARD, AND DELETE NEEDED IT MORE. Renaming while a job runs
+ // orphans a registry entry keyed by the old slug; DELETING while one runs
+ // pulls the directory out from under a writer — a download's `.part`, a
+ // transcribe's sidecar, a digest unit's JSON — and the lane units make no job
+ // record at all, so the registry alone never saw them. `deleteChannel` then
+ // races the writer for the tree and whichever loses reports an ENOENT nobody
+ // asked about.
+ const busy = channelMediaBusyReason(slug, "deleting it");
+ if (busy) return { error: busy };
await deleteChannel(getPaths(), slug);
// Same reason as createChannelAction: the deleted channel keeps a leaf in
// every compiled tree until something recompiles. A leaf matching nothing is
@@ -627,18 +637,15 @@ export async function renameChannelAction(
return { error: `Channel "${newSlug}" already exists` };
}
- const activeJobs = getRegistry()
- .list()
- .filter(
- (j) =>
- j.channelSlug === oldSlug &&
- (j.status === "running" || j.status === "queued"),
- );
- if (activeJobs.length > 0) {
- return {
- error: `Finish or cancel ${activeJobs.length} running/queued job(s) for this channel before renaming.`,
- };
- }
+ // THE REGISTRY IS HALF THE TRUTH, and this check used to be the other half's
+ // ancestor: it counted running/queued JOBS only. The auto-queue lanes run
+ // their per-video units in-process and make no job record (the omnimirror
+ // incident, lib/mediaBusy.ts), so a digest unit writing a sidecar into
+ // `data/` was invisible here — and renaming moves the directory out from
+ // under it. One question, one answer, the same sentence the Storage panel
+ // and the bulk move say.
+ const busy = channelMediaBusyReason(oldSlug, "renaming it");
+ if (busy) return { error: busy };
let result;
try {
diff --git a/editor/app/channels/components/DeleteChannelForm.tsx b/editor/app/channels/components/DeleteChannelForm.tsx
@@ -9,9 +9,12 @@ type Props = {
prev: ActionResult,
formData: FormData,
) => Promise<ActionResult>;
+ // Why the delete is refused right now, or null. Same shape and same reason as
+ // the rename form's: the action is the guard, this is the earlier warning.
+ busyReason?: string | null;
};
-export function DeleteChannelForm({ slug, action }: Props) {
+export function DeleteChannelForm({ slug, action, busyReason = null }: Props) {
const [state, formAction] = useActionState<ActionResult, FormData>(
action,
undefined,
@@ -22,6 +25,15 @@ export function DeleteChannelForm({ slug, action }: Props) {
Deleting will remove <code>transcripts/channels/{slug}/</code> and all
downloaded videos. Type the slug to confirm.
</p>
+ {busyReason && (
+ <p
+ role="status"
+ aria-label="delete blocked"
+ className="text-sm rounded border border-border bg-muted px-3 py-2"
+ >
+ {busyReason}
+ </p>
+ )}
<div className="flex gap-2 items-start">
<input
name="confirmSlug"
@@ -32,7 +44,8 @@ export function DeleteChannelForm({ slug, action }: Props) {
/>
<button
type="submit"
- className="px-3 py-1.5 rounded-md bg-destructive text-destructive-foreground text-sm font-medium hover:bg-destructive/90"
+ disabled={busyReason !== null}
+ className="px-3 py-1.5 rounded-md bg-destructive text-destructive-foreground text-sm font-medium hover:bg-destructive/90 disabled:opacity-50"
>
Delete channel
</button>
diff --git a/editor/app/channels/components/RenameChannelForm.tsx b/editor/app/channels/components/RenameChannelForm.tsx
@@ -6,9 +6,13 @@ import type { ActionResult } from "../actions";
type Props = {
slug: string;
action: (prev: ActionResult, formData: FormData) => Promise<ActionResult>;
+ // Why the rename is refused right now, or null. The ACTION asks the same
+ // question and refuses with the same sentence — this is the courtesy that
+ // says so before the form is filled in, not the guard.
+ busyReason?: string | null;
};
-export function RenameChannelForm({ slug, action }: Props) {
+export function RenameChannelForm({ slug, action, busyReason = null }: Props) {
const [state, formAction] = useActionState<ActionResult, FormData>(
action,
undefined,
@@ -31,6 +35,15 @@ export function RenameChannelForm({ slug, action }: Props) {
className="rounded border border-border bg-card px-2 py-1 text-sm font-mono max-w-xs"
/>
</label>
+ {busyReason && (
+ <p
+ role="status"
+ aria-label="rename blocked"
+ className="text-sm rounded border border-border bg-muted px-3 py-2"
+ >
+ {busyReason}
+ </p>
+ )}
<div className="flex gap-2 items-start">
<input
name="confirmSlug"
@@ -41,7 +54,8 @@ export function RenameChannelForm({ slug, action }: Props) {
/>
<button
type="submit"
- className="px-3 py-1.5 rounded-md bg-destructive text-destructive-foreground text-sm font-medium hover:bg-destructive/90"
+ disabled={busyReason !== null}
+ className="px-3 py-1.5 rounded-md bg-destructive text-destructive-foreground text-sm font-medium hover:bg-destructive/90 disabled:opacity-50"
>
Rename channel
</button>
diff --git a/editor/e2e/channel-rename.spec.ts b/editor/e2e/channel-rename.spec.ts
@@ -1,6 +1,8 @@
-import { test, expect } from "@playwright/test";
+import { test, expect, type Page } from "@playwright/test";
+import { baseUrl } from "./baseUrl";
import {
channelStage,
+ generateReport,
readJson,
resetData,
writeSite,
@@ -55,3 +57,117 @@ test("rename requires the exact current slug and then moves the channel", async
);
expect(site.channels.map((c) => c.slug)).toEqual([NEW]);
});
+
+// RENAMING AND DELETING ARE REFUSED WHILE SOMETHING IS WRITING INTO data/.
+//
+// The rename always asked, but it asked the JOB REGISTRY alone — and the
+// registry is half the truth: the auto-queue lanes run their per-video units
+// in-process and make no job record (lib/mediaBusy.ts, the omnimirror
+// incident). Delete asked nothing at all, which is the worse of the two: a
+// rename that orphans a registry entry is a nuisance, a delete racing a
+// download for the tree loses bytes.
+//
+// Both now ask `channelMediaBusyReason`, the one question the Storage panel and
+// the bulk move ask, and both say the same sentence with their own verb.
+//
+// THE JOB IS THE HALF AN E2E CAN STAGE. A lane unit is in flight for
+// milliseconds at a time and cannot be held there from a browser; the fake
+// yt-dlp's `--test-slow` holds a real job for 30 s, and it is the same registry
+// read either way.
+const SLOW = "slow-channel";
+
+async function running(page: Page, slug: string): Promise<void> {
+ await expect
+ .poll(
+ async () => {
+ const res = await page.request.get(`${baseUrl}/api/jobs/active`);
+ const body = await res.json();
+ const jobs: { channelSlug?: string; status: string }[] = Array.isArray(
+ body,
+ )
+ ? body
+ : (body.jobs ?? []);
+ return jobs.filter(
+ (j) =>
+ j.channelSlug === slug &&
+ (j.status === "running" || j.status === "queued"),
+ ).length;
+ },
+ { timeout: 30_000 },
+ )
+ .toBeGreaterThan(0);
+}
+
+test("rename and delete refuse while a job is writing into the channel", async ({
+ page,
+}) => {
+ test.setTimeout(120_000);
+ await resetData("slow-pipeline-channel");
+ await generateReport(page, SLOW);
+ // Rendered while the channel is QUIET, so both forms come back live. The
+ // point of the next few lines is that the SERVER refuses — a page that
+ // already knows would prove only that the button can be greyed out.
+ await expect
+ .poll(
+ async () => {
+ const res = await page.request.get(`${baseUrl}/api/jobs/active`);
+ const body = await res.json();
+ const jobs: { channelSlug?: string; status: string }[] = Array.isArray(
+ body,
+ )
+ ? body
+ : (body.jobs ?? []);
+ return jobs.filter(
+ (j) =>
+ j.channelSlug === SLOW &&
+ (j.status === "running" || j.status === "queued"),
+ ).length;
+ },
+ { timeout: 30_000 },
+ )
+ .toBe(0);
+ await page.goto(channelStage(SLOW, "danger"));
+ await expect(
+ page.getByRole("button", { name: "Rename channel" }),
+ ).toBeEnabled();
+
+ // Out of band, so this page's forms are the ones rendered before it started.
+ const started = await page.request.post(`${baseUrl}/api/ops/sync`, {
+ headers: { authorization: "Bearer test-worker-token" },
+ data: { slug: SLOW },
+ });
+ expect(started.ok()).toBe(true);
+ await running(page, SLOW);
+
+ // --- the server refuses, in the sentence every other surface says ---------
+ await page.getByLabel("new slug").fill("renamed-slow");
+ await page.getByLabel("confirm current slug").fill(SLOW);
+ await page.getByRole("button", { name: "Rename channel" }).click();
+ // `.first()`: the refusal is rendered by the form's own error paragraph, and
+ // a re-render would put the same sentence in the status above it too.
+ await expect(page.getByText(/before renaming it\./).first()).toBeVisible();
+ await expect(page).toHaveURL(new RegExp(`/channels/${SLOW}(\\?|$)`));
+
+ await page.getByLabel("confirm slug to delete").fill(SLOW);
+ await page.getByRole("button", { name: "Delete channel" }).click();
+ await expect(page.getByText(/before deleting it\./).first()).toBeVisible();
+ // Nothing was deleted: the channel page still exists.
+ expect(
+ (await page.request.get(`${baseUrl}/channels/${SLOW}`)).status(),
+ ).toBe(200);
+
+ // --- and a fresh render says so before either form is filled in -----------
+ await page.goto(channelStage(SLOW, "danger"));
+ await expect(page.getByLabel("rename blocked")).toContainText(
+ /before renaming it\./,
+ );
+ await expect(page.getByLabel("delete blocked")).toContainText(
+ /before deleting it\./,
+ );
+ await expect(
+ page.getByRole("button", { name: "Rename channel" }),
+ ).toBeDisabled();
+ await expect(
+ page.getByRole("button", { name: "Delete channel" }),
+ ).toBeDisabled();
+});