import { test } from "node:test"; import assert from "node:assert/strict"; import { mkdir, mkdtemp, readFile, readlink, rm, stat, symlink, 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 { channelExists, readChannelConfig, writeChannelConfig, } from "./channels"; import { savedVideoDir } from "../lib/savedVideo"; import { persistSourceVideo, resolveSavedVideo, } from "../lib/savedVideo-server"; import { getSite } from "../lib/site"; import { emptyChannelSyncState, readSchedulerState, writeSchedulerState, } from "../jobs/syncSchedulerState"; import { renameChannel } from "./renameChannel"; import { inspectChannelMedia, RELOCATION_MARKER_FILENAME, } from "../lib/channelMedia"; import { relocatedMediaDir, tierLinkTarget } from "../lib/mediaTier-server"; // Run with: // pnpm --filter yt-dlp-transcript-common exec tsx --test controller/renameChannel.test.ts async function withPaths(fn: (paths: Paths) => Promise): Promise { const dir = await mkdtemp(path.join(tmpdir(), "ttb-rename-")); const paths = { channelsDir: path.join(dir, "channels"), savedVideosDir: path.join(dir, "saved"), sitesDir: path.join(dir, "sites"), schedulerStateFile: path.join(dir, ".scheduler", "state.json"), } as Paths; try { await fn(paths); } finally { await rm(dir, { recursive: true, force: true }); } } const config: ChannelConfig = { handling: "youtube", name: "Old Name" }; // Seed a channel "old" with one video that has a persisted saved source, plus a // site membership and a scheduler backoff entry — all keyed by the old slug. async function seedOld(paths: Paths): Promise { await writeChannelConfig(paths, "old", config); const videoDir = path.join(paths.channelsDir, "old", "data", "vid1"); await mkdir(videoDir, { recursive: true }); await writeFile( path.join(videoDir, "metadata.info.json"), JSON.stringify({ id: "vid1", title: "A video" }), ); await writeFile(path.join(videoDir, "source-media.mp4"), "SOURCEBYTES"); await persistSourceVideo({ videoDir, sourceFilename: "source-media.mp4", storeDir: savedVideoDir(paths, config, "old", "vid1"), keepReason: "pin", }); await mkdir(path.join(paths.sitesDir, "mysite"), { recursive: true }); await writeFile( path.join(paths.sitesDir, "mysite", "site.json"), JSON.stringify({ channels: [{ slug: "old" }] }), ); const state = await readSchedulerState(paths); state.channels["old"] = { ...emptyChannelSyncState(), consecutiveFailures: 5 }; await writeSchedulerState(paths, state); } test("renameChannel migrates every slug-keyed store", async () => { await withPaths(async (paths) => { await seedOld(paths); const result = await renameChannel(paths, "old", "new", config); assert.deepEqual(result.warnings, []); // Channel directory moved. assert.equal(await channelExists(paths, "old"), false); assert.equal(await channelExists(paths, "new"), true); assert.equal((await readChannelConfig(paths, "new"))?.name, "Old Name"); await stat(path.join(paths.channelsDir, "new", "data", "vid1", "metadata.info.json")); // Saved-video store moved and the pointer rewritten so it resolves. const newVideoDir = path.join(paths.channelsDir, "new", "data", "vid1"); const resolved = await resolveSavedVideo(newVideoDir); assert.equal( resolved, path.join(paths.savedVideosDir, "new", "vid1", "source-media.mp4"), ); await assert.rejects(() => stat(path.join(paths.savedVideosDir, "old"))); // Site membership retargeted. assert.deepEqual( getSite("mysite", paths).channels.map((c) => c.slug), ["new"], ); // Scheduler backoff carried over under the new key. const state = await readSchedulerState(paths); assert.equal(state.channels["old"], undefined); assert.equal(state.channels["new"]?.consecutiveFailures, 5); }); }); test("renameChannel rejects invalid, same, and existing targets", async () => { await withPaths(async (paths) => { await writeChannelConfig(paths, "old", config); await assert.rejects( () => renameChannel(paths, "old", "old", config), /same/, ); await assert.rejects( () => renameChannel(paths, "old", "bad/slug", config), /not a valid slug/, ); await assert.rejects( () => renameChannel(paths, "old", "..", config), /not a valid slug/, ); await writeChannelConfig(paths, "taken", config); await assert.rejects( () => renameChannel(paths, "old", "taken", config), /already exists/, ); // The failed rename must not have moved "old". assert.equal(await channelExists(paths, "old"), true); }); }); // --- relocated media ------------------------------------------------------ test("rename re-points a convention-shaped relocated media dir", async () => { await withPaths(async (paths) => { const dir = path.dirname(paths.channelsDir); const mediaRoot = path.join(dir, "platter"); const target = relocatedMediaDir(mediaRoot, "old"); await writeChannelConfig(paths, "old", { ...config, mediaDir: target }); await mkdir(path.join(target, "vid1"), { recursive: true }); await writeFile(path.join(target, "vid1", "audio.m4a"), "BYTES"); await symlink(target, path.join(paths.channelsDir, "old", "media")); // The tiered file's RELATIVE link in the real `data/` on the corpus disk. const videoDir = path.join(paths.channelsDir, "old", "data", "vid1"); await mkdir(videoDir, { recursive: true }); await symlink(tierLinkTarget("vid1", "audio.m4a"), path.join(videoDir, "audio.m4a")); const result = await renameChannel(paths, "old", "new", { ...config, mediaDir: target, }); assert.deepEqual(result.warnings, []); const newTarget = relocatedMediaDir(mediaRoot, "new"); assert.equal((await readChannelConfig(paths, "new"))?.mediaDir, newTarget); assert.equal( await readlink(path.join(paths.channelsDir, "new", "media")), newTarget, ); // The per-file link is untouched — relative, it moved with the channel — // and the media reads through it at the on-disk contract path. assert.equal( await readlink(path.join(paths.channelsDir, "new", "data", "vid1", "audio.m4a")), tierLinkTarget("vid1", "audio.m4a"), ); assert.equal( await readFile( path.join(paths.channelsDir, "new", "data", "vid1", "audio.m4a"), "utf8", ), "BYTES", ); assert.equal( (await inspectChannelMedia(paths, "new")).status, "ok", ); // The old / is gone, not left as a duplicate. await assert.rejects(() => stat(path.join(mediaRoot, "old"))); }); }); test("a media dir that does not follow the convention is left alone", async () => { await withPaths(async (paths) => { const dir = path.dirname(paths.channelsDir); // //media — the link is absolute and still works // after the rename, so moving a directory whose name is not ours would be // worse than leaving it. const target = path.join(dir, "platter", "handpicked", "media"); await writeChannelConfig(paths, "old", { ...config, mediaDir: target }); await mkdir(path.join(target, "vid1"), { recursive: true }); await symlink(target, path.join(paths.channelsDir, "old", "media")); await renameChannel(paths, "old", "new", { ...config, mediaDir: target }); assert.equal((await readChannelConfig(paths, "new"))?.mediaDir, target); assert.equal( await readlink(path.join(paths.channelsDir, "new", "media")), target, ); await stat(path.join(target, "vid1")); }); }); test("rename rolls the channel dir back when the media move fails", async () => { await withPaths(async (paths) => { const dir = path.dirname(paths.channelsDir); const mediaRoot = path.join(dir, "platter"); const target = relocatedMediaDir(mediaRoot, "old"); await writeChannelConfig(paths, "old", { ...config, mediaDir: target }); await mkdir(path.join(target, "vid1"), { recursive: true }); await symlink(target, path.join(paths.channelsDir, "old", "media")); // Something is already sitting at /new. await mkdir(path.join(mediaRoot, "new"), { recursive: true }); await assert.rejects( () => renameChannel(paths, "old", "new", { ...config, mediaDir: target }), /already exists/, ); // Nothing half-renamed: the channel is still "old", still linked, still // pointing at its media. assert.equal(await channelExists(paths, "old"), true); assert.equal(await channelExists(paths, "new"), false); assert.equal( await readlink(path.join(paths.channelsDir, "old", "media")), target, ); await stat(path.join(target, "vid1")); }); }); // THE RETIRED LAYOUT (release 17 review L2): a legacy channel is migrated // before it is renamed, so the tier migration only ever sees // `dataDir = //data`. test("a legacy channel's rename is refused, naming migrate-tier, and nothing moves", async () => { await withPaths(async (paths) => { const dir = path.dirname(paths.channelsDir); const target = path.join(dir, "platter", "old", "data"); await writeChannelConfig(paths, "old", { ...config, dataDir: target }); await mkdir(path.join(target, "vid1"), { recursive: true }); await symlink(target, path.join(paths.channelsDir, "old", "data")); await assert.rejects( () => renameChannel(paths, "old", "new", { ...config, dataDir: target }), /cannot be renamed: .*archilyzer storage migrate-tier old/, ); assert.equal(await channelExists(paths, "old"), true); assert.equal(await channelExists(paths, "new"), false); await stat(path.join(target, "vid1")); }); }); test("rename is refused while a relocation is in flight", async () => { await withPaths(async (paths) => { await writeChannelConfig(paths, "old", config); await writeFile( path.join(paths.channelsDir, "old", RELOCATION_MARKER_FILENAME), JSON.stringify({ target: "/mnt/platter/old/media", direction: "out", startedAt: new Date().toISOString(), phase: "copy", }), ); await assert.rejects( () => renameChannel(paths, "old", "new", config), /relocation in progress/, ); assert.equal(await channelExists(paths, "old"), true); }); });