import { test } from "node:test"; import assert from "node:assert/strict"; import { mkdir, mkdtemp, rm, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import type { Paths } from "../lib/paths"; import { loadDoNotClean, setDoNotClean } from "../lib/doNotClean-server"; import { KeepVideosError, keepVideosMatching } from "./keepVideosMatching"; // Run with: // pnpm --filter yt-dlp-transcript-common exec tsx --test controller/keepVideosMatching.test.ts const SLUG = "ch"; type Seed = { // id -> [title, description] written to data//metadata.info.json dirs?: Record; // id -> [title, description] written to metadata-scan.json only scanned?: Record; // bare ids written to the playlist file playlist?: string[]; }; async function withChannel( seed: Seed, fn: (paths: Paths, channelDir: string) => Promise, ): Promise { const root = await mkdtemp(path.join(tmpdir(), "ttb-keep-videos-")); const paths = { channelsDir: path.join(root, "channels") } as Paths; const channelDir = path.join(paths.channelsDir, SLUG); try { await mkdir(path.join(channelDir, "data"), { recursive: true }); await writeFile( path.join(channelDir, "config.json"), JSON.stringify({ name: "Ch", url: "https://www.youtube.com/@ch" }), ); for (const [id, [title, description]] of Object.entries(seed.dirs ?? {})) { const dir = path.join(channelDir, "data", id); await mkdir(dir, { recursive: true }); await writeFile( path.join(dir, "metadata.info.json"), JSON.stringify({ id, title, description }), ); } const entries: Record = {}; for (const [id, [title, description]] of Object.entries(seed.scanned ?? {})) { entries[id] = { title, description, uploadDate: "20240101", scannedAt: "2026-09-25T00:00:00.000Z", }; } await writeFile( path.join(channelDir, "metadata-scan.json"), JSON.stringify({ version: 1, updatedAt: "", lastRun: null, entries, errors: {} }), ); await writeFile( path.join(channelDir, "playlist"), (seed.playlist ?? []) .map((id) => `https://www.youtube.com/watch?v=${id}`) .join("\n"), ); await fn(paths, channelDir); } finally { await rm(root, { recursive: true, force: true }); } } const dirOf = (channelDir: string, id: string) => path.join(channelDir, "data", id); test("a title match and a description-only match are both marked, case-insensitively", async () => { await withChannel( { dirs: { aaaaaaaaaaa: ["Reacting to THEQUARTERING", "nothing here"], bbbbbbbbbbb: ["Some other video", "featuring thequartering at 10:00"], ccccccccccc: ["Unrelated", "unrelated"], }, }, async (paths, channelDir) => { const r = await keepVideosMatching({ paths, channelSlug: SLUG, pattern: "TheQuartering", }); assert.equal(r.considered, 3); assert.equal(r.marked, 2); assert.deepEqual( r.matched.map((m) => [m.id, m.marked, m.downloaded]), [ ["aaaaaaaaaaa", true, true], ["bbbbbbbbbbb", true, true], ], ); const rec = await loadDoNotClean(dirOf(channelDir, "aaaaaaaaaaa")); assert.equal(rec?.note, "keep-videos: matched /TheQuartering/i"); assert.equal(await loadDoNotClean(dirOf(channelDir, "ccccccccccc")), null); }, ); }); test("an already-kept video is counted and not rewritten", async () => { await withChannel( { dirs: { aaaaaaaaaaa: ["TheQuartering live", ""] } }, async (paths, channelDir) => { const dir = dirOf(channelDir, "aaaaaaaaaaa"); await setDoNotClean(dir, true, "by hand"); const markerFile = path.join(dir, "do-not-clean.json"); const before = (await stat(markerFile)).mtimeMs; await new Promise((r) => setTimeout(r, 20)); const r = await keepVideosMatching({ paths, channelSlug: SLUG, pattern: "thequartering", }); assert.equal(r.marked, 0); assert.equal(r.alreadyKept, 1); assert.deepEqual(r.matched[0], { id: "aaaaaaaaaaa", title: "TheQuartering live", downloaded: true, alreadyKept: true, marked: false, }); assert.equal((await stat(markerFile)).mtimeMs, before); assert.equal((await loadDoNotClean(dir))?.note, "by hand"); }, ); }); test("a scan-only match is reported notDownloaded and no dir is created; unscanned counts the gap", async () => { await withChannel( { scanned: { sssssssssss: ["TheQuartering clip", ""] }, playlist: ["sssssssssss", "uuuuuuuuuuu", "vvvvvvvvvvv"], }, async (paths, channelDir) => { const r = await keepVideosMatching({ paths, channelSlug: SLUG, pattern: "thequartering", }); assert.deepEqual(r.notDownloaded, ["sssssssssss"]); assert.equal(r.marked, 0); assert.equal(r.matched[0].downloaded, false); assert.equal(r.unscanned, 2); await assert.rejects(stat(dirOf(channelDir, "sssssssssss")), { code: "ENOENT", }); }, ); }); test("a dry run reports the matches and writes nothing", async () => { await withChannel( { dirs: { aaaaaaaaaaa: ["TheQuartering", ""] } }, async (paths, channelDir) => { const r = await keepVideosMatching({ paths, channelSlug: SLUG, pattern: "thequartering", dryRun: true, }); assert.equal(r.dryRun, true); assert.equal(r.matched.length, 1); assert.equal(r.matched[0].marked, false); assert.equal(r.marked, 0); assert.equal(await loadDoNotClean(dirOf(channelDir, "aaaaaaaaaaa")), null); }, ); }); test('fields: ["title"] ignores a description hit', async () => { await withChannel( { dirs: { aaaaaaaaaaa: ["Plain title", "TheQuartering in the description"], bbbbbbbbbbb: ["TheQuartering in the title", ""], }, }, async (paths) => { const r = await keepVideosMatching({ paths, channelSlug: SLUG, pattern: "thequartering", fields: ["title"], }); assert.deepEqual(r.fields, ["title"]); assert.deepEqual( r.matched.map((m) => m.id), ["bbbbbbbbbbb"], ); }, ); }); test("a bad pattern, an unknown field and an unknown channel throw the typed error", async () => { await withChannel({ dirs: {} }, async (paths) => { await assert.rejects( keepVideosMatching({ paths, channelSlug: SLUG, pattern: "(" }), (e) => e instanceof KeepVideosError && /invalid pattern/.test(e.message), ); await assert.rejects( keepVideosMatching({ paths, channelSlug: SLUG, pattern: "(\\w+\\s?)*$" }), (e) => e instanceof KeepVideosError && /nested quantifier/.test(e.message), ); await assert.rejects( keepVideosMatching({ paths, channelSlug: SLUG, pattern: "x", fields: ["tags" as "title"], }), KeepVideosError, ); await assert.rejects( keepVideosMatching({ paths, channelSlug: "nope", pattern: "x" }), (e) => e instanceof KeepVideosError && /not found/.test(e.message), ); }); });