"use server"; // WHERE A CHANNEL'S MEDIA LIVES — the 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 BUSY GUARD IS THE RENAME'S, widened: channelMediaBusyReason() asks the job // registry AND the auto-queue lanes, because a lane's units make no job record — // see lib/mediaBusy.ts. The rename's version is the ancestor: // 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 `media/` 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 path from "node:path"; import { revalidatePath } from "next/cache"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; import { getSettings } from "yt-dlp-transcript-common/lib/settings"; import type { StreamActionResult } from "yt-dlp-transcript-common/jobs/streamCommand"; import { clearRelocationMarker, readRelocationMarker, } from "yt-dlp-transcript-common/lib/channelMedia"; import { relocatedMediaDir } from "yt-dlp-transcript-common/lib/mediaTier-server"; import { previewRelocation, relocationRootProblem, tierMigrationRefusal, type RelocationPreview, } from "yt-dlp-transcript-common/controller/relocateChannelMedia"; import { enqueueRelocation } from "../lib/relocationJob"; import { resolveMoveDestination, type MoveDestination, } from "../lib/moveDestination"; import { channelMediaBusyReason } from "../lib/mediaBusy"; export type PreviewRelocationResult = { ok: true; preview: RelocationPreview } | { ok: false; error: string }; // One tree walk of the channel's `media/` plus two statfs calls — and, the // first time for a classic channel, the tiering the job's preflight would do // (same-disk renames, idempotent: release 17's ruling). 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, dest: MoveDestination, ): Promise { const resolved = resolveMoveDestination( dest, getSettings().storage.locations, ); if ("error" in resolved) return { ok: false, error: resolved.error }; try { const preview = await previewRelocation({ paths: getPaths(), slug, root: resolved.root, }); return { ok: true, preview }; } catch (e) { return { ok: false, error: (e as Error).message }; } } export async function relocateChannelMediaAction( slug: string, dest: MoveDestination, ): Promise { const resolved = resolveMoveDestination( dest, getSettings().storage.locations, ); if ("error" in resolved) return { ok: false, error: resolved.error }; const root = resolved.root; // Relative, or inside the corpus. The job refuses both too — it is the guard — // but a root that would copy the channel onto itself should not become a job // record and a log the operator has to open to read the reason. const rootProblem = await relocationRootProblem({ paths: getPaths(), slug, root, }); if (rootProblem) return { ok: false, error: rootProblem }; const refusal = channelMediaBusyReason(slug, "moving its media", { mediaOnly: true, }); if (refusal) return { ok: false, error: refusal }; return enqueueRelocation({ slug, direction: "out", root }); } // FINISH THE MOVE THAT WAS INTERRUPTED — the other half of "Clear marker", and // the one the operator wanted first. // // `relocateChannelMedia` has ALWAYS resumed a same-direction marker // (relocateChannelMedia.ts: `resumed` relaxes the "must be in place" // precondition, and the job logs "(resumed an interrupted move)"). Nothing in // the editor could reach it: the panel's Move button is hidden once the config // records a target and disabled while a marker stands, so a killed copy left // exactly one affordance — throw the marker away and re-copy from scratch, // which for the incident that motivated this was 131 GB already correctly on // the far side. // // So this enqueues the SAME relocation job with the SAME direction, and the // controller does the resuming. What it adds is the root, which the marker does // not carry: a marker records `//media`, so the root is its // grandparent. That inversion is checked rather than assumed — if rebuilding // the target from the derived root does not give back the marker's own target // (a hand-edited marker, a slug with a separator in it, a future layout), this // refuses instead of resuming a move towards a directory nobody named. The move // BACK needs no root at all: its target is the config's, and the controller // reads it. export async function resumeRelocationAction( slug: string, ): Promise { return resumeRelocation(slug, false); } // RECONCILE AND RESUME — the remediation for a move whose verification failed // (release 16 slice RM). The same job as Resume move, asked to make the // destination copy MATCH the source before it verifies: one mirror pass // (`rsync -a --delete` toward the destination copy, never the source), the // verify, the swap, the reclaim. What it found is logged by kind first — extra // on the destination, missing there, changed — so the operator reads what was // settled; a difference that will not settle (something still writing) is // refused again and named. The operator never deletes a file by hand. export async function reconcileRelocationAction( slug: string, ): Promise { return resumeRelocation(slug, true); } // Not exported: in a "use server" file every export is an endpoint. async function resumeRelocation( slug: string, reconcile: boolean, ): Promise { const paths = getPaths(); const marker = await readRelocationMarker(paths, slug); if (!marker) { return { ok: false, error: `Channel "${slug}" has no relocation marker — there is no ` + `interrupted move to ${reconcile ? "reconcile" : "resume"}.`, }; } // The same guard the two moves use, and for the same reason: a marker with a // LIVE run behind it is not interrupted, it is in progress, and a second job // would copy into the directory the first one is writing. 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: // the migration runs with the editor stopped and resumes itself. const migrating = tierMigrationRefusal(slug, marker); if (migrating) return { ok: false, error: migrating }; if (marker.direction === "back") { return enqueueRelocation({ slug, direction: "back", reconcile }); } const root = path.dirname(path.dirname(marker.target)); if (marker.target !== relocatedMediaDir(root, slug)) { return { ok: false, error: `The relocation marker points at ${marker.target}, which is not ` + `/${slug}/media — the destination root cannot be recovered from ` + `it. Clear the marker and start the move again.`, }; } return enqueueRelocation({ slug, direction: "out", root, reconcile }); } export async function moveChannelMediaBackAction( slug: string, ): Promise { const refusal = channelMediaBusyReason(slug, "moving its media back", { mediaOnly: true, }); if (refusal) return { ok: false, error: refusal }; return enqueueRelocation({ slug, direction: "back" }); } // THE LAST RESORT, and the only one of the three that is not a move. // // A channel carrying a relocation marker is "in-transition" to every guard: the // lane runners skip it, runManagedFunction refuses its media jobs, and its // snapshot will not regenerate. That is correct while a move is running and a // dead end once one is not — a killed process or a replaced container leaves a // marker nothing will ever clear, and the relocate job itself is refused from // the panel while the marker stands. // // So this removes the marker and NOTHING else: no link, no config, no bytes. // Whatever inspect() reports afterwards is the truth the disk was already // telling underneath it — which may well be `inconsistent`, and that is the // honest answer rather than a repair nobody asked for. It is refused while the // channel has a live job, because then the marker is not stale: it belongs to // the run that is holding it. export async function clearRelocationMarkerAction( slug: string, ): Promise<{ ok: true } | { ok: false; error: string }> { const refusal = channelMediaBusyReason( slug, "clearing its relocation marker", { mediaOnly: true }, ); if (refusal) return { ok: false, error: refusal }; // AN INTERRUPTED TIER MIGRATION'S MARKER is never cleared from here: it is // the phase the migration resumes from (the same sentence Resume gives). const migrating = tierMigrationRefusal( slug, await readRelocationMarker(getPaths(), slug), ); if (migrating) return { ok: false, error: migrating }; try { await clearRelocationMarker(getPaths(), slug); } catch (e) { return { ok: false, error: (e as Error).message }; } revalidatePath(`/channels/${slug}`); revalidatePath("/channels"); revalidatePath("/"); return { ok: true }; }