"use server"; // MOVE SEVERAL CHANNELS' MEDIA TO THE COLD ROOT, FROM /channels. // // ONE JOB PER CHANNEL, ALL ON ONE QUEUE — `relocationQueueKey()`, the shared key // the per-channel Storage panel uses too. There is no batch controller and there // is deliberately not going to be one: the job queue is the batch, and a // cross-channel controller would have to reinvent cancellation, resume and the // per-channel log that already exist. // // THE SHARED KEY IS LOAD-BEARING, not tidiness. registry.ts caps a queue key at // concurrency 1 and caps NOTHING across keys, so a per-channel key would start // every selected channel's rsync at once, onto one destination volume. See // relocationQueueKey() for why that breaks the space check as well as the disk. // // EVERY CHECK RUNS TWICE, AND THAT IS THE DESIGN. What this action refuses at // ENQUEUE time is what it can see now — a social channel, a channel with no // media to move, one already relocated, one mid-transition, one with live jobs. // What it cannot see is the state of the destination twenty minutes from now, so // each job runs its own `previewRelocation`-equivalent preflight (space, // writability, containment, movable state) when it STARTS. Because the jobs run // one at a time, that check measures a root no other move is writing to — so a // root that fills up partway through a selection refuses the remainder cleanly, // one job at a time, instead of the whole bulk failing at the point the first // one is enqueued. // // A skip is never a failure: the result names every slug that did not queue and // why, and the bar renders both numbers. 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 { 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"; import { resolveMoveDestination, type MoveDestination, } from "./lib/moveDestination"; // The same shape syncAllChannelsAction and the per-group stage buttons return, // so the bar renders it the same way they do. export type BulkRelocateResult = QueueOutcome; // Follows a link, deliberately. This path only reaches it for a channel // inspect() already called in-place, whose `data/` is a real directory on the // corpus disk or nothing. async function isDirectory(p: string): Promise { try { return (await stat(p)).isDirectory(); } catch { return false; } } // THE DESTINATION IS A LOCATION ID, not a root — see lib/moveDestination.ts. // The deck picks a name from the list /storage maintains and sends the id; the // root is resolved HERE, from the settings, so a stale page cannot aim a batch // at a root a re-point has moved. `__custom` and a typed root are still // accepted, for the one-off. export async function bulkRelocateChannelMediaAction( slugs: string[], dest: MoveDestination, ): Promise { const paths = getPaths(); const resolved = resolveMoveDestination( dest, getSettings().storage.locations, ); const refuseAll = (reason: string): BulkRelocateResult => ({ queued: [], jobIds: [], skipped: slugs.map((slug) => ({ slug, reason })), }); if ("error" in resolved) return refuseAll(resolved.error); const chosen = resolved.root.trim(); if (!chosen) { return refuseAll( "no destination root — add a media location on /storage, or type one here", ); } if (!path.isAbsolute(chosen)) { return refuseAll( `the destination root must be an absolute path (got "${chosen}")`, ); } return queueForSlugs(slugs, { skip: async (slug) => { const config = await readChannelConfig(paths, slug); if (!config) return "channel not found"; if (isSocialChannel(config)) { return "social channel — it has no downloaded media"; } // 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, 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 // nothing has no `data/` at all, and inspect() calls that `in-place` — // correctly, since it is not relocated. Without this the bulk path queues // a job whose only act is to throw the same sentence from // relocateChannelMedia.ts's copy phase, after a job record, a log and a // queue slot. Selecting a whole page of channels is the ordinary way this // is used, and on a fresh corpus most of them are this case. if (!media.relocated && !(await isDirectory(media.dataDir))) { return "nothing to move — no media has been downloaded for it yet"; } if (media.marker) { return `a relocation (${media.marker.direction}) to ${media.marker.target} is already in flight`; } // THE RETIRED LAYOUT (release 17) is migrated, never moved: the skip // names the command. if (media.status === "legacy") { return `cannot be moved — ${media.detail ?? "its media layout is the retired whole-directory one"}`; } // ALREADY RELOCATED IS A SKIP, NOT A FAILURE — including to a DIFFERENT // root. Selecting the whole page and pressing Move is the ordinary way // this gets used, and the channels already on the platter are exactly the // ones that should quietly drop out of the batch. if (media.relocated) { return `already relocated to ${media.target ?? "another root"}`; } // Containment is per-channel because the target is: `//media` // can resolve into one channel's directory and not another's. return relocationRootProblem({ paths, slug, root: chosen }); }, run: (slug) => enqueueRelocation({ slug, direction: "out", root: chosen }), }); }