import { test } from "node:test"; import assert from "node:assert/strict"; import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import type { Paths } from "../lib/paths"; import type { ChannelConfig } from "../lib/channelConfig"; import type { SiteSettings } from "../lib/settings"; import type { DiskGateStatus } from "../lib/diskSpace"; import type { DownloadOutcomeRecord } from "../lib/downloadOutcome"; import { loadSavedVideo, persistSourceVideo } from "../lib/savedVideo-server"; import { persistVideos, type PersistVideoDownload, type PersistVideosDeps, } from "./persistVideos"; // Run with: // pnpm --filter yt-dlp-transcript-common exec tsx --test controller/persistVideos.test.ts // // Every dependency that would reach the network, the clock or the machine's // memory is injected: the download writes a fake container and persists it the // way downloadOneManaged does, sleeps are recorded, MemAvailable is scripted. const CONFIG: ChannelConfig = { handling: "transcribe", platform: "youtube", url: "https://www.youtube.com/@demo", }; const SETTINGS = { sourceVideoQuality: "original", sleepBetweenDownloadsSeconds: 0, minFreeDiskGB: 0, inlineTranscribeOnFallback: false, skipLiveDownloads: false, } as unknown as SiteSettings; const OPEN_GATE: DiskGateStatus = { ok: true, enabled: false, freeBytes: Number.POSITIVE_INFINITY, thresholdBytes: 0, resumeBytes: 0, reason: "ok", message: "", }; const CLOSED_GATE: DiskGateStatus = { ...OPEN_GATE, ok: false, enabled: true, freeBytes: 1, thresholdBytes: 10, reason: "below-floor", message: "Low disk space", } as DiskGateStatus; async function withPaths(fn: (paths: Paths) => Promise): Promise { const dir = await mkdtemp(path.join(tmpdir(), "ttb-persistvideos-")); const paths = { transcriptsDir: dir, channelsDir: path.join(dir, "channels"), savedVideosDir: path.join(dir, "saved"), } as Paths; try { await fn(paths); } finally { await rm(dir, { recursive: true, force: true }); } } function videoDir(paths: Paths, slug: string, id: string): string { return path.join(paths.channelsDir, slug, "data", id); } // A downloaded video: its dir and the metadata that names its source URL. async function seedVideo(paths: Paths, slug: string, id: string): Promise { const dir = videoDir(paths, slug, id); await mkdir(dir, { recursive: true }); await writeFile( path.join(dir, "metadata.info.json"), JSON.stringify({ id, webpage_url: `https://www.youtube.com/watch?v=${id}` }), ); } async function seedSaved( paths: Paths, slug: string, id: string, opts: { height?: number; file?: string; keepReason?: "keep-latest" | "pin"; requestedBy?: string; } = {}, ): Promise { await seedVideo(paths, slug, id); const dir = videoDir(paths, slug, id); const file = opts.file ?? "source-media.mp4"; await writeFile(path.join(dir, file), `old ${id}`); await persistSourceVideo({ videoDir: dir, sourceFilename: file, storeDir: path.join(paths.savedVideosDir, slug, id), keepReason: opts.keepReason ?? "keep-latest", ...(opts.requestedBy ? { origin: { requestedBy: opts.requestedBy } } : {}), format: { preset: "original", ...(opts.height ? { height: opts.height } : {}), }, }); } type Harness = { deps: Partial; downloads: { slug: string; id: string; quality: string }[]; sleeps: number[]; events: string[]; }; // The default harness: every channel exists, the gate is open, memory is // plentiful, and a download persists a 720p container named `file`. function harness( paths: Paths, over: Partial & { file?: string; height?: number } = {}, ): Harness { const downloads: Harness["downloads"] = []; const sleeps: number[] = []; const events: string[] = []; const download: PersistVideoDownload = async (o) => { const id = o.videoUrl.split("v=")[1]; downloads.push({ slug: o.channelSlug, id, quality: o.quality }); events.push(`download ${id}`); const dir = videoDir(paths, o.channelSlug, id); const file = over.file ?? "source-media.mp4"; await writeFile(path.join(dir, file), `new ${id}`); await persistSourceVideo({ videoDir: dir, sourceFilename: file, storeDir: path.join(paths.savedVideosDir, o.channelSlug, id), keepReason: "override", format: { preset: o.quality, height: over.height ?? 720 }, }); return { videoId: id, status: "ok", startedAt: "", finishedAt: "", attempts: [], } satisfies DownloadOutcomeRecord; }; return { downloads, sleeps, events, deps: { download, sleep: async (ms) => { sleeps.push(ms); events.push(`sleep ${ms}`); }, readMemAvailableMb: async () => 99_999, diskGate: async () => OPEN_GATE, getSettings: () => SETTINGS, readChannelConfig: async (_p, slug) => (slug === "nope" ? null : CONFIG), onPlatformBackoff: async () => {}, ...over, }, }; } test("already-saved items are skipped; the rest are fetched in order", async () => { await withPaths(async (paths) => { await seedSaved(paths, "demo-channel", "aaa111"); await seedVideo(paths, "demo-channel", "bbb222"); await seedVideo(paths, "other-channel", "ccc333"); const h = harness(paths); const res = await persistVideos({ paths, items: [ { slug: "demo-channel", id: "aaa111" }, { slug: "demo-channel", id: "bbb222" }, { slug: "other-channel", id: "ccc333" }, ], deps: h.deps, }); assert.deepEqual( h.downloads.map((d) => d.id), ["bbb222", "ccc333"], ); assert.equal(res.plan.saved.count, 1); assert.equal(res.persisted.length, 2); assert.equal(res.failed.length, 0); assert.equal(res.stopped, null); // Re-running the same list is the resume: everything is saved now. const again = await persistVideos({ paths, items: [ { slug: "demo-channel", id: "aaa111" }, { slug: "demo-channel", id: "bbb222" }, { slug: "other-channel", id: "ccc333" }, ], deps: harness(paths).deps, }); assert.equal(again.plan.saved.count, 3); assert.equal(again.plan.willFetch, 0); }); }); test("format defaults to each channel's quality, and an explicit one wins", async () => { await withPaths(async (paths) => { await seedVideo(paths, "demo-channel", "aaa111"); await seedVideo(paths, "hd-channel", "bbb222"); const configs: Record = { "demo-channel": CONFIG, "hd-channel": { ...CONFIG, sourceVideoQuality: "video_720" }, }; const h = harness(paths, { readChannelConfig: async (_p, s) => configs[s] ?? null }); await persistVideos({ paths, items: [ { slug: "demo-channel", id: "aaa111" }, { slug: "hd-channel", id: "bbb222" }, ], dryRun: false, deps: h.deps, }); assert.deepEqual( h.downloads.map((d) => d.quality), ["original", "video_720"], ); await seedVideo(paths, "demo-channel", "ccc333"); const h2 = harness(paths, { readChannelConfig: async (_p, s) => configs[s] ?? null }); await persistVideos({ paths, items: [{ slug: "demo-channel", id: "ccc333" }], format: "video_720", deps: h2.deps, }); assert.deepEqual(h2.downloads.map((d) => d.quality), ["video_720"]); }); }); test("above-height replaces a too-tall or unmeasured container, keeping its provenance", async () => { await withPaths(async (paths) => { await seedSaved(paths, "demo-channel", "tall111", { height: 1080, file: "source-media.webm" }); await seedSaved(paths, "demo-channel", "nohgt22", { keepReason: "pin", requestedBy: "umtool" }); await seedSaved(paths, "demo-channel", "ok33333", { height: 720 }); const items = [ { slug: "demo-channel", id: "tall111" }, { slug: "demo-channel", id: "nohgt22" }, { slug: "demo-channel", id: "ok33333" }, ]; // "never" (the default) leaves every saved item alone. const never = harness(paths); const left = await persistVideos({ paths, items, format: "video_720", deps: never.deps }); assert.equal(never.downloads.length, 0); assert.equal(left.plan.wrongHeight.count, 2); assert.equal(left.plan.saved.count, 1); assert.equal(left.plan.willFetch, 0); const oldTall = await loadSavedVideo(videoDir(paths, "demo-channel", "tall111")); const h = harness(paths); const res = await persistVideos({ paths, items, format: "video_720", replace: "above-height", deps: h.deps, }); assert.deepEqual( h.downloads.map((d) => d.id), ["tall111", "nohgt22"], ); assert.equal(res.replaced.length, 2); const tall = await loadSavedVideo(videoDir(paths, "demo-channel", "tall111")); assert.equal(tall?.file, "source-media.mp4"); assert.equal(tall?.format?.height, 720); // The replacement is still the keep-latest container it replaced. assert.equal(tall?.keepReason, "keep-latest"); const nohgt = await loadSavedVideo(videoDir(paths, "demo-channel", "nohgt22")); assert.equal(nohgt?.keepReason, "pin"); assert.deepEqual(nohgt?.origin, { requestedBy: "umtool" }); assert.equal(nohgt?.format?.height, 720); // The old, differently-named container went only after the new one landed. await assert.rejects(stat(path.join(oldTall!.dir, oldTall!.file))); assert.equal( await readFile(path.join(tall!.dir, tall!.file), "utf8"), "new tall111", ); }); }); test("a replacement that does not land leaves the old container alone", async () => { await withPaths(async (paths) => { await seedSaved(paths, "demo-channel", "tall111", { height: 1080, file: "source-media.webm" }); const h = harness(paths, { // A download that "succeeds" without moving anything into the store. download: async () => ({ videoId: "tall111", status: "ok", startedAt: "", finishedAt: "", attempts: [], }), }); const res = await persistVideos({ paths, items: [{ slug: "demo-channel", id: "tall111" }], format: "video_720", replace: "above-height", deps: h.deps, }); assert.equal(res.failed.length, 1); assert.match(res.failed[0].error, /not saved to the store/); const ptr = await loadSavedVideo(videoDir(paths, "demo-channel", "tall111")); assert.equal(await readFile(path.join(ptr!.dir, ptr!.file), "utf8"), "old tall111"); }); }); test("a closed disk gate stops the run; the rest are not attempted", async () => { await withPaths(async (paths) => { for (const id of ["aaa111", "bbb222", "ccc333"]) { await seedVideo(paths, "demo-channel", id); } let calls = 0; const h = harness(paths, { diskGate: async () => (++calls >= 2 ? CLOSED_GATE : OPEN_GATE), }); const res = await persistVideos({ paths, items: ["aaa111", "bbb222", "ccc333"].map((id) => ({ slug: "demo-channel", id })), deps: h.deps, }); assert.deepEqual(h.downloads.map((d) => d.id), ["aaa111"]); assert.equal(res.stopped, "low-disk"); assert.deepEqual( res.notAttempted.map((i) => i.id), ["bbb222", "ccc333"], ); }); }); test("a rate limit stops the run and records the platform's backoff", async () => { await withPaths(async (paths) => { for (const id of ["aaa111", "bbb222"]) await seedVideo(paths, "demo-channel", id); const backoffs: string[] = []; const h = harness(paths, { download: async () => ({ videoId: "aaa111", status: "failed", startedAt: "", finishedAt: "", attempts: [], failureClass: "rate_limit", }), onPlatformBackoff: async (platform, _p, cls) => { backoffs.push(`${platform}:${cls}`); }, }); const res = await persistVideos({ paths, items: ["aaa111", "bbb222"].map((id) => ({ slug: "demo-channel", id })), deps: h.deps, }); assert.equal(res.stopped, "rate-limit"); assert.equal(res.failed.length, 1); assert.deepEqual(res.notAttempted.map((i) => i.id), ["bbb222"]); assert.deepEqual(backoffs, ["youtube:rate_limit"]); }); }); test("the gap is paid between downloads, not before the first", async () => { await withPaths(async (paths) => { for (const id of ["aaa111", "bbb222", "ccc333"]) await seedVideo(paths, "demo-channel", id); await seedSaved(paths, "demo-channel", "saved11"); const h = harness(paths); await persistVideos({ paths, items: ["aaa111", "saved11", "bbb222", "ccc333"].map((id) => ({ slug: "demo-channel", id, })), gapMs: 5000, deps: h.deps, }); assert.deepEqual(h.events, [ "download aaa111", "sleep 5000", "download bbb222", "sleep 5000", "download ccc333", ]); }); }); test("the default gap is the batch downloads' own (sleepBetweenDownloadsSeconds)", async () => { await withPaths(async (paths) => { for (const id of ["aaa111", "bbb222"]) await seedVideo(paths, "demo-channel", id); const h = harness(paths, { readChannelConfig: async () => ({ ...CONFIG, sleepBetweenDownloadsSeconds: 7 }), }); await persistVideos({ paths, items: ["aaa111", "bbb222"].map((id) => ({ slug: "demo-channel", id })), deps: h.deps, }); assert.deepEqual(h.sleeps, [7000]); }); }); test("the memory wait holds each download until MemAvailable reaches the floor", async () => { await withPaths(async (paths) => { await seedVideo(paths, "demo-channel", "aaa111"); const readings = [1000, 2000, 5000]; const h = harness(paths, { readMemAvailableMb: async () => readings.shift() ?? 5000, }); const res = await persistVideos({ paths, items: [{ slug: "demo-channel", id: "aaa111" }], minFreeMemMb: 4096, deps: h.deps, }); assert.deepEqual(h.events, ["sleep 10000", "sleep 10000", "download aaa111"]); assert.equal(res.persisted.length, 1); }); }); test("cancel and drain stop between items", async () => { await withPaths(async (paths) => { for (const id of ["aaa111", "bbb222", "ccc333"]) await seedVideo(paths, "demo-channel", id); const items = ["aaa111", "bbb222", "ccc333"].map((id) => ({ slug: "demo-channel", id })); const abort = new AbortController(); const h = harness(paths); const inner = h.deps.download!; h.deps.download = async (o) => { const r = await inner(o); abort.abort(); return r; }; const cancelled = await persistVideos({ paths, items, signal: abort.signal, deps: h.deps }); assert.equal(cancelled.stopped, "cancelled"); assert.equal(cancelled.persisted.length, 1); assert.deepEqual(cancelled.notAttempted.map((i) => i.id), ["bbb222", "ccc333"]); const drain = new AbortController(); const h2 = harness(paths); const inner2 = h2.deps.download!; h2.deps.download = async (o) => { const r = await inner2(o); drain.abort(); return r; }; const drained = await persistVideos({ paths, items, drainSignal: drain.signal, deps: h2.deps }); assert.equal(drained.stopped, "drained"); assert.deepEqual(h2.downloads.map((d) => d.id), ["bbb222"]); assert.deepEqual(drained.notAttempted.map((i) => i.id), ["ccc333"]); }); }); test("a dry run buckets every item and fetches nothing", async () => { await withPaths(async (paths) => { await seedSaved(paths, "demo-channel", "saved11", { height: 720 }); await seedSaved(paths, "demo-channel", "tall111", { height: 1080 }); await seedVideo(paths, "demo-channel", "fetch11"); // A video dir with no metadata, on a channel with no platform: no URL. await mkdir(videoDir(paths, "bare-channel", "nourl11"), { recursive: true }); const h = harness(paths, { readChannelConfig: async (_p, slug) => slug === "nope" ? null : slug === "bare-channel" ? { handling: "transcribe" } : CONFIG, }); const res = await persistVideos({ paths, items: [ { slug: "demo-channel", id: "saved11" }, { slug: "demo-channel", id: "tall111" }, { slug: "demo-channel", id: "fetch11" }, { slug: "bare-channel", id: "nourl11" }, { slug: "nope", id: "xyz" }, { slug: "demo-channel", id: "missing" }, { slug: "demo-channel", id: "../escape" }, // A duplicate collapses. { slug: "demo-channel", id: "fetch11" }, ], format: "video_720", replace: "above-height", dryRun: true, deps: h.deps, }); assert.equal(h.downloads.length, 0); assert.equal(res.dryRun, true); assert.deepEqual(res.plan.saved, { count: 1, items: [{ slug: "demo-channel", id: "saved11" }] }); assert.deepEqual(res.plan.wrongHeight.items, [{ slug: "demo-channel", id: "tall111" }]); assert.deepEqual(res.plan.toFetch.items, [{ slug: "demo-channel", id: "fetch11" }]); assert.deepEqual(res.plan.noUrl.items, [{ slug: "bare-channel", id: "nourl11" }]); assert.deepEqual( res.plan.unknown.items.map((i) => `${i.slug}/${i.id}`), ["nope/xyz", "demo-channel/missing", "demo-channel/../escape"], ); assert.equal(res.plan.willFetch, 2); }); });