import { getRegistry } from "yt-dlp-transcript-common/jobs/registry"; import { getAutoRunnerStatus } from "yt-dlp-transcript-common/controller/autoRunner"; // IS ANYTHING WRITING INTO THE SAVED-VIDEO STORE RIGHT NOW? // // `channelMediaBusyReason` asks the same question about one channel's `data/`. // This is its twin for the store, and the difference is the one that matters: // THE STORE HAS NO SLUG. A persist belongs to a video, the video belongs to a // channel, and the container lands in the ONE store — so "is this channel // busy" answers nothing here and the question has to be asked of the whole // machine. // // WHY A BUSY CHECK AT ALL WHEN THE MARKER ALREADY REFUSES THE WRITE. // `assertSavedVideosStoreWritable` (lib/savedVideoStore.ts) is the guard: it // runs at the moment of the persist, it covers every caller including ones that // do not exist yet, and it is what makes a raced write impossible rather than // merely unlikely. What it CANNOT do is give the operator the answer before // they commit — a download that starts a persist thirty seconds into a // three-hour copy is refused correctly, and the operator finds out from a job // log. So this is the courtesy half: a sentence, before the move, naming what // is running. // // THE KIND LIST IS DELIBERATELY NOT EXHAUSTIVE, and that is safe precisely // because the marker is the guard. It names the kinds that can reach // `persistSourceVideo` / the store today; a kind that escapes it costs a // refused persist (logged, container left in the data dir, picked up next // time), not a lost container. // // SERVER-SIDE ONLY: it reaches the controller, which imports execa // transitively. `next build` proves nothing client-side imports it. // Everything that runs `downloadOneManaged` (which persists a kept source // container at the end of a download), plus the four kinds whose whole subject // is the store. const STORE_TOUCHING_KINDS = new Set([ // Downloads, every entry point. "auto-download", "auto-download-unit", "download-from-playlist", "download-missing", "download-missing-subs", "import-one", "import-archive-org", "redownload-archive", "redownload-incomplete-bucket", "retry-bucket", // One kind, channel-scoped: a sync downloads. "sync", // The store's own jobs. "persist-kept", "persist-videos", "check-kept-deleted", "backup-saved-videos", "verify-saved-video-backup", ]); // A sentence naming what is holding the store, or null. The caller supplies the // verb, so the same reason reads as an instruction wherever it appears. // // `runningOnly` is the store move's own first step (relocateSavedVideos' // `busy`): by then the move has waited its turn on the relocation queue, and a // download merely QUEUED behind other work is not writing — when it persists // later, the marker refuses the persist and leaves the container in its data // dir. The enqueue-time courtesy counts queued jobs too. export function savedVideosStoreBusyReason( what?: string, opts: { runningOnly?: boolean } = {}, ): string | null { const jobs = getRegistry() .list() .filter( (j) => STORE_TOUCHING_KINDS.has(j.kind) && (j.status === "running" || (!opts.runningOnly && j.status === "queued")), ); // THE DOWNLOAD LANE'S UNITS MAKE NO JOB RECORD — the omnimirror lesson, and // the reason `channelMediaBusyReason` exists in the shape it does. A unit // that is mid-download is a persist that has not happened yet. No slug // filter: every one of them writes into the same store. const units = getAutoRunnerStatus("download").inFlight.length; if (jobs.length === 0 && units === 0) return null; const parts: string[] = []; if (jobs.length > 0) { const kinds = [...new Set(jobs.map((j) => j.kind))].slice(0, 3).join(", "); parts.push( `${jobs.length} ${opts.runningOnly ? "running" : "running/queued"} ` + `download job(s) (${kinds})`, ); } if (units > 0) parts.push(`${units} auto-download unit(s) in flight`); const subject = `${parts.join(" and ")} — any of them can persist a source video into the store`; return what ? `Finish or cancel ${subject}, before ${what}.` : subject; }