import { safeRevalidate } from "../../lib/safeRevalidate"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; import { relocationQueueKey } from "yt-dlp-transcript-common/lib/queueKeys"; import { runManagedFunction, type StreamActionResult, } from "yt-dlp-transcript-common/jobs/streamCommand"; import { formatBytes } from "yt-dlp-transcript-common/lib/format"; import { relocateChannelMedia } from "yt-dlp-transcript-common/controller/relocateChannelMedia"; import type { RelocationDirection } from "yt-dlp-transcript-common/lib/channelMedia"; // ONE ENQUEUE OF THE RELOCATION JOB, for the two callers that have one: the // per-channel Storage panel ([slug]/storageActions.ts) and the /channels bulk // move (bulkStorageActions.ts). // // Deliberately NOT in either of those files: both carry "use server", which // means every non-type export in them is a server action — a shared helper // cannot live there, and exporting this one from a "use server" file would put // an unguarded enqueue on the wire under its own endpoint. Same reason // lib/queueForSlugs.ts exists. // // THE GUARDS ARE THE CALLERS'. This function refuses nothing: each caller runs // its own active-jobs check and its own root check before reaching here, and the // controller re-checks everything at run time regardless. What is shared is only // the job record's shape — kind, queue key, channel slug, the summary line and // the three revalidations — because those are what must not drift between a // single move and a bulk one. // // THE QUEUE KEY IS THE SHARED ONE AND TAKES NO SLUG, which is the whole reason // this helper exists rather than each caller building its own record. See // relocationQueueKey() in common/lib/queueKeys.ts: a per-channel key would // serialize a channel only against itself, so a bulk move of ten channels would // start ten rsyncs onto one destination volume whose run-time space checks would // then each be credited room the others had already claimed. export async function enqueueRelocation(opts: { slug: string; direction: RelocationDirection; root?: string; // Reconcile and resume (release 16 slice RM): finish an interrupted move by // mirroring the destination copy to the source, verifying, and completing. reconcile?: boolean; }): Promise { const { slug, direction, root, reconcile } = opts; const paths = getPaths(); return runManagedFunction({ kind: "relocate-channel-media", queueKey: relocationQueueKey(), paths, channelSlug: slug, // THE COPY IS ONE TASK, and it is the only one this job has. rsync already // prints its progress (`--info=progress2`); until now that went nowhere but // the log, as thousands of carriage-return redraws of one line, so a move // of 131 GB looked from /jobs exactly like a move of 3 MB — a spinner. The // controller parses those frames (see makeProgressSink) and hands them back // as `RelocationProgress`; this turns them into the task bar every other // long job on that page already draws. // // ADDED ON THE FIRST FRAME, NOT AT START. A move spends its first seconds in // preflight (space, writability, movable state) and, on a resume, in a // verify pass that transfers nothing — a task bar sitting at 0 % through // that would be claiming a copy had begun. fn: async (onLog, signal, _setProgress, ctx) => { const taskId = `relocate:${slug}`; let started = false; const result = await relocateChannelMedia({ paths, slug, direction, root, reconcile, onLog, onProgress: (p) => { if (!started) { started = true; ctx?.addTask({ id: taskId, label: direction === "out" ? `${slug} → ${root ?? "destination"}` : `${slug} → in place`, kind: "relocate", startedAt: Date.now(), }); } ctx?.updateTask(taskId, { fraction: p.fraction, detail: p.detail, }); }, signal, }).finally(() => { // In a `finally` so a failed or cancelled move does not leave a task on // the record for the life of the process — the same contract // taskHooks.ts's `end()` has. if (started) ctx?.removeTask(taskId); }); onLog( `${direction === "out" ? "Moved" : "Moved back"} ${result.files} file(s) / ` + `${formatBytes(result.bytes)} — ${result.target}` + // "Reconciled" only when the copy phase ran as a reconcile: a // marker already past the copy phase had nothing to reconcile. (result.resumed ? result.reconciled ? " (reconciled and resumed an interrupted move)" : " (resumed an interrupted move)" : "") + // Said out loud because the operator has watched this refuse a move: // a directory timestamp left by a sidecar written mid-copy used to // fail the verify with 131 GB correctly on the far side. (result.retried ? " (a change made during the copy was settled by a second mirror pass)" : "") + // A classic channel's first move tiers it in place first (release // 17): its big files renamed into `media/` and linked from `data/`. (result.tiered > 0 ? ` (${result.tiered} file(s) tiered first)` : ""), ); // No snapshot regen — deliberately, and `relocate-channel-media` is in // NO_REGEN_KINDS so the central hook does not arm one either. The move // changes where the bytes are, not what they are: every count in the // report is identical afterwards, and a regen would be a full walk of the // channel to rewrite the same numbers under a newer timestamp. // // What DOES have to change is what the pages read per render — the badge, // the location line, the free-space figure — so those are revalidated. safeRevalidate([`/channels/${slug}`, "/channels", "/"]); }, }); }