Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit ca9616c8344331150b995ef4fb25aba58766f55b
parent f4cc389be809bfdd8b3354277371ffea76a34415
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Sun, 20 Sep 2026 20:09:39 -0400

storage review: the fixes an auto-pause and a store move cannot ship without

BLOCKER — an auto-pause/restore cycle destroyed per-operation overrides.
`sanitizeOverrides` normalises away any override equal to the BASE tier, which
is what keeps the document a list of exceptions; measured against the FORCED
`paused` every `{op:"paused"}` fence the operator set stops being an exception
and is deleted, so the restore hands back a channel with no fence. Reproduced:
`{tier:"normal", overrides:{sync:"paused"}}` came back bare. On this corpus that
is legal-mindset losing both fences and cornbreadman losing its sync fence to
one hiccup of a USB cable. The record is now computed first and the overrides
normalised against `previousTier` — the base the operator actually set.

BLOCKER — nothing stopped a download writing into a store being moved. The
relocation queue key serialises relocations and says nothing about a persist,
and `moveFileCrossDevice`'s unconditional `mkdir -p` makes that a container
lost three ways: a verify refusing at the end of a multi-hour copy, a container
landing in the parked dir that `reclaimParked` then rm -rf's with
`saved-video.json` still pointing at it, and `saved-videos` recreated as a real
dir between the rename and the symlink. So: `lib/savedVideoStore.ts` — in lib
because `savedVideo-server.ts` is lib and may not import controller — holds the
marker's name and reader and `assertSavedVideosStoreWritable`, which
`persistSourceVideo` and `unpersistSavedVideo` call. That is the guard, and it
covers callers that do not exist yet. `savedVideosStoreBusyReason` is the
courtesy half: a sentence before the operator commits to a copy that will be
raced. And the swap now links defensively (an empty recreated dir is removed; a
non-empty one refuses) and records `savedVideosLocationId` only once the link
has been read back.

Also: two consecutive down passes before the watch pauses anything (one blanket-
caught stat is not evidence — EIO, a disk spinning up); restore stays
single-pass, and the asymmetry is argued in the header. The watch calls
`maybeAutoRepoint` for a `mounted-elsewhere` location that armed it, so a drive
that moves while the editor is up no longer waits for a reboot. A resumed copy's
bar is offset by what is already on the far side, so it no longer tops out at
40 %. `rsyncTree` buffers the trailing segment across chunks, so a torn frame
never parses as `bytes=0`. Move-back deletes the target only when this run can
vouch for the local copy — it did the swap, the marker says a previous run got
past it, or the local tree measures at least as large; otherwise it finishes and
says what it did not delete. Deleting a location the store is on is refused, in
the action and on the row. The parked name is timestamped and swept by prefix. A
store that has never existed is created empty instead of failing rsync 23 behind
a stuck marker. And both sides of the containment check are realpath-resolved.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Diffstat:
Mcommon/controller/relocateChannelMedia.ts | 10++++++++++
Acommon/controller/relocateDir.test.ts | 91+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/controller/relocateDir.ts | 54+++++++++++++++++++++++++++++++++++++++++++++++++-----
Mcommon/controller/relocateSavedVideos.test.ts | 182+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/controller/relocateSavedVideos.ts | 200++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Mcommon/controller/storageWatch.test.ts | 156+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
Mcommon/controller/storageWatch.ts | 87++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcommon/jobs/progressParsers.test.ts | 29+++++++++++++++++++++++++++++
Mcommon/lib/channelPriority.test.ts | 70++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/lib/channelPriority.ts | 23+++++++++++++++++++++--
Mcommon/lib/savedVideo-server.ts | 22+++++++++++++++++++++-
Acommon/lib/savedVideoStore.test.ts | 168+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/lib/savedVideoStore.ts | 115+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/views/storage.ts | 15++++++++++++++-
Mcommon/ytdlp/downloadOneManaged.ts | 6++++++
Meditor/app/storage/actions.ts | 29+++++++++++++++++++++++++++++
Aeditor/app/storage/lib/storeBusy.ts | 80+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
17 files changed, 1283 insertions(+), 54 deletions(-)

