import test from "node:test"; import assert from "node:assert/strict"; import path from "node:path"; import os from "node:os"; import { mkdtemp, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises"; import { reconcileVideoDirs } from "./reconcileVideoDirs"; // Reconciling a video dir whose name is not its canonical id, when BOTH sides // hold a `clips/`. // // This case has its own test because `clips/` is the first DIRECTORY the merge // ever had to move, and the merge is written for files. The first version // removed the emptied source with rm() and no `recursive`, which throws EISDIR // on a directory; the catch swallowed it, the source `clips/` survived, and the // rmdir(srcDir) that follows then failed ENOTEMPTY — so a merge that had in // fact worked was reported as a "source dir not empty" conflict and the whole // video dir was left split in two. const VIDEO_URL = "https://www.youtube.com/watch?v=canonical01"; async function makeChannel(): Promise { const root = await mkdtemp(path.join(os.tmpdir(), "reconcile-clips-")); const dataDir = path.join(root, "data"); await mkdir(dataDir, { recursive: true }); return root; } // A video dir whose name differs from the canonical id in its metadata, so // reconcile wants to merge it into `canonical01`. async function writeStrayDir( channelDir: string, name: string, files: Record, ): Promise { const dir = path.join(channelDir, "data", name); await mkdir(dir, { recursive: true }); await writeFile( path.join(dir, "metadata.info.json"), JSON.stringify({ webpage_url: VIDEO_URL }), ); for (const [rel, body] of Object.entries(files)) { const full = path.join(dir, rel); await mkdir(path.dirname(full), { recursive: true }); await writeFile(full, body); } return dir; } test("clips/ on both sides MERGES, and the source dir is actually removed", async (t) => { const channelDir = await makeChannel(); t.after(() => rm(channelDir, { recursive: true, force: true })); await writeStrayDir(channelDir, "canonical01", { "clips/0.00-10.00.mp4": "destination window", "clips/0.00-10.00.json": '{"requestedBy":"umtool"}', }); await writeStrayDir(channelDir, "v-canonical01", { "clips/20.00-30.00.mp4": "source window", "audio.mp3": "source audio", }); const result = await reconcileVideoDirs({ channelDir }); // A MERGE, not a conflict — the whole point. assert.deepEqual(result.conflicts, []); assert.equal(result.merged.length, 1); assert.equal(result.merged[0].from, "v-canonical01"); assert.equal(result.merged[0].to, "canonical01"); // The window is reported under its path inside clips/, not as a bare name. assert.ok( result.merged[0].movedFiles.includes(path.join("clips", "20.00-30.00.mp4")), JSON.stringify(result.merged[0].movedFiles), ); // The source dir is gone, which is what the EISDIR bug prevented. const dirs = await readdir(path.join(channelDir, "data")); assert.deepEqual(dirs.sort(), ["canonical01"]); // BOTH halves of the cache survive: one directory, both windows. const clips = await readdir(path.join(channelDir, "data", "canonical01", "clips")); assert.deepEqual(clips.sort(), [ "0.00-10.00.json", "0.00-10.00.mp4", "20.00-30.00.mp4", ]); }); test("a window present on both sides keeps the source bytes and stashes the other", async (t) => { const channelDir = await makeChannel(); t.after(() => rm(channelDir, { recursive: true, force: true })); await writeStrayDir(channelDir, "canonical01", { "clips/5.00-15.00.mp4": "destination bytes", }); await writeStrayDir(channelDir, "v-canonical01", { "clips/5.00-15.00.mp4": "source bytes", }); const result = await reconcileVideoDirs({ channelDir }); assert.deepEqual(result.conflicts, []); assert.equal(result.merged.length, 1); const clipsDir = path.join(channelDir, "data", "canonical01", "clips"); const clips = await readdir(clipsDir); // The same rule the file case uses: the source's bytes win, the canonical // copy is stashed rather than destroyed. assert.deepEqual(clips.sort(), [ "5.00-15.00.mp4", "5.00-15.00.mp4.dup-v-canonical01", ]); assert.equal( await readFile(path.join(clipsDir, "5.00-15.00.mp4"), "utf8"), "source bytes", ); assert.equal( await readFile( path.join(clipsDir, "5.00-15.00.mp4.dup-v-canonical01"), "utf8", ), "destination bytes", ); assert.deepEqual(await readdir(path.join(channelDir, "data")), ["canonical01"]); }); test("clips/ only on the source side moves whole, as any other entry would", async (t) => { const channelDir = await makeChannel(); t.after(() => rm(channelDir, { recursive: true, force: true })); await writeStrayDir(channelDir, "canonical01", { "audio.mp3": "dst" }); await writeStrayDir(channelDir, "v-canonical01", { "clips/1.00-2.00.mp4": "only source has windows", }); const result = await reconcileVideoDirs({ channelDir }); assert.deepEqual(result.conflicts, []); assert.equal(result.merged.length, 1); assert.deepEqual( await readdir(path.join(channelDir, "data", "canonical01", "clips")), ["1.00-2.00.mp4"], ); assert.deepEqual(await readdir(path.join(channelDir, "data")), ["canonical01"]); }); test("a dry run reports the same merge and moves nothing", async (t) => { const channelDir = await makeChannel(); t.after(() => rm(channelDir, { recursive: true, force: true })); await writeStrayDir(channelDir, "canonical01", { "clips/0.00-10.00.mp4": "dst", }); await writeStrayDir(channelDir, "v-canonical01", { "clips/20.00-30.00.mp4": "src", }); const result = await reconcileVideoDirs({ channelDir, dryRun: true }); assert.deepEqual(result.conflicts, []); assert.equal(result.merged.length, 1); assert.deepEqual( (await readdir(path.join(channelDir, "data"))).sort(), ["canonical01", "v-canonical01"], ); assert.deepEqual( await readdir(path.join(channelDir, "data", "v-canonical01", "clips")), ["20.00-30.00.mp4"], ); });