commit 2e81d8b218c4c7f0403a39e1b3e516ba93271279
parent c661c087f9a5b5ee832c873653fad74952ea9fc2
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 11 Sep 2026 11:27:49 -0400
editor: a relocation is a job on the channel's own queue
The job kind is one entry, and `needsMedia: false` is written out rather than
omitted because this is the entry where the explicit false is load-bearing: the
relocate job is what FIXES an unreachable channel, so guard 1 refusing it would
block the only action that can clear the condition it refuses for. Not drainable
(the work is one rsync child; stopping it is a cancel, which the signal already
does and which leaves the source untouched) and not replayable (a replay carries
neither direction nor root).
storageActions.ts is the editor's half. The preview runs INLINE — it is
read-only and the operator is holding a form open for its two numbers, so a
queued job with a log would be a worse way to show them. Both directions of the
move go through runManagedFunction on channelQueueKey(slug), so a relocation
serializes with the channel's own bookkeeping jobs instead of running while one
of them writes into the dir being copied.
The active-jobs guard is the rename's, copied rather than shared, and it has a
sharper edge here: a download running against data/ WHILE its bytes are copied
out either fails the verify (safe) or loses the write (not). The queue alone
would make a second move WAIT, and waiting is the wrong answer for a move the
operator can already see in flight.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
2 files changed, 167 insertions(+), 0 deletions(-)
diff --git a/common/jobs/jobKinds.ts b/common/jobs/jobKinds.ts
@@ -343,6 +343,34 @@ const JOB_KINDS: Record<string, JobKindMeta> = {
replayable: false,
queueKeyStrategy: "custom",
},
+ // MOVE A CHANNEL'S MEDIA TO ANOTHER DRIVE, AND BACK
+ // (plans/relocate-channel-media.md).
+ //
+ // `needsMedia: false` is written out rather than omitted, and this is the one
+ // entry where the explicit `false` earns its line: this kind is what FIXES an
+ // unreachable channel. Guard 1 in runManagedFunction refuses a media kind for
+ // a channel whose media it cannot reach — so a kind that declared `true` here
+ // would be refused precisely when the operator needs it (a `back` run after
+ // remounting, or a retry of an interrupted move), and the only action that can
+ // clear the condition would be the one the condition blocks.
+ //
+ // Not drainable: the work is one rsync child, and stopping it is a cancel —
+ // which the AbortSignal already does, leaving the source untouched and the
+ // partial copy resumable. There is no "stop starting new sub-operations" to
+ // honor. Not replayable: a replay carries no direction and no root, and
+ // re-running a move against a channel that has since moved is not a retry.
+ //
+ // Queue key is channelQueueKey(slug) (set by the action), so a relocation
+ // serializes with the channel's own bookkeeping jobs instead of running while
+ // one of them writes into the dir being copied.
+ "relocate-channel-media": {
+ kind: "relocate-channel-media",
+ label: "Relocate channel media",
+ drainable: false,
+ replayable: false,
+ queueKeyStrategy: "custom",
+ needsMedia: false,
+ },
// Social-post ingest for a `sourceKind: "social"` channel. Drainable (the
// fetcher stops paging on the drain signal and keeps what it already has) and
// replayable. queueKeyForUrl() routes x.com / bsky.app to
diff --git a/editor/app/channels/[slug]/storageActions.ts b/editor/app/channels/[slug]/storageActions.ts
@@ -0,0 +1,139 @@
+"use server";
+
+// WHERE A CHANNEL'S MEDIA LIVES — the three actions the Storage panel drives.
+//
+// All of the mechanism is in common/controller/relocateChannelMedia.ts. What is
+// here is the editor's half: the preview (a plain async call, no job — it is
+// read-only and the operator is waiting on its numbers before committing), and
+// the two directions of the move, each wrapped in runManagedFunction so it gets
+// 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:
+// 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
+// or a transcribe running against `data/` WHILE its bytes are being copied out
+// would write into the directory the swap is about to replace, and the verify
+// would then fail (which is the safe outcome) or the write would be lost (which
+// is not). The check is cheap and refuses early, before any bytes move.
+//
+// The relocate job's own queue key is channelQueueKey(slug), so a second one is
+// also serialized by the queue — but the queue would make it WAIT, and waiting
+// is the wrong answer for a move the operator can see is already in flight.
+
+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 { requestChannelSnapshot } from "yt-dlp-transcript-common/jobs/snapshotScheduler";
+import { formatBytes } from "yt-dlp-transcript-common/lib/format";
+import {
+ previewRelocation,
+ relocateChannelMedia,
+ type RelocationPreview,
+} from "yt-dlp-transcript-common/controller/relocateChannelMedia";
+
+export type PreviewRelocationResult =
+ | { ok: true; preview: RelocationPreview }
+ | { ok: false; error: string };
+
+// Read-only: one tree walk of the channel's data dir plus two statfs calls. It
+// runs INLINE rather than as a job because its whole purpose is to answer a
+// question the operator is holding a form open for; a queued job with a log
+// would be a worse way to show two numbers.
+export async function previewRelocationAction(
+ slug: string,
+ root: string,
+): Promise<PreviewRelocationResult> {
+ const trimmed = root.trim();
+ if (!trimmed) return { ok: false, error: "Enter a destination root." };
+ try {
+ const preview = await previewRelocation({
+ paths: getPaths(),
+ slug,
+ root: trimmed,
+ });
+ return { ok: true, preview };
+ } catch (e) {
+ return { ok: false, error: (e as Error).message };
+ }
+}
+
+// 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} its media.`
+ );
+}
+
+export async function relocateChannelMediaAction(
+ slug: string,
+ root: string,
+): Promise<StreamActionResult> {
+ const trimmed = root.trim();
+ if (!trimmed) return { ok: false, error: "Enter a destination root." };
+ const refusal = activeJobsRefusal(slug, "moving");
+ if (refusal) return { ok: false, error: refusal };
+ return runMove(slug, "out", trimmed);
+}
+
+export async function moveChannelMediaBackAction(
+ slug: string,
+): Promise<StreamActionResult> {
+ const refusal = activeJobsRefusal(slug, "moving 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)" : ""),
+ );
+ // The snapshot records what is in `data/`, and a move does not change
+ // that — but the badge, the free-space line and the media location on
+ // every page that draws them are read per render, so the pages that show
+ // them have to be told. The snapshot request is for the report's own
+ // freshness clock, not for a count this changed.
+ requestChannelSnapshot(paths, slug);
+ revalidatePath(`/channels/${slug}`);
+ revalidatePath("/channels");
+ revalidatePath("/");
+ },
+ });
+}