commit b7688f22158827ee9ebc2d1fe9af48eedd10385a
parent 32b81b9082dd5e1cee5695b54cd547679582f9d8
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 2 Oct 2026 00:51:40 -0400
common, editor: a move is held by the channel's media writers only
channelMediaWriters (relocateChannelMedia.ts) keeps the jobs whose kind opens
or writes a big file, a running move of the channel, and every lane but the
digest one; the move's first step, its preview and the Storage panel's
actions (move, back, resume, reconcile, clear marker, the bulk move) ask it,
so a digest may run during a move. A rename and a delete still ask every
writer. The /storage and re-point prose names mediaDir.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
10 files changed, 111 insertions(+), 23 deletions(-)
diff --git a/common/controller/relocateChannelMedia.test.ts b/common/controller/relocateChannelMedia.test.ts
@@ -30,8 +30,10 @@ import {
tierLinkTarget,
} from "../lib/mediaTier-server";
import type { ChannelWriter } from "./channelWriters";
+import type { JobRecord } from "../jobs/registry";
import {
assertRelocationRootPresent,
+ channelMediaWriters,
previewRelocation,
relocateChannelMedia,
relocationRootPresenceProblem,
@@ -1464,6 +1466,54 @@ test("a writer seen once the marker is written refuses, and a fresh move's marke
});
});
+// A MOVE HOLDS ONLY THE MEDIA WRITERS (release 17 ruling): a digest — a job or
+// the digest lane's unit — reads and writes the text, which never moves, so it
+// is not a writer for this question. A running move of the channel IS one,
+// though its kind is not `needsMedia`: the preview and the panel must not
+// offer a second move over it.
+test("channelMediaWriters: media jobs, a move and the media lanes — never a digest", () => {
+ const job = (id: string, kind: string): JobRecord =>
+ ({
+ id,
+ kind,
+ queueKey: "q",
+ status: "running",
+ queuedAt: 1,
+ logPath: "/dev/null",
+ channelSlug: "alpha",
+ }) as JobRecord;
+ const jobs = [
+ job("J1", "digest-channel-local"),
+ job("J2", "whisper-all"),
+ job("J3", "relocate-channel-media"),
+ ];
+ const unit = (lane: "digest" | "transcription", videoId: string) => ({
+ lane,
+ unit: { videoId, leafId: "leaf", channelSlug: "alpha", startedAt: 1 },
+ });
+ const source = {
+ jobs: () => jobs,
+ units: () => [unit("digest", "d1"), unit("transcription", "t1")],
+ };
+ const names = (ws: ChannelWriter[]) =>
+ ws.map((w) => (w.source === "job" ? w.jobId : `${w.lane}:${w.videoId}`));
+ assert.deepEqual(names(channelMediaWriters("alpha", { source })), [
+ "J2",
+ "J3",
+ "transcription:t1",
+ ]);
+ // The move's own first step leaves itself out.
+ assert.deepEqual(
+ names(
+ channelMediaWriters("alpha", {
+ source,
+ ignoreKinds: ["relocate-channel-media"],
+ }),
+ ),
+ ["J2", "transcription:t1"],
+ );
+});
+
// THE 2026-10-01 CASE. A move killed mid-copy left a transcriber's scratch dir
// on the destination that has since gone from the source. The resume used to
// copy everything else and refuse on the counts (1755 against 1750), and no
diff --git a/common/controller/relocateChannelMedia.ts b/common/controller/relocateChannelMedia.ts
@@ -30,7 +30,9 @@ import {
channelWriters,
channelWritersRefusal,
type ChannelWriter,
+ type ChannelWritersOptions,
} from "./channelWriters";
+import { kindNeedsMedia } from "../jobs/jobKinds";
import { isSocialChannel } from "../lib/channelConfig";
import {
locationOfDataDir,
@@ -181,13 +183,29 @@ const RELOCATE_KIND = "relocate-channel-media";
// MEDIA WRITERS ONLY (release 17 ruling): a move carries `media/` and nothing
// else, so a digest, a normalize or any other reader/writer of the TEXT may run
-// while it does — `mediaOnly` keeps the jobs whose kind opens or writes a big
-// file and the lane units of every lane but the digest one.
+// while it does. Kept: the jobs whose kind opens or writes a big file
+// (`kindNeedsMedia`), the lane units of every lane but the digest one — and a
+// MOVE of this channel's media itself, whose kind is not `needsMedia` (it must
+// not be refused by the media guard it is the reason for) but which is, of
+// everything, the thing writing into `media/`. In the order `channelWriters`
+// gives (running jobs, queued jobs, lane units), so the refusal names the same
+// writer first.
+//
+// The Storage panel's actions, the preview and the job's own first step all
+// ask this; a rename or a delete of the channel asks every writer.
+export function channelMediaWriters(
+ slug: string,
+ opts: Omit<ChannelWritersOptions, "mediaOnly"> = {},
+): ChannelWriter[] {
+ return channelWriters(slug, opts).filter((w) =>
+ w.source === "job"
+ ? kindNeedsMedia(w.kind) || w.kind === RELOCATE_KIND
+ : w.lane !== "digest",
+ );
+}
+
function liveWriters(slug: string): ChannelWriter[] {
- return channelWriters(slug, {
- ignoreKinds: [RELOCATE_KIND],
- mediaOnly: true,
- });
+ return channelMediaWriters(slug, { ignoreKinds: [RELOCATE_KIND] });
}
// THE RETIRED WHOLE-DIRECTORY LAYOUT, refused by every entry point of the mover
@@ -554,7 +572,7 @@ export async function previewRelocation({
paths,
slug,
root,
- writers = (s) => channelWriters(s, { mediaOnly: true }),
+ writers = (s) => channelMediaWriters(s),
}: {
paths: Paths;
slug: string;
diff --git a/common/jobs/jobKinds.ts b/common/jobs/jobKinds.ts
@@ -463,7 +463,7 @@ const JOB_KINDS: Record<string, JobKindMeta> = {
// THE SAME LINKS, WITHOUT THE BYTES. A re-point rewrites every channel
// symlink on one storage location plus the location's root, for the case the
// relocation above cannot help with: the media never moved, the DISK did, and
- // every `config.dataDir` on it now names a mountpoint that is not there.
+ // every `config.mediaDir` on it now names a mountpoint that is not there.
//
// `needsMedia: false` for the relocation's reason, and more sharply: every
// channel this job touches is BY DEFINITION unreachable when it starts —
diff --git a/editor/app/channels/[slug]/page.tsx b/editor/app/channels/[slug]/page.tsx
@@ -635,7 +635,11 @@ export default async function ChannelDetailPage({
// and stated in the action too, because a disabled button is a courtesy
// 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");
+ // The move holds the media writers only (release 17): a digest may
+ // run during it.
+ const busy = channelMediaBusyReason(slug, "moving its media", {
+ mediaOnly: true,
+ });
const blockedReason =
busy ??
(marker
diff --git a/editor/app/channels/[slug]/storageActions.ts b/editor/app/channels/[slug]/storageActions.ts
@@ -95,7 +95,9 @@ export async function relocateChannelMediaAction(
root,
});
if (rootProblem) return { ok: false, error: rootProblem };
- const refusal = channelMediaBusyReason(slug, "moving its media");
+ const refusal = channelMediaBusyReason(slug, "moving its media", {
+ mediaOnly: true,
+ });
if (refusal) return { ok: false, error: refusal };
return enqueueRelocation({ slug, direction: "out", root });
}
@@ -162,6 +164,7 @@ async function resumeRelocation(
const refusal = channelMediaBusyReason(
slug,
reconcile ? "reconciling its move" : "resuming its move",
+ { mediaOnly: true },
);
if (refusal) return { ok: false, error: refusal };
// A TIER MIGRATION'S MARKER (release 17) is not this panel's to resume:
@@ -194,7 +197,9 @@ async function resumeRelocation(
export async function moveChannelMediaBackAction(
slug: string,
): Promise<StreamActionResult> {
- const refusal = channelMediaBusyReason(slug, "moving its media back");
+ const refusal = channelMediaBusyReason(slug, "moving its media back", {
+ mediaOnly: true,
+ });
if (refusal) return { ok: false, error: refusal };
return enqueueRelocation({ slug, direction: "back" });
}
@@ -220,6 +225,7 @@ export async function clearRelocationMarkerAction(
const refusal = channelMediaBusyReason(
slug,
"clearing its relocation marker",
+ { mediaOnly: true },
);
if (refusal) return { ok: false, error: refusal };
try {
diff --git a/editor/app/channels/bulkStorageActions.ts b/editor/app/channels/bulkStorageActions.ts
@@ -99,7 +99,7 @@ export async function bulkRelocateChannelMediaAction(
}
// 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);
+ const busy = channelMediaBusyReason(slug, undefined, { mediaOnly: true });
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
diff --git a/editor/app/channels/components/ChannelVolumeBar.tsx b/editor/app/channels/components/ChannelVolumeBar.tsx
@@ -28,7 +28,7 @@ import { LOCATION_FILTER_PARAM } from "yt-dlp-transcript-common/views/storage";
// thing here that stays local.
export type ChannelVolume = {
- // A location id, "internal" for the corpus volume, or "" for a dataDir under
+ // A location id, "internal" for the corpus volume, or "" for a mediaDir under
// a root nobody named.
id: string;
label: string;
diff --git a/editor/app/channels/lib/mediaBusy.ts b/editor/app/channels/lib/mediaBusy.ts
@@ -27,6 +27,7 @@ import {
channelWriters,
describeChannelWriter,
} from "yt-dlp-transcript-common/controller/channelWriters";
+import { channelMediaWriters } from "yt-dlp-transcript-common/controller/relocateChannelMedia";
// 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
@@ -39,11 +40,19 @@ import {
// and its job's first step ask — with queued jobs counted here, because this is
// the courtesy before anything is enqueued. A lane's download unit is also a
// registry job; it is counted once, as the unit.
+//
+// `mediaOnly` (release 17): a MOVE of the media tier is held only by the
+// channel's media writers — a digest may run during it (`channelMediaWriters`,
+// the same list the move's preview and its first step ask). A rename or a
+// delete moves or removes the whole channel and asks every writer.
export function channelMediaBusyReason(
slug: string,
what?: string,
+ opts: { mediaOnly?: boolean } = {},
): string | null {
- const writers = channelWriters(slug, { includeQueued: true });
+ const writers = opts.mediaOnly
+ ? channelMediaWriters(slug, { includeQueued: true })
+ : channelWriters(slug, { includeQueued: true });
if (writers.length === 0) return null;
const jobs = writers.filter((w) => w.source === "job").length;
const units = writers.length - jobs;
diff --git a/editor/app/storage/components/StorageLocationsTable.tsx b/editor/app/storage/components/StorageLocationsTable.tsx
@@ -386,8 +386,8 @@ function LocationCard({
<h3 className="text-sm font-semibold">Re-point</h3>
<p className="text-xs text-muted-foreground">
The volume is mounted at a different place than this location’s
- root. Re-pointing rewrites each channel’s <code>data/</code>{" "}
- symlink and its <code>dataDir</code>, then the root.{" "}
+ root. Re-pointing rewrites each channel’s <code>media</code>{" "}
+ symlink and its <code>mediaDir</code>, then the root.{" "}
<strong>No bytes move</strong> — the media is already there.
</p>
<StreamActionLog
diff --git a/editor/app/storage/page.tsx b/editor/app/storage/page.tsx
@@ -23,13 +23,14 @@ export default async function StoragePage() {
</div>
<p className="text-sm text-muted-foreground max-w-3xl">
- A storage location is a named place a channel’s media may live —
- usually a second drive. A channel is on a location when its{" "}
- <code>dataDir</code> is under that location’s root; nothing is
- tagged, so moving a channel on or off one is a move, not a setting. When
- a drive comes back at a different mountpoint, <strong>re-point</strong>{" "}
- the location: it rewrites every channel’s symlink and{" "}
- <code>dataDir</code> and moves no bytes. Move media onto a location from
+ A storage location is a named place a channel’s media — its big
+ files, the audio and the raw live chat — may live, usually a second
+ drive; a channel’s text never leaves the corpus volume. A channel
+ is on a location when its <code>mediaDir</code> is under that
+ location’s root; nothing is tagged, so moving a channel on or off
+ one is a move, not a setting. When a drive comes back at a different
+ mountpoint, <strong>re-point</strong> the location: it rewrites every
+ channel’s symlink and <code>mediaDir</code> and moves no bytes. Move media onto a location from
a channel’s <Link href="/channels" className="underline">Storage
panel</Link>.
</p>