diff --git a/common/controller/relocateChannelMedia.ts b/common/controller/relocateChannelMedia.ts @@ -480,6 +480,12 @@ async function moveOut(args: { ); } await mkdir(target, { recursive: true }); + // WHAT A PREVIOUS ATTEMPT ALREADY LANDED, so the bar is about the TREE and + // not about this process's share of it. rsync counts only what it sends: a + // copy resumed at 60 % would otherwise climb 0 → 40 % and stop, having + // moved every remaining byte. One walk of the target, which for a fresh + // move is an empty directory and costs a readdir. + const already = (await measureTree(target)).bytes; await writeMarker(paths, slug, { target, direction: "out", @@ -496,6 +502,7 @@ async function moveOut(args: { log, progress: makeProgressSink({ totalBytes: measured.bytes, + alreadyBytes: already, log, onProgress: args.onProgress, }), @@ -716,6 +723,9 @@ async function moveBack(args: { log, progress: makeProgressSink({ totalBytes: measured.bytes, + // The same figure the space check above is priced in — what is already + // in `data.incoming` from an interrupted run. + alreadyBytes: already, log, onProgress: args.onProgress, }), diff --git a/common/controller/relocateDir.test.ts b/common/controller/relocateDir.test.ts @@ -0,0 +1,91 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { makeProgressSink, type RelocationProgress } from "./relocateDir"; + +// Run with: +// pnpm --filter yt-dlp-transcript-common exec tsx --test controller/relocateDir.test.ts +// +// The sink, on its own: no rsync, no disk. What it has to get right is the two +// things a bar claims — that it is about the TREE, and that it only moves one +// way. + +function frame(bytes: number, percent: number): string { + return ` ${bytes.toLocaleString("en-US")} ${percent}% 1.03GB/s 0:00:03`; +} + +function collect(opts: { totalBytes: number; alreadyBytes?: number }) { + const seen: RelocationProgress[] = []; + const lines: string[] = []; + const sink = makeProgressSink({ + ...opts, + log: (m) => lines.push(m), + onProgress: (p) => seen.push(p), + }); + return { sink, seen, lines }; +} + +test("a fresh copy's fraction is transferred over the measured tree", () => { + const { sink, seen } = collect({ totalBytes: 100 }); + sink(frame(25, 25)); + sink(frame(100, 100)); + assert.deepEqual( + seen.map((p) => p.fraction), + [0.25, 1], + ); + assert.equal(seen[0].bytes, 25); +}); + +// RSYNC COUNTS WHAT *IT* SENT, NOT WHAT IS THERE. Resume a copy that died at +// 60 % and the bar climbs 0 → 40 % and stops, having moved every remaining +// byte — which an operator reads as a copy that wedged four fifths of the way +// through something. The caller measures the destination before the copy; this +// is what that measurement is for. +test("a resumed copy is offset by what is already on the far side", () => { + const { sink, seen } = collect({ totalBytes: 100, alreadyBytes: 60 }); + sink(frame(0, 0)); + sink(frame(20, 50)); + sink(frame(40, 100)); + assert.deepEqual( + seen.map((p) => p.fraction), + [0.6, 0.8, 1], + ); + assert.deepEqual( + seen.map((p) => p.bytes), + [60, 80, 100], + ); + // The detail line says the same thing, so the log and the bar cannot + // disagree about the same instant. + assert.match(seen[1].detail, /80 B of 100 B · 80 %/); +}); + +// A resumed copy re-sends the partial file it died inside, so +// `already + transferred` can exceed the tree by one file's size. That is a +// bar reading 112 %, which is a bar nobody believes again. +test("the fraction is clamped when a partial file is re-sent", () => { + const { sink, seen } = collect({ totalBytes: 100, alreadyBytes: 90 }); + sink(frame(30, 100)); + assert.equal(seen[0].fraction, 1); + assert.equal(seen[0].bytes, 100); +}); + +// ONE LINE PER DECILE, and a resumed copy does not re-announce the deciles it +// did not do — which is why the latch lives in the closure and not in a +// parameter. +test("the log gets one line per decile, starting from the resume point", () => { + const { sink, lines } = collect({ totalBytes: 100, alreadyBytes: 60 }); + sink(frame(0, 0)); + sink(frame(1, 2)); + sink(frame(20, 50)); + sink(frame(40, 100)); + assert.equal(lines.length, 3); + assert.match(lines[0], /^Copying… 60 B of 100 B · 60 %/); + assert.match(lines[2], /100 B of 100 B · 100 %/); +}); + +test("a line that is not a frame is ignored entirely", () => { + const { sink, seen, lines } = collect({ totalBytes: 100 }); + sink("sending incremental file list"); + sink(".d..t...... 20240101_test1234567/"); + assert.deepEqual(seen, []); + assert.deepEqual(lines, []); +}); diff --git a/common/controller/relocateDir.ts b/common/controller/relocateDir.ts @@ -192,17 +192,37 @@ export type RelocationProgress = { // re-announce the eight deciles it did not do. export function makeProgressSink(opts: { totalBytes: number; + // BYTES ALREADY ON THE FAR SIDE WHEN THIS RUN STARTED — a resumed copy's + // offset, and 0 for a fresh one. + // + // rsync counts what IT transferred, not what is there: resume a copy that + // died at 60 % and the bar climbs 0 → 40 % and stops, having moved every + // remaining byte. The operator reads that as a copy that wedged four fifths + // of the way through something. Adding the head start makes the fraction a + // statement about the TREE, which is what the bar claims to be about, and it + // is why the caller measures the destination before the copy rather than + // letting rsync's own numbers stand. + alreadyBytes?: number; log: (m: string) => void; onProgress?: (p: RelocationProgress) => void; }): (line: string) => void { let lastDecile = -1; + const already = Math.max(0, opts.alreadyBytes ?? 0); return (line: string) => { const raw = parseRsyncProgress(line); if (!raw) return; - const fraction = rsyncProgressFraction(raw, opts.totalBytes); - const detail = formatRsyncProgressDetail(raw, opts.totalBytes, formatBytes); + // Clamped to the total: a resumed copy can re-send a partial file, so + // `already + transferred` may exceed the tree by the size of one file. + const done = Math.min(opts.totalBytes || Infinity, already + raw.bytes); + const shifted = { ...raw, bytes: done }; + const fraction = rsyncProgressFraction(shifted, opts.totalBytes); + const detail = formatRsyncProgressDetail( + shifted, + opts.totalBytes, + formatBytes, + ); opts.onProgress?.({ - bytes: raw.bytes, + bytes: done, totalBytes: opts.totalBytes, fraction, rate: raw.rate, @@ -247,6 +267,17 @@ export async function rsyncTree(opts: { reject: false, }); let output = ""; + // THE TAIL OF THE LAST CHUNK, HELD BACK UNTIL ITS DELIMITER ARRIVES. + // + // A chunk boundary falls wherever the pipe decides, and it lands INSIDE a + // progress frame often enough to matter on a long copy. Parsing the two + // halves separately is not merely a dropped frame: ` 30,000,` matches the + // number, the percent and the rate of nothing, and the leading ` 30` of + // the next chunk can parse as a frame reporting THIRTY BYTES — so the bar + // jumps back to 0 % and the decile latch has already spent its line. So the + // trailing segment (the one with no delimiter after it) is carried into the + // next chunk, which is what every line-oriented stream reader has to do. + let pending = ""; child.all?.on("data", (c: Buffer) => { const text = c.toString("utf8"); // The verify reads this whole buffer back (driftLines), so it is @@ -258,9 +289,12 @@ export async function rsyncTree(opts: { } // rsync rewrites the progress line in place with carriage returns, so one // chunk carries many frames. Split on both, exactly as taskHooks does for - // yt-dlp. + // yt-dlp — but keep the last segment back unless the chunk ended on a + // delimiter, because only then is it a whole line. + const segments = (pending + text).split(/[\r\n]+/); + pending = /[\r\n]$/.test(text) ? "" : (segments.pop() ?? ""); let passedThrough = ""; - for (const part of text.split(/[\r\n]+/)) { + for (const part of segments) { if (part.trim() === "") continue; if (parseRsyncProgress(part) === null) { passedThrough += `${part}\n`; @@ -271,6 +305,16 @@ export async function rsyncTree(opts: { if (passedThrough) opts.log(passedThrough); }); const result = await child; + // THE LAST LINE HAS NO DELIMITER AFTER IT. rsync's final `100%` frame is + // exactly that, and it is the one the decile latch needs to reach 10. + if (pending.trim() !== "") { + if (opts.progress && parseRsyncProgress(pending) !== null) { + opts.progress(pending); + } else { + opts.log(`${pending}\n`); + } + pending = ""; + } return { exitCode: result.exitCode ?? 1, output }; } diff --git a/common/controller/relocateSavedVideos.test.ts b/common/controller/relocateSavedVideos.test.ts @@ -8,6 +8,7 @@ import { readFile, readlink, rm, + symlink, writeFile, } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -313,3 +314,184 @@ async function exists(p: string): Promise<boolean> { async function readDirMarkerExists(p: string): Promise<boolean> { return exists(p); } + +// A STORE THAT HAS NEVER EXISTED IS NOT AN RSYNC ERROR. Nothing has ever been +// persisted on a corpus whose keep-latest window is unset, so there is no +// `saved-videos` directory at all — and rsync exits 23 on a missing source, +// which the first cut reported as "rsync failed (exit 23)" AFTER writing the +// marker, leaving the store stuck in transition over a directory that was never +// there. +test("a store that has never existed moves cleanly, leaving an empty one", async () => { + await withTmp(async (h) => { + // Deliberately no seedStore. + const result = await relocateSavedVideos({ + paths: h.paths, + locationId: "cold", + io: h.io, + onLog: () => {}, + }); + assert.equal(result.files, 0); + const target = relocatedSavedVideosDir(h.root); + assert.equal(await readlink(h.paths.savedVideosDir), target); + assert.equal(h.io.read().storage.savedVideosLocationId, "cold"); + assert.equal(await exists(savedVideosMarkerPath(h.paths)), false); + // And the first persist after the move lands on the platter, through the + // link, with nothing special asked of it. + await mkdir(path.join(h.paths.savedVideosDir, "chan", "vid9"), { + recursive: true, + }); + await writeFile( + path.join(h.paths.savedVideosDir, "chan", "vid9", "source-media.mp4"), + "z", + ); + assert.equal( + await readFile(path.join(target, "chan", "vid9", "source-media.mp4"), "utf8"), + "z", + ); + }); +}); + +// THE TARGET IS DELETED ONLY WHEN THIS RUN CAN VOUCH FOR THE COPY STANDING IN +// FOR IT. "`store` is a real directory" is not evidence: it is also what +// `rsync --copy-links` of the corpus produces (WORKTREES.md documents that as +// the way to carry a store into a shard) and what `inconsistent` looks like. +// The old code rm -rf'd the relocated copy on the strength of it. +test("move back refuses to delete a target the local copy cannot account for", async () => { + await withTmp(async (h) => { + // The state: settings say the store is on cold and the target holds two + // files, but `saved-videos` is a REAL directory holding only one. + const target = relocatedSavedVideosDir(h.root); + await mkdir(path.join(target, "chan", "vid1"), { recursive: true }); + await writeFile( + path.join(target, "chan", "vid1", "source-media.mp4"), + "x".repeat(4096), + ); + await mkdir(path.join(target, "chan", "vid2"), { recursive: true }); + await writeFile( + path.join(target, "chan", "vid2", "source-media.mkv"), + "y".repeat(2048), + ); + await mkdir(path.join(h.paths.savedVideosDir, "chan", "vid1"), { + recursive: true, + }); + await writeFile( + path.join(h.paths.savedVideosDir, "chan", "vid1", "source-media.mp4"), + "x".repeat(4096), + ); + const settings = h.io.read(); + await h.io.write({ + ...settings, + storage: { ...settings.storage, savedVideosLocationId: "cold" }, + }); + + const lines: string[] = []; + const result = await relocateSavedVideos({ + paths: h.paths, + locationId: "", + io: h.io, + onLog: (l) => lines.push(l), + }); + // The reversible half succeeded: the record is cleared and the store is a + // real directory. The DELETE is what was refused. + assert.equal(h.io.read().storage.savedVideosLocationId, undefined); + assert.equal(await isDir(target), true); + assert.match(lines.join("\n"), /was NOT deleted/); + assert.equal(result.locationId, ""); + // No stuck marker over it. + assert.equal(await exists(savedVideosMarkerPath(h.paths)), false); + }); +}); + +test("move back does delete the target once the local copy accounts for it", async () => { + await withTmp(async (h) => { + await seedStore(h.paths); + await relocateSavedVideos({ + paths: h.paths, + locationId: "cold", + io: h.io, + onLog: () => {}, + }); + const target = relocatedSavedVideosDir(h.root); + await relocateSavedVideos({ + paths: h.paths, + locationId: "", + io: h.io, + onLog: () => {}, + }); + assert.equal(await exists(target), false); + }); +}); + +// A RERUN MUST NOT TRIP OVER THE LAST ATTEMPT'S PARKED COPY. A fixed +// `saved-videos.relocated` name meant a rename onto a non-empty directory +// (ENOTEMPTY); the timestamped name plus a prefix sweep is the channel mover's +// answer, and it is the one that does not orphan an earlier attempt's copy on +// the volume the move exists to free. +test("a stale parked copy is swept, not renamed onto", async () => { + await withTmp(async (h) => { + await seedStore(h.paths); + // What an earlier, crashed attempt left beside the store. + const stale = path.join( + path.dirname(h.paths.savedVideosDir), + "saved-videos.relocated-1600000000000", + ); + await mkdir(path.join(stale, "chan", "old"), { recursive: true }); + await writeFile(path.join(stale, "chan", "old", "source-media.mp4"), "old"); + + await relocateSavedVideos({ + paths: h.paths, + locationId: "cold", + io: h.io, + onLog: () => {}, + }); + // Both the stale copy and this run's parked one are gone. + const left = ( + await readdir(path.dirname(h.paths.savedVideosDir)) + ).filter((n) => n.startsWith("saved-videos.")); + assert.deepEqual(left, []); + }); +}); + +// A ROOT THAT IS A SYMLINK BACK INTO THE CORPUS is the lexical check's blind +// spot, and it is the one that copies the store onto itself and then reclaims +// the only copy. +test("a root that only RESOLVES inside the corpus is refused", async () => { + await withTmp(async (h) => { + await seedStore(h.paths); + const inside = path.join(h.paths.transcriptsDir, "inside"); + await mkdir(inside, { recursive: true }); + // A path outside the corpus that is a link to a path inside it. + const sneaky = path.join(path.dirname(h.paths.transcriptsDir), "sneaky"); + await symlink(inside, sneaky); + const settings = h.io.read(); + await h.io.write({ + ...settings, + storage: { + ...settings.storage, + locations: [ + ...settings.storage.locations, + { id: "sneaky", label: "Sneaky", root: sneaky, autoRepoint: false }, + ], + }, + }); + await assert.rejects( + relocateSavedVideos({ + paths: h.paths, + locationId: "sneaky", + io: h.io, + onLog: () => {}, + }), + /inside the corpus/, + ); + assert.equal(await isDir(h.paths.savedVideosDir), true); + assert.equal(await exists(savedVideosMarkerPath(h.paths)), false); + }); +}); + +async function isDir(p: string): Promise<boolean> { + try { + return (await lstat(p)).isDirectory(); + } catch { + return false; + } +} diff --git a/common/controller/relocateSavedVideos.ts b/common/controller/relocateSavedVideos.ts @@ -4,6 +4,7 @@ import { constants as fsConstants, mkdir, readdir, + realpath, rename, rm, symlink, @@ -19,6 +20,10 @@ import { } from "../lib/settings"; import type { RelocationMarker, RelocationPhase } from "../lib/channelMedia"; import { + relocatedSavedVideosDir, + savedVideosMarkerPath, +} from "../lib/savedVideoStore"; +import { clearDirMarker, COPY_ARGS, isDirectory, @@ -58,19 +63,26 @@ import { // deliberate — it is an absolute path the operator set, and silently // re-anchoring it would be this function deciding something it was not asked. -export const SAVED_VIDEOS_DIRNAME = "saved-videos"; -export const SAVED_VIDEOS_MARKER_FILENAME = ".relocating-saved-videos.json"; - -export function savedVideosMarkerPath(paths: Paths): string { - return path.join(paths.transcriptsDir, SAVED_VIDEOS_MARKER_FILENAME); -} +// THE MARKER'S NAME AND ITS READER LIVE IN lib/, not here — see +// lib/savedVideoStore.ts. `savedVideo-server.ts`, which persists a container +// and is the thing that must be refused while a move is in flight, is lib and +// may not import controller. Re-exported so every existing importer of this +// module keeps working; this file WRITES the marker, it does not own it. +export { + SAVED_VIDEOS_DIRNAME, + SAVED_VIDEOS_MARKER_FILENAME, + relocatedSavedVideosDir, + savedVideosMarkerPath, +} from "../lib/savedVideoStore"; -// The store's home on a location: `<root>/saved-videos`. Flat, beside the -// channels' `<slug>/data` dirs, and not configurable for the same reason -// `relocatedDataDir` is not — a mover recognises a target by its shape. -export function relocatedSavedVideosDir(root: string): string { - return path.join(root.trim(), SAVED_VIDEOS_DIRNAME); -} +// THE PARKED NAME IS TIMESTAMPED AND SWEPT BY PREFIX, exactly as the channel +// mover's `data.relocated-<ts>` is, and for its reason: a rerun that renamed +// onto a parked directory left by an earlier attempt gets ENOTEMPTY, and a +// sweep that took only the name THIS process minted would orphan the earlier +// one for ever — a full second copy of the store, on the volume the move exists +// to free. +const PARKED_PREFIX = "saved-videos.relocated-"; +const INCOMING_NAME = "saved-videos.incoming"; export type SavedVideosStoreStatus = // The store is a real directory under the corpus. @@ -275,13 +287,35 @@ async function moveStoreOut(a: Inner): Promise<SavedVideosRelocateResult> { } catch { throw new Error(`The destination root ${loc.root} is not writable`); } - if (isWithin(paths.transcriptsDir, loc.root)) { + // RESOLVED THROUGH EVERY SYMLINK, on BOTH sides. A lexical comparison is + // exactly the check the channel mover learned not to make: `/mnt/x` can be a + // link back into the corpus, and the corpus dir itself is routinely a link + // (the worktrees' `test-transcripts`, a bind mount in a container). Miss it + // and the store is copied onto itself, verified against itself, and then the + // "source" is reclaimed — which is the only copy. + const [realRoot, realCorpus] = await Promise.all([ + realOrResolved(loc.root), + realOrResolved(paths.transcriptsDir), + ]); + if (isWithin(realCorpus, realRoot)) { throw new Error( `The destination root ${loc.root} is inside the corpus at ` + `${paths.transcriptsDir} — the store would be copied onto itself and ` + `then reclaimed. Pick a directory on the other drive.`, ); } + // Belt and braces for a root outside the corpus whose `saved-videos` level is + // a link back into it: the target resolves separately, because realpath of + // the root cannot see through a link one level down. + const realTarget = await realOrResolved(relocatedSavedVideosDir(realRoot)); + const realStore = await realOrResolved(paths.savedVideosDir); + if (isWithin(realCorpus, realTarget) || isWithin(realStore, realTarget)) { + throw new Error( + `The destination ${target} resolves inside the corpus at ` + + `${paths.transcriptsDir} — the store would be copied onto itself and ` + + `then reclaimed. Pick a directory on the other drive.`, + ); + } const state = await linkOrDirState(store); if (!a.resume && state.kind !== "real-dir" && state.kind !== "missing") { throw new Error( @@ -289,6 +323,18 @@ async function moveStoreOut(a: Inner): Promise<SavedVideosRelocateResult> { `already moved, or something else is there.`, ); } + // A STORE THAT HAS NEVER EXISTED IS NOT AN RSYNC ERROR. Nothing has ever been + // persisted on a corpus whose keep-latest window is unset, so there is no + // `saved-videos` directory at all — and rsync exits 23 on a missing source, + // which the old code reported as "rsync failed (exit 23)" AFTER writing the + // marker, leaving the store stuck in transition over a directory that was + // never there. Create it empty and let the move proceed: the operator asked + // for the store to live on the platter, and an empty store on the platter is + // exactly that answer, ready for the first persist. + if (state.kind === "missing") { + await mkdir(store, { recursive: true }); + log(`${store} did not exist yet — created it empty before the move.`); + } let phase: RelocationPhase = a.resume?.phase ?? "copy"; const measured = await measureTree(store); @@ -313,6 +359,8 @@ async function moveStoreOut(a: Inner): Promise<SavedVideosRelocateResult> { ); } await mkdir(target, { recursive: true }); + // What a previous attempt already landed — see the channel mover. + const already = (await measureTree(target)).bytes; await stampMarker(markerFile, target, "out", "copy"); const { exitCode } = await rsyncTree({ rsyncBin: paths.rsyncBin, @@ -322,6 +370,7 @@ async function moveStoreOut(a: Inner): Promise<SavedVideosRelocateResult> { log, progress: makeProgressSink({ totalBytes: measured.bytes, + alreadyBytes: already, log, onProgress: a.onProgress, }), @@ -352,7 +401,10 @@ async function moveStoreOut(a: Inner): Promise<SavedVideosRelocateResult> { // OBSERVE, DO NOT ASSUME — a crash lands between any two syscalls, and the // marker can only say which phase it was in, never how far through it got. const now = await linkOrDirState(store); - const parked = `${store}.relocated`; + const parked = path.join( + path.dirname(store), + `${PARKED_PREFIX}${Date.now()}`, + ); if (now.kind === "real-dir") { // Re-verify: a resumed run did not do the copy in this process and must // not take the interrupted one's word for it. @@ -366,14 +418,24 @@ async function moveStoreOut(a: Inner): Promise<SavedVideosRelocateResult> { signal: a.signal, }) ).retried; - await rm(parked, { recursive: true, force: true }); await rename(store, parked); } else if (now.kind === "other") { throw new Error( `${store} is neither a directory nor a symlink — refusing to replace it`, ); } - const after = await linkOrDirState(store); + // THE LINK IS MADE DEFENSIVELY, AND THE RECORD IS WRITTEN ONLY IF IT EXISTS. + // + // `after.kind === "missing"` was the whole condition, and it is not enough: + // `moveFileCrossDevice` (savedVideo-server.ts) does an unconditional + // `mkdir -p` of the destination's parent, so a persist firing in the + // instant between the rename above and this line RECREATES `saved-videos` + // as a real, empty directory. The old code then saw `real-dir`, made no + // link, and went on to record the store as living on a location it could + // not be reached at — a settings field pointing at bytes nothing follows. + // (The guard in lib/savedVideoStore.ts is what closes that window; this is + // what makes the outcome safe if it is ever open anyway.) + let after = await linkOrDirState(store); if ( after.kind === "link" && path.resolve(after.linkTarget) !== path.resolve(target) @@ -382,8 +444,42 @@ async function moveStoreOut(a: Inner): Promise<SavedVideosRelocateResult> { `${store} already points at ${after.linkTarget}, not ${target}`, ); } - if (after.kind === "missing") await symlink(target, store); - // THE RECORD IS WRITTEN ONLY NOW, on success, after the link exists. + if (after.kind === "real-dir") { + // An empty directory is the mkdir -p above and nothing else — remove it + // and link. A NON-empty one holds bytes this move did not copy, and + // deleting it is not this function's call to make. + const stray = await readdir(store).catch(() => ["keep"] as string[]); + if (stray.length > 0) { + throw new Error( + `${store} is a real directory again and is not empty (${stray.length} ` + + `entr(ies)) — something wrote into the store during the move. The ` + + `copy at ${target} is complete and untouched; move those entries ` + + `aside and rerun.`, + ); + } + log( + `${store} was recreated as an empty directory during the swap ` + + `(a persist raced the move) — removing it and linking.`, + ); + await rm(store, { recursive: false, force: true }).catch(async () => { + await rm(store, { recursive: true, force: true }); + }); + after = await linkOrDirState(store); + } + if (after.kind === "missing") { + await symlink(target, store); + after = await linkOrDirState(store); + } + if (after.kind !== "link") { + throw new Error( + `${store} is not a symlink after the swap (it is ${after.kind}) — ` + + `refusing to record the store as relocated. The copy at ${target} is ` + + `complete; nothing has been deleted.`, + ); + } + // THE RECORD IS WRITTEN ONLY NOW: on success, after the link exists and has + // been read back. It is a statement about where bytes are, and a statement + // nothing can follow is worse than no statement. const latest = io.read(); await io.write({ ...latest, @@ -433,7 +529,7 @@ async function moveStoreBack(a: Inner): Promise<SavedVideosRelocateResult> { ); } - const incoming = `${store}.incoming`; + const incoming = path.join(path.dirname(store), INCOMING_NAME); let phase: RelocationPhase = a.resume?.phase ?? "copy"; if (phase === "copy" && !(await isDirectory(target))) { throw new Error( @@ -479,6 +575,8 @@ async function moveStoreBack(a: Inner): Promise<SavedVideosRelocateResult> { log, progress: makeProgressSink({ totalBytes: measured.bytes, + // The figure the space check above is priced in. + alreadyBytes: already, log, onProgress: a.onProgress, }), @@ -503,6 +601,24 @@ async function moveStoreBack(a: Inner): Promise<SavedVideosRelocateResult> { phase = "swap"; } + // MAY THE TARGET BE DELETED AT THE END? Only when this run can vouch for the + // copy that is standing in for it. + // + // The old code reclaimed the target whenever `store` was a real directory, + // and "a real directory" is not evidence of anything: it is ALSO what + // `rsync --copy-links` of the corpus produces (WORKTREES.md documents that as + // the way to carry a store into a shard), and it is what the `inconsistent` + // state looks like — settings naming a location while the disk holds a real + // dir. In both cases `rm -rf target` deletes the relocated copy on the + // strength of a local directory nobody compared it with. + // + // Three things count as vouching, and nothing else does: this run did the + // swap itself from a verified `incoming`; the marker says a previous run got + // past the swap (`phase: "reclaim"`, which is written only after it); or the + // local tree measures at least as large as the target's, which is the + // cheapest honest answer when neither of the first two applies. + let vouched = a.resume?.phase === "reclaim"; + if (phase === "swap") { await stampMarker(markerFile, target, "back", "swap"); const now = await linkOrDirState(store); @@ -523,6 +639,9 @@ async function moveStoreBack(a: Inner): Promise<SavedVideosRelocateResult> { // recursively would be the one way this design eats the media. if (now.kind !== "missing") await unlink(store); await rename(incoming, store); + // THIS run moved a verified copy into place. Nothing is more vouched + // than that. + vouched = true; log(`Swapped: ${store} is a real directory again`); } const latest = io.read(); @@ -533,6 +652,40 @@ async function moveStoreBack(a: Inner): Promise<SavedVideosRelocateResult> { await stampMarker(markerFile, target, "back", "reclaim"); } + // THE LAST MEASUREMENT BEFORE THE ONLY DESTRUCTIVE STEP. Cheap — two walks of + // a store that holds one container per pinned video — and it is the answer to + // "is what I am about to delete still the only copy". + if (!vouched && (await isDirectory(target))) { + const [here, there] = await Promise.all([ + measureTree(store), + measureTree(target), + ]); + vouched = here.bytes >= there.bytes && here.files >= there.files; + if (!vouched) { + // NOT AN ERROR, AND DELIBERATELY NOT: the move back has succeeded as far + // as anything reversible goes — the store is a real directory and the + // record is cleared. What is refused is the DELETE, and leaving a second + // copy on the platter is the safe half of that decision. The marker is + // cleared so the store is not stuck in transition over it. + await reclaimParked(paths, log); + await clearDirMarker(markerFile); + log( + `The store is in place, but ${target} was NOT deleted: it holds ` + + `${there.files} file(s)/${formatBytes(there.bytes)} against ` + + `${here.files}/${formatBytes(here.bytes)} here, so this run cannot ` + + `vouch that the local copy is complete. Compare them and remove it ` + + `by hand.`, + ); + return { + locationId: "", + target, + bytes: here.bytes, + files: here.files, + resumed: Boolean(a.resume), + retried, + }; + } + } await rm(target, { recursive: true, force: true }); await reclaimParked(paths, log); await clearDirMarker(markerFile); @@ -556,16 +709,21 @@ async function reclaimParked( log: (m: string) => void, ): Promise<void> { const parent = path.dirname(paths.savedVideosDir); - const base = path.basename(paths.savedVideosDir); const names = await readdir(parent).catch(() => [] as string[]); for (const n of names) { - if (n !== `${base}.relocated` && n !== `${base}.incoming`) continue; + if (!n.startsWith(PARKED_PREFIX) && n !== INCOMING_NAME) continue; const p = path.join(parent, n); log(`Reclaiming ${p}`); await rm(p, { recursive: true, force: true }); } } +// Resolved through every symlink when the path exists, lexically when it does +// not — `relocateChannelMedia.ts`'s helper, same name, same reason. +async function realOrResolved(p: string): Promise<string> { + return await realpath(p).catch(() => path.resolve(p)); +} + function isWithin(parent: string, child: string): boolean { const rel = path.relative(parent, child); return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel)); diff --git a/common/controller/storageWatch.test.ts b/common/controller/storageWatch.test.ts @@ -1,4 +1,4 @@ -import { test } from "node:test"; +import { beforeEach, test } from "node:test"; import assert from "node:assert/strict"; import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -10,7 +10,15 @@ import { sanitizeChannelPriority, } from "../lib/channelPriority"; import { LANES } from "../lib/autoQueueTypes"; -import { runStorageWatchPass } from "./storageWatch"; +import { + resetStorageWatchSuspicion, + runStorageWatchPass, +} from "./storageWatch"; + +// THE CONFIRMATION COUNT IS MODULE STATE (see storageWatch.ts rule 3), so each +// case starts from a clean one — otherwise the second test inherits the first +// test's suspicions and pauses on what should be its first pass. +beforeEach(() => resetStorageWatchSuspicion()); // Run with: // pnpm --filter yt-dlp-transcript-common exec tsx --test controller/storageWatch.test.ts @@ -103,16 +111,46 @@ function tierOf(h: H, slug: string): string | undefined { return h.io.read().channelPriority.channels[slug]?.tier; } +// ONE BAD READ IS A SUSPICION, TWO IN A ROW IS A FACT — availability is a bare +// stat with a blanket catch, so an EIO or a spun-down disk reads exactly like +// "not mounted". Most cases here are about what happens once a drive really is +// gone, so they run the confirming pair and assert on the second. +async function twoPasses(h: H) { + const first = await runStorageWatchPass({ + paths: h.paths, + io: h.io, + bins: h.paths, + }); + const second = await runStorageWatchPass({ + paths: h.paths, + io: h.io, + bins: h.paths, + }); + return { first, second }; +} + test("a channel whose target is gone is auto-paused, once, in one write", async () => { await withTmp(async (h) => { await seedRelocated(h, "gone-a", { targetExists: false }); await seedRelocated(h, "gone-b", { targetExists: false }); await seedRelocated(h, "fine", { targetExists: true }); - const first = await runStorageWatchPass({ paths: h.paths, io: h.io, bins: h.paths }); - assert.deepEqual(first.paused.sort(), ["gone-a", "gone-b"]); - assert.deepEqual(first.restored, []); - assert.equal(first.wrote, true); + // RUN THE PASSES ONE AT A TIME HERE, not through twoPasses(): the write + // count between them is exactly what this case is about. + const pass = () => + runStorageWatchPass({ paths: h.paths, io: h.io, bins: h.paths }); + const first = await pass(); + // The first pass only suspects — and writes NOTHING, which is the point: + // one flaky stat must not rewrite the corpus's priority document. + assert.deepEqual(first.suspected.sort(), ["gone-a", "gone-b"]); + assert.deepEqual(first.paused, []); + assert.equal(first.wrote, false); + assert.equal(h.writes, 0); + // The second confirms. + const second = await pass(); + assert.deepEqual(second.paused.sort(), ["gone-a", "gone-b"]); + assert.deepEqual(second.restored, []); + assert.equal(second.wrote, true); // TWO CHANNELS, ONE WRITE. Ten on a drive that vanished must be one pulse // bump, not ten. assert.equal(h.writes, 1); @@ -124,9 +162,9 @@ test("a channel whose target is gone is auto-paused, once, in one write", async ); // A QUIET PASS WRITES NOTHING. The document already describes the world. - const second = await runStorageWatchPass({ paths: h.paths, io: h.io, bins: h.paths }); - assert.deepEqual(second.paused, []); - assert.equal(second.wrote, false); + const third = await runStorageWatchPass({ paths: h.paths, io: h.io, bins: h.paths }); + assert.deepEqual(third.paused, []); + assert.equal(third.wrote, false); assert.equal(h.writes, 1); }); }); @@ -143,7 +181,7 @@ test("the drive coming back restores the tier it overwrote", async () => { }); h.writes = 0; - await runStorageWatchPass({ paths: h.paths, io: h.io, bins: h.paths }); + await twoPasses(h); assert.equal(tierOf(h, "away"), "paused"); await mkdir(path.join(h.root, "away", "data"), { recursive: true }); @@ -171,9 +209,9 @@ test("a manually paused channel is never claimed by the watch", async () => { }), }); h.writes = 0; - const r = await runStorageWatchPass({ paths: h.paths, io: h.io, bins: h.paths }); - assert.deepEqual(r.paused, []); - assert.equal(r.wrote, false); + const { second } = await twoPasses(h); + assert.deepEqual(second.paused, []); + assert.equal(second.wrote, false); assert.equal(h.writes, 0); assert.equal( h.io.read().channelPriority.channels.off.autoPaused, @@ -196,9 +234,9 @@ test("a channel mid-relocation is not auto-paused", async () => { phase: "copy", }), ); - const r = await runStorageWatchPass({ paths: h.paths, io: h.io, bins: h.paths }); - assert.deepEqual(r.paused, []); - assert.equal(r.wrote, false); + const { second } = await twoPasses(h); + assert.deepEqual(second.paused, []); + assert.equal(second.wrote, false); }); }); @@ -240,12 +278,10 @@ test("a record on an in-place channel is restored", async () => { test("write: false reports the transition and changes nothing", async () => { await withTmp(async (h) => { await seedRelocated(h, "gone", { targetExists: false }); - const r = await runStorageWatchPass({ - paths: h.paths, - io: h.io, - bins: h.paths, - write: false, - }); + // The first pass only suspects, whatever `write` says. + const opts = { paths: h.paths, io: h.io, bins: h.paths, write: false }; + assert.deepEqual((await runStorageWatchPass(opts)).suspected, ["gone"]); + const r = await runStorageWatchPass(opts); assert.deepEqual(r.paused, ["gone"]); assert.equal(r.wrote, false); assert.equal(h.writes, 0); @@ -262,6 +298,80 @@ test("no locations and nothing auto-paused is a free pass", async () => { }); h.writes = 0; const r = await runStorageWatchPass({ paths: h.paths, io: h.io, bins: h.paths }); - assert.deepEqual(r, { probed: 0, paused: [], restored: [], wrote: false }); + assert.deepEqual(r, { + probed: 0, + suspected: [], + repointed: [], + paused: [], + restored: [], + wrote: false, + }); + }); +}); + +// ONE BAD READ MUST NOT PAUSE A TIER. Availability is a bare `stat` with a +// blanket catch (storageVolumes.ts), so an EIO on a flaky cable or a disk that +// has spun down and needs a beat to answer is indistinguishable from "not +// mounted" — and pausing on it rewrites the corpus's priority document for a +// drive that is fine. +test("a drive that blips for one pass is never paused", async () => { + await withTmp(async (h) => { + await seedRelocated(h, "blip", { targetExists: false }); + const first = await runStorageWatchPass({ + paths: h.paths, + io: h.io, + bins: h.paths, + }); + assert.deepEqual(first.suspected, ["blip"]); + assert.deepEqual(first.paused, []); + assert.equal(h.writes, 0); + + // It answers on the next pass. Nothing was ever paused, and the suspicion + // is dropped — so a LATER real outage starts its own two-pass count rather + // than pausing immediately on the strength of a blip an hour ago. + await mkdir(path.join(h.root, "blip", "data"), { recursive: true }); + const second = await runStorageWatchPass({ + paths: h.paths, + io: h.io, + bins: h.paths, + }); + assert.deepEqual(second.paused, []); + assert.deepEqual(second.restored, []); + assert.equal(second.wrote, false); + assert.equal(h.writes, 0); + + // Prove the suspicion really was dropped: the drive going away again takes + // two fresh passes. + await rm(path.join(h.root, "blip"), { recursive: true, force: true }); + assert.deepEqual( + (await runStorageWatchPass({ paths: h.paths, io: h.io, bins: h.paths })) + .paused, + [], + ); + assert.deepEqual( + (await runStorageWatchPass({ paths: h.paths, io: h.io, bins: h.paths })) + .paused, + ["blip"], + ); + }); +}); + +// RESTORE STAYS SINGLE-PASS, and the asymmetry is the point: being slow to +// pause costs a few refused units (the start-of-work guards catch those), while +// being slow to restore leaves a lane off after the operator fixed the cable. +test("the restore needs only one good pass", async () => { + await withTmp(async (h) => { + await seedRelocated(h, "back", { targetExists: false }); + await twoPasses(h); + assert.equal(tierOf(h, "back"), "paused"); + h.writes = 0; + await mkdir(path.join(h.root, "back", "data"), { recursive: true }); + const r = await runStorageWatchPass({ + paths: h.paths, + io: h.io, + bins: h.paths, + }); + assert.deepEqual(r.restored, ["back"]); + assert.equal(h.writes, 1); }); }); diff --git a/common/controller/storageWatch.ts b/common/controller/storageWatch.ts @@ -22,7 +22,7 @@ import { inspectChannelMedia } from "../lib/channelMedia"; import { locationOfDataDir } from "../lib/storageLocations"; import type { VolumeBins } from "../lib/storageVolumes"; import { listChannelConfigs } from "./channels"; -import { probeAllLocations } from "./storageLocations"; +import { maybeAutoRepoint, probeAllLocations } from "./storageLocations"; // THE DRIVE WENT AWAY WHILE THE CHANNEL WAS ON — now what. // @@ -49,6 +49,17 @@ import { probeAllLocations } from "./storageLocations"; // an `autoPaused` record, and the one priority writer clears that record on // any manual tier change — so a drive coming back can never un-pause a // channel the operator paused on purpose in the meantime. +// 3. IT TAKES TWO CONSECUTIVE DOWN PASSES TO PAUSE, AND ONE UP PASS TO +// RESTORE. Availability is a bare `stat` with a blanket catch +// (`storageVolumes.ts`) — an EIO on a flaky cable, or a disk that has spun +// down and needs a beat to answer, reads exactly like "not mounted". One +// such read would otherwise pause every channel on the drive and rewrite +// the corpus's priority document. The confirmation is held IN MEMORY, not +// in settings: a pending suspicion is not a fact worth persisting, and a +// process restart starting the count again is the safe direction. The +// asymmetry is deliberate — being slow to pause costs a few refused units +// (the start-of-work guards catch those), while being slow to RESTORE costs +// the operator a lane that stays off after they fixed the cable. // // IT IS A RUNNER, so `ARCHILYZER_IDLE_BOOT` must not arm it: a container // pointed at somebody else's corpus for the first time has no business @@ -60,6 +71,13 @@ import { probeAllLocations } from "./storageLocations"; export type StorageWatchResult = { // Locations probed this pass. probed: number; + // Channels this pass saw as unreachable for the FIRST time. They are not + // paused yet; the next pass decides. Reported so a caller (and the test) can + // see the confirmation working rather than infer it from silence. + suspected: string[]; + // Locations whose volume was found at a different mountpoint and for which an + // auto re-point was queued. Empty unless a location has `autoRepoint` on. + repointed: string[]; // Channels newly auto-paused, and channels restored. Both empty on a quiet // pass, which is the overwhelming majority. paused: string[]; @@ -80,6 +98,15 @@ export type StorageWatchOpts = { const DEFAULT_IO = { read: getSettings, write: writeSettings }; +// Channels seen down on the LAST pass and not yet paused. Module state, and +// deliberately not settings: see rule 3 in the header. +const suspected = new Set<string>(); + +// Test seam, and the escape hatch for a process that wants a clean count. +export function resetStorageWatchSuspicion(): void { + suspected.clear(); +} + export async function runStorageWatchPass( opts: StorageWatchOpts = {}, ): Promise<StorageWatchResult> { @@ -90,6 +117,8 @@ export async function runStorageWatchPass( const locations = settings.storage.locations; const out: StorageWatchResult = { probed: 0, + suspected: [], + repointed: [], paused: [], restored: [], wrote: false, @@ -111,6 +140,49 @@ export async function runStorageWatchPass( }); out.probed = Object.keys(probes).length; + // THE DRIVE CAME UP SOMEWHERE ELSE — FOLLOW IT, IF THE OPERATOR ARMED THAT. + // + // `mounted-elsewhere` is the one status with a remedy that moves no bytes: + // the volume IS here, under a different mountpoint, and a re-point rewrites + // the links. Until now only the BOOT pass took it, so a disk that came back + // at a new mountpoint while the editor was up sat there while this pass + // dutifully paused every channel on it — and the fix was a restart. Same + // opt-in (`autoRepoint`), same preflight, same refusal-with-a-reason; what + // changes is that the cadence can reach it. + // + // It runs BEFORE the per-channel loop and the enqueued job runs after this + // pass returns, so this pass still sees (and may still suspect) the channels + // on that location — which is correct: nothing has moved yet, and the + // two-pass confirmation gives the re-point a whole interval to land before + // anything is paused. + if (opts.write !== false) { + for (const loc of locations) { + const probe = probes[loc.id]; + if (!probe || probe.status !== "mounted-elsewhere" || !loc.autoRepoint) { + continue; + } + const outcome = await maybeAutoRepoint({ + paths, + location: loc, + probe, + bins: opts.bins ?? paths, + io, + }).catch((err) => ({ + started: false as const, + reason: (err as Error).message, + })); + if (outcome.started) { + out.repointed.push(loc.id); + log( + `[storage] "${loc.id}": the volume came up at a different mountpoint ` + + `— auto re-point queued (${outcome.newRoot})`, + ); + } else { + log(`[storage] "${loc.id}": auto re-point declined — ${outcome.reason}`); + } + } + } + const configs = await listChannelConfigs(paths); let model: ChannelPriority = settings.channelPriority; @@ -121,6 +193,7 @@ export async function runStorageWatchPass( // In place. It cannot be on a drive that went away — but it CAN carry a // record from before it was moved back, and that record has to come off // or the channel stays paused for ever. + suspected.delete(slug); if (wasAutoPaused) { model = restoreAfterMedia(model, slug); out.restored.push(slug); @@ -145,6 +218,17 @@ export async function runStorageWatchPass( : locationDown || media.status === "unreachable"; if (down && !wasAutoPaused) { + // ONE BAD READ IS A SUSPICION, TWO IN A ROW IS A FACT. See rule 3. + if (!suspected.has(slug)) { + suspected.add(slug); + out.suspected.push(slug); + log( + `[storage] ${slug}: media unreachable (${ + loc ? `location "${loc.id}" is ${probe?.status ?? "unprobed"}` : media.status + }) — waiting for a second pass to confirm before pausing`, + ); + continue; + } const before = model; model = autoPauseForMedia(model, slug); // autoPauseForMedia no-ops on a channel the OPERATOR already paused — @@ -159,6 +243,7 @@ export async function runStorageWatchPass( } continue; } + if (!down) suspected.delete(slug); if (!down && wasAutoPaused) { const restoredTo = model.channels[slug]?.autoPaused?.previousTier; model = restoreAfterMedia(model, slug); diff --git a/common/jobs/progressParsers.test.ts b/common/jobs/progressParsers.test.ts @@ -230,3 +230,32 @@ test("rsync progress: the detail line is the one wording", () => { "57.2 MB of 57.2 MB · 100 % · 1.05GB/s", ); }); + +// A TORN FRAME MUST NEVER PARSE AS A FRAME. A chunk boundary falls wherever the +// pipe decides, and it lands inside a progress line often enough to matter on a +// long copy. The halves are the hazard, not the loss: ` 30,000,` still +// matches nothing, but the NEXT chunk's leading ` 30` can parse as a frame +// reporting thirty bytes — the bar jumps back to 0 % and the decile latch has +// already spent its line. relocateDir.ts holds the trailing segment back; this +// pins what each half does on its own so that buffering is provably necessary. +test("rsync progress: half a frame is not a frame, and two halves are one", () => { + const whole = " 30,000,000 85% 1.03GB/s 0:00:03 (xfr#1, to-chk=1/3)"; + const cut = 12; + const head = whole.slice(0, cut); + const tail = whole.slice(cut); + // The head alone has no percentage, so it cannot parse. + assert.equal(parseRsyncProgress(head), null); + // The tail alone is the dangerous one: rejoined wrongly it would be read as a + // frame about a handful of bytes. + const strayTail = parseRsyncProgress(tail); + if (strayTail !== null) { + assert.notEqual(strayTail.bytes, 30_000_000); + } + // Rejoined, it is the frame it always was. + assert.deepEqual(parseRsyncProgress(head + tail), { + bytes: 30_000_000, + percent: 85, + rate: "1.03GB/s", + etaSeconds: 3, + }); +}); diff --git a/common/lib/channelPriority.test.ts b/common/lib/channelPriority.test.ts @@ -1306,3 +1306,73 @@ test("an auto-paused channel keeps its rank across a sanitize", () => { undefined, ); }); + +// THE FULL ROUND TRIP: pause → sanitize → restore → sanitize gives back the +// entry the operator wrote, in every field. +// +// The overrides half is the one that bit. `sanitizeOverrides` normalises away +// any override equal to the BASE TIER — which is what keeps the document a +// list of exceptions — and while the machine's pause stands the base on the +// entry is the forced `paused`. Measured against that, every `{op:"paused"}` +// fence the operator set stops being an exception and is deleted, so the +// restore hands back a channel with no fence at all. On this corpus that is +// legal-mindset losing both of its, and cornbreadman losing its sync fence to +// one hiccup of a USB cable. +test("an auto-pause round trip preserves tier, rank AND per-operation overrides", () => { + const written = sanitizeChannelPriority({ + channels: { + "legal-mindset": { + tier: "normal", + rank: 7, + overrides: { sync: "paused", download: "paused" }, + }, + }, + }); + assert.deepEqual(written.channels["legal-mindset"], { + tier: "normal", + rank: 7, + overrides: { sync: "paused", download: "paused" }, + }); + + // The drive goes away. The tier is forced, the fences are NOT touched. + const paused = sanitizeChannelPriority( + autoPauseForMedia(written, "legal-mindset", new Date("2026-09-20T00:00:00Z")), + ); + assert.equal(paused.channels["legal-mindset"].tier, "paused"); + assert.equal(paused.channels["legal-mindset"].rank, 7); + assert.deepEqual(paused.channels["legal-mindset"].overrides, { + sync: "paused", + download: "paused", + }); + // Sanitizing twice must not erode it either — the document is written back + // to disk on every settings write. + assert.deepEqual(sanitizeChannelPriority(paused), paused); + + // And it comes back exactly as it went in. + const restored = sanitizeChannelPriority( + restoreAfterMedia(paused, "legal-mindset"), + ); + assert.deepEqual(restored.channels["legal-mindset"], { + tier: "normal", + rank: 7, + overrides: { sync: "paused", download: "paused" }, + }); +}); + +// The mirror case: an override equal to the RESTORED base is still normalised +// away while auto-paused, because that is what it will be on restore. +test("an override equal to the previous tier is still dropped while auto-paused", () => { + const paused = sanitizeChannelPriority( + autoPauseForMedia( + sanitizeChannelPriority({ + channels: { a: { tier: "low", overrides: { sync: "low" } } }, + }), + "a", + ), + ); + assert.equal(paused.channels.a.overrides, undefined); + assert.equal( + sanitizeChannelPriority(restoreAfterMedia(paused, "a")).channels.a.tier, + "low", + ); +}); diff --git a/common/lib/channelPriority.ts b/common/lib/channelPriority.ts @@ -266,10 +266,29 @@ export function sanitizeChannelPriority(value: unknown): ChannelPriority { ? raw.tier : DEFAULT_CHANNEL_TIER; const entry: ChannelPriorityEntry = { tier }; - const overrides = sanitizeOverrides(raw.overrides, tier); - if (overrides) entry.overrides = overrides; + // AUTO-PAUSE FIRST, BECAUSE THE OVERRIDES ARE NORMALISED AGAINST THE BASE + // TIER — AND WHILE THE MACHINE'S PAUSE STANDS, `tier` IS NOT IT. + // + // `sanitizeOverrides` drops any override equal to the base, which is what + // keeps the document a list of exceptions. Measure that against the FORCED + // `paused` and every `{op: "paused"}` fence the operator set is an + // "exception" that is no longer an exception, so it is deleted — and the + // restore then hands back a channel with the fence gone. Reproduced: + // `{tier:"normal", overrides:{sync:"paused"}}` auto-paused and restored + // came back as a bare `{tier:"normal"}`. On this corpus that is + // legal-mindset losing both its fences, and cornbreadman — which lives on + // the platter with `overrides:{sync:"paused"}` — losing its sync fence to + // one hiccup of a USB cable. + // + // `previousTier` is the base the operator actually set, so that is what the + // exceptions are exceptions to. const autoPaused = sanitizeAutoPause(raw.autoPaused, tier); if (autoPaused) entry.autoPaused = autoPaused; + const overrides = sanitizeOverrides( + raw.overrides, + autoPaused ? autoPaused.previousTier : tier, + ); + if (overrides) entry.overrides = overrides; if (typeof raw.rank === "number" && Number.isFinite(raw.rank)) { // RANK IS ONLY MEANINGFUL WHERE SOMETHING IS ORDERED. A channel that is diff --git a/common/lib/savedVideo-server.ts b/common/lib/savedVideo-server.ts @@ -15,6 +15,8 @@ import { type SavedVideoKeepReason, type SavedVideoPointer, } from "./savedVideo"; +import { assertSavedVideosStoreWritable } from "./savedVideoStore"; +import type { Paths } from "./paths"; // Filesystem side of the saved-video store. See savedVideo.ts for the layout. @@ -105,7 +107,18 @@ export async function persistSourceVideo(opts: { sourceFilename: string; storeDir: string; keepReason?: SavedVideoKeepReason; + // WHEN THE STORE IS BEING MOVED, THIS DOES NOT RUN. Passed by the download + // path, which has them; a caller that omits them opts out, which is right for + // a store that is not the corpus one. See lib/savedVideoStore.ts for the + // three ways a write landing mid-move loses a container silently — the worst + // of them is `moveFileCrossDevice`'s unconditional `mkdir -p` recreating + // `saved-videos` as a real directory in the instant between the mover's + // rename and its symlink. + paths?: Pick<Paths, "transcriptsDir" | "savedVideosDir">; }): Promise<SavedVideoPointer> { + if (opts.paths) { + await assertSavedVideosStoreWritable(opts.paths, opts.storeDir); + } const src = path.join(opts.videoDir, opts.sourceFilename); const dest = path.join(opts.storeDir, opts.sourceFilename); const st = await stat(src); @@ -143,9 +156,16 @@ async function pruneEmptyStoreDirs(storeDir: string): Promise<void> { // Reverse persistSourceVideo: move the stored container back into the data dir // and remove the pointer. Returns false when there was no pointer to reverse. -export async function unpersistSavedVideo(videoDir: string): Promise<boolean> { +export async function unpersistSavedVideo( + videoDir: string, + // Same opt-in guard as persistSourceVideo, and for the same window: this + // READS out of the store and then prunes its empty dirs, both of which race a + // move in flight. + paths?: Pick<Paths, "transcriptsDir" | "savedVideosDir">, +): Promise<boolean> { const pointer = await loadSavedVideo(videoDir); if (!pointer) return false; + if (paths) await assertSavedVideosStoreWritable(paths, pointer.dir); const src = savedVideoPath(pointer); const dest = path.join(videoDir, pointer.file); try { diff --git a/common/lib/savedVideoStore.test.ts b/common/lib/savedVideoStore.test.ts @@ -0,0 +1,168 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import type { Paths } from "./paths"; +import { + assertSavedVideosStoreWritable, + isInCorpusSavedVideoStore, + readSavedVideosMarker, + savedVideosMarkerPath, + SavedVideosStoreInTransitionError, +} from "./savedVideoStore"; +import { persistSourceVideo } from "./savedVideo-server"; + +// Run with: +// pnpm --filter yt-dlp-transcript-common exec tsx --test lib/savedVideoStore.test.ts +// +// THE GUARD AT THE MOMENT OF THE WRITE. The relocation queue key serialises +// relocations against each other and says nothing about a download; this is +// what actually stops a container landing in a store that is being moved — and +// it has to live in lib/, because `savedVideo-server.ts` is what does the +// landing and lib may not import controller. + +async function withTmp( + fn: (paths: Paths, videoDir: string) => Promise<void>, +): Promise<void> { + const dir = await mkdtemp(path.join(tmpdir(), "ttb-store-guard-")); + const transcriptsDir = path.join(dir, "corpus"); + const paths = { + transcriptsDir, + channelsDir: path.join(transcriptsDir, "channels"), + savedVideosDir: path.join(transcriptsDir, "saved-videos"), + } as Paths; + const videoDir = path.join(paths.channelsDir, "chan", "data", "vid1"); + await mkdir(videoDir, { recursive: true }); + await mkdir(paths.savedVideosDir, { recursive: true }); + try { + await fn(paths, videoDir); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +async function writeMarker(paths: Paths, phase = "copy"): Promise<void> { + await writeFile( + savedVideosMarkerPath(paths), + JSON.stringify({ + target: "/mnt/platter/saved-videos", + direction: "out", + startedAt: new Date().toISOString(), + phase, + }), + ); +} + +test("no marker, no refusal", async () => { + await withTmp(async (paths) => { + assert.equal(await readSavedVideosMarker(paths), null); + await assertSavedVideosStoreWritable(paths, paths.savedVideosDir); + }); +}); + +test("a marker refuses a write into the corpus store, by name", async () => { + await withTmp(async (paths) => { + await writeMarker(paths, "swap"); + await assert.rejects( + assertSavedVideosStoreWritable( + paths, + path.join(paths.savedVideosDir, "chan", "vid1"), + ), + (err: Error) => { + assert.ok(err instanceof SavedVideosStoreInTransitionError); + assert.match(err.message, /being moved/); + assert.match(err.message, /phase "swap"/); + return true; + }, + ); + }); +}); + +// A CHANNEL MAY POINT ITS OWN STORE SOMEWHERE ELSE ENTIRELY +// (`ChannelConfig.savedVideosDir`), and that store is not the one being moved. +// Refusing a write to it would decline a persist for a move that has nothing to +// do with it. +test("a store outside the corpus is not this move's business", async () => { + await withTmp(async (paths) => { + await writeMarker(paths); + assert.equal( + isInCorpusSavedVideoStore(paths, "/somewhere/else/chan/vid1"), + false, + ); + await assertSavedVideosStoreWritable(paths, "/somewhere/else/chan/vid1"); + }); +}); + +// THE WHOLE POINT, end to end: a persist that fires while the store is being +// moved does not move the container. The three ways it ends badly are in +// savedVideoStore.ts's header; the worst is the container landing in the +// directory the swap is about to park, which `reclaimParked` then rm -rf's +// while `saved-video.json` still points at it. +test("persistSourceVideo refuses while the store is in transition, and moves nothing", async () => { + await withTmp(async (paths, videoDir) => { + const source = "source-media.mp4"; + await writeFile(path.join(videoDir, source), "the only copy"); + const storeDir = path.join(paths.savedVideosDir, "chan", "vid1"); + await writeMarker(paths); + + await assert.rejects( + persistSourceVideo({ + videoDir, + sourceFilename: source, + storeDir, + paths, + }), + /being moved/, + ); + // The container is still where it was, with no pointer beside it and + // nothing created in the store. + assert.equal( + await readFile(path.join(videoDir, source), "utf8"), + "the only copy", + ); + assert.equal(await exists(path.join(videoDir, "saved-video.json")), false); + assert.equal(await exists(storeDir), false); + + // Clear the marker and the same call succeeds — the guard is the marker + // and nothing else. + await rm(savedVideosMarkerPath(paths), { force: true }); + const pointer = await persistSourceVideo({ + videoDir, + sourceFilename: source, + storeDir, + paths, + }); + assert.equal(pointer.dir, storeDir); + assert.equal( + await readFile(path.join(storeDir, source), "utf8"), + "the only copy", + ); + }); +}); + +// A caller that passes no paths opts out. That is right for a store that is not +// the corpus one, and it is what keeps this change from touching every existing +// call site. +test("persistSourceVideo without paths does not consult the marker", async () => { + await withTmp(async (paths, videoDir) => { + await writeMarker(paths); + await writeFile(path.join(videoDir, "source-media.mp4"), "x"); + const storeDir = path.join(paths.savedVideosDir, "chan", "vid1"); + const pointer = await persistSourceVideo({ + videoDir, + sourceFilename: "source-media.mp4", + storeDir, + }); + assert.equal(pointer.file, "source-media.mp4"); + }); +}); + +async function exists(p: string): Promise<boolean> { + try { + await readFile(p); + return true; + } catch (err) { + return (err as NodeJS.ErrnoException).code === "EISDIR"; + } +} diff --git a/common/lib/savedVideoStore.ts b/common/lib/savedVideoStore.ts @@ -0,0 +1,115 @@ +import path from "node:path"; +import { readFile } from "node:fs/promises"; +import type { Paths } from "./paths"; +import type { RelocationMarker } from "./channelMedia"; + +// IS THE SAVED-VIDEO STORE SAFE TO WRITE INTO RIGHT NOW? +// +// The store moves to another drive the same way a channel's `data/` does, and +// it inherits the same hazard: for the length of a multi-hour copy the bytes at +// `transcripts/saved-videos` are being read, verified and then swapped, and +// anything that writes into them in the meantime is one of three failures, all +// of them silent until much later. +// +// 1. A container landing mid-copy makes `verifyCopy` refuse AT THE END of the +// whole transfer — the omnimirror incident, exactly, one directory down. +// 2. A container landing between the verify and the rename lands in the dir +// the swap is about to PARK, and `reclaimParked` then `rm -rf`s it while +// the video's `saved-video.json` still points at it. That is the only copy +// of a source container, gone, with a pointer that outlives it. +// 3. `moveFileCrossDevice` does an unconditional `mkdir -p` of the +// destination's parent, so a persist firing between the `rename` and the +// `symlink` RECREATES `saved-videos` as a real directory — the symlink is +// then never made, and the move would have recorded the store as being on +// a location it cannot be reached at. +// +// THIS MODULE IS lib/, AND IT HAS TO BE. `common/lib/savedVideo-server.ts` is +// what persists a container, it is lib, and lib may not import controller +// (architecture.test.ts) — so the marker's name and its reader live here, next +// to `channelMedia.ts`, which is the same module for the same reason about a +// channel. The controller that WRITES the marker imports these; it does not own +// them. +// +// The marker's shape is deliberately a channel relocation marker +// (`{target, direction, startedAt, phase}`) — one mover writes both, and a +// second shape would be a second thing to keep in step. + +export const SAVED_VIDEOS_DIRNAME = "saved-videos"; +export const SAVED_VIDEOS_MARKER_FILENAME = ".relocating-saved-videos.json"; + +export function savedVideosMarkerPath( + paths: Pick<Paths, "transcriptsDir">, +): string { + return path.join(paths.transcriptsDir, SAVED_VIDEOS_MARKER_FILENAME); +} + +// The store's home on a location: `<root>/saved-videos`. Flat, beside the +// channels' `<slug>/data` dirs, and not configurable for the same reason +// `relocatedDataDir` is not — a mover recognises a target by its shape. +export function relocatedSavedVideosDir(root: string): string { + return path.join(root.trim(), SAVED_VIDEOS_DIRNAME); +} + +export async function readSavedVideosMarker( + paths: Pick<Paths, "transcriptsDir">, +): Promise<RelocationMarker | null> { + try { + const raw = JSON.parse( + await readFile(savedVideosMarkerPath(paths), "utf8"), + ) as Partial<RelocationMarker>; + if (typeof raw.target !== "string" || raw.target.trim() === "") return null; + return { + target: raw.target, + direction: raw.direction === "back" ? "back" : "out", + startedAt: typeof raw.startedAt === "string" ? raw.startedAt : "", + phase: + raw.phase === "swap" || raw.phase === "reclaim" ? raw.phase : "copy", + }; + } catch { + return null; + } +} + +// Thrown by assertSavedVideosStoreWritable. A distinct class so a caller can +// tell "the store is being moved" from any other I/O failure and decline the +// persist rather than failing the whole download. +export class SavedVideosStoreInTransitionError extends Error { + readonly marker: RelocationMarker; + constructor(marker: RelocationMarker) { + super( + `The saved-video store is being moved (${marker.direction} to ` + + `${marker.target}, phase "${marker.phase}") — nothing may be written ` + + `into it until that finishes or its marker is cleared on /storage.`, + ); + this.name = "SavedVideosStoreInTransitionError"; + this.marker = marker; + } +} + +// Is `dir` the corpus store, or inside it? A channel may point its own store +// somewhere else entirely (`ChannelConfig.savedVideosDir`), and that store is +// not the one being moved — guarding a write to it would refuse a persist for a +// move that has nothing to do with it. +export function isInCorpusSavedVideoStore( + paths: Pick<Paths, "savedVideosDir">, + dir: string, +): boolean { + const rel = path.relative(paths.savedVideosDir, dir); + return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel)); +} + +// THE GUARD, at the moment of the write. One `readFile` of a small JSON file +// per persisted container — the same cost `inspectChannelMedia` pays per lane +// tick, and a persist happens once per kept video, not once per tick. +// +// It is checked AT THE WRITE and not only at the start of the download because +// the window is the whole copy: a download that began before the move started +// is exactly the one that would land a container in the parked directory. +export async function assertSavedVideosStoreWritable( + paths: Pick<Paths, "transcriptsDir" | "savedVideosDir">, + storeDir: string, +): Promise<void> { + if (!isInCorpusSavedVideoStore(paths, storeDir)) return; + const marker = await readSavedVideosMarker(paths); + if (marker) throw new SavedVideosStoreInTransitionError(marker); +} diff --git a/common/views/storage.ts b/common/views/storage.ts @@ -325,7 +325,7 @@ export function buildStorageRows(i: StorageRowsInputs): StorageRowsPayload { offered: !busy, ...(busy ? { withheld: busy } : {}), }, - deleteAction(loc, counts, busy), + deleteAction(loc, counts, busy, i.savedVideos?.locationId === loc.id), ]; const roll = i.rollups[loc.id]; @@ -561,10 +561,23 @@ function deleteAction( loc: StorageLocation, counts: StorageChannelCounts, busy: string | null, + // The saved-video store lives here. A resident like any other, and orphaned + // by exactly the same delete — see the action's own refusal. + hasStore: boolean, ): StorageActionView { if (busy) { return { kind: "delete", label: "Delete", offered: false, withheld: busy }; } + if (hasStore) { + return { + kind: "delete", + label: "Delete", + offered: false, + withheld: + `The saved-video store is under ${loc.root}. Move it back in place, ` + + `or onto another location, first.`, + }; + } if (counts.total > 0) { return { kind: "delete", diff --git a/common/ytdlp/downloadOneManaged.ts b/common/ytdlp/downloadOneManaged.ts @@ -262,6 +262,12 @@ async function finalizeAppExtraction(opts: { sourceFilename: source, storeDir, keepReason: opts.category === "none" ? undefined : opts.category, + // The store-in-transition guard. A move of the saved-video store is a + // multi-hour copy, and a container landing in the middle of it is lost + // three different ways — see lib/savedVideoStore.ts. The catch below is + // already the right handling: the container stays in the data dir with a + // line in the log, and the next persist (after the move) picks it up. + paths: opts.paths, }); opts.onLog( `Persisted source video to ${path.join(pointer.dir, pointer.file)} (${pointer.bytes} bytes).\n`, diff --git a/editor/app/storage/actions.ts b/editor/app/storage/actions.ts @@ -28,6 +28,7 @@ import { savedVideosMarkerPath } from "yt-dlp-transcript-common/controller/reloc import { channelMediaBusyReason } from "../channels/lib/mediaBusy"; import { enqueueRepointJob } from "./lib/repointJob"; import { enqueueSavedVideosRelocation } from "./lib/savedVideosJob"; +import { savedVideosStoreBusyReason } from "./lib/storeBusy"; // THE SIX THINGS AN OPERATOR MAY DO TO A STORAGE LOCATION. // @@ -179,6 +180,22 @@ export async function deleteStorageLocationAction( `Move them back in place, or onto another location, first.`, }; } + // AND THE SAVED-VIDEO STORE IS A RESIDENT TOO. The channel check above exists + // because deleting the location erases the only record of which disk those + // absolute paths belong to; the store is on the location by exactly the same + // kind of record (`settings.storage.savedVideosLocationId`) and would be + // orphaned by exactly the same delete — reachable only through a symlink + // whose target nothing in the corpus can any longer name. + if ((settings.storage.savedVideosLocationId ?? "") === id) { + return { + ok: false, + error: + `The saved-video store is on ${location.root}. Move it back in place ` + + `(or onto another location) on this page first — deleting the location ` + + `would leave the store reachable only through a symlink nothing here ` + + `remembers the name of.`, + }; + } const locations = settings.storage.locations.filter((l) => l.id !== id); await writeSettings({ ...settings, @@ -301,6 +318,16 @@ export async function repointStorageLocationAction( export async function relocateSavedVideosAction( locationId: string, ): Promise<StreamActionResult> { + // THE RELOCATION QUEUE KEY ONLY SERIALISES RELOCATIONS. It stops a second + // move, a channel move and a re-point from running at once, and it says + // nothing at all about the download that is about to persist a source + // container into the directory this is about to copy, verify, rename and + // reclaim. See lib/storeBusy.ts for the three ways that ends badly, and + // lib/savedVideoStore.ts for the guard that makes it refuse rather than lose + // a container — this is the half that answers before the operator commits to + // a multi-hour copy. + const busy = savedVideosStoreBusyReason("moving the saved-video store"); + if (busy) return { ok: false, error: busy }; return enqueueSavedVideosRelocation({ locationId }); } @@ -321,6 +348,8 @@ export async function resumeSavedVideosRelocationAction(): Promise<StreamActionR "interrupted move to resume.", }; } + const busy = savedVideosStoreBusyReason("resuming the store's move"); + if (busy) return { ok: false, error: busy }; if (marker.direction === "back") { return enqueueSavedVideosRelocation({ locationId: "" }); } diff --git a/editor/app/storage/lib/storeBusy.ts b/editor/app/storage/lib/storeBusy.ts @@ -0,0 +1,80 @@ +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", + "redownload-archive", + "redownload-incomplete-bucket", + "retry-bucket", + // One kind, channel-scoped: a sync downloads. + "sync", + // The store's own jobs. + "persist-kept", + "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. +export function savedVideosStoreBusyReason(what?: string): string | null { + const jobs = getRegistry() + .list() + .filter( + (j) => + STORE_TOUCHING_KINDS.has(j.kind) && + (j.status === "running" || 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} 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; +}