Archilyzer · Source

archilyzer

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

commit 3d4991c941b01725a60581780a4cbc7ca5144ccf
parent 513f3466c9ca304217c6e01bcdfe79ec4e658864
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Fri, 11 Sep 2026 11:16:01 -0400

common: delete and rename reach the other drive

`rm -r` on a channel dir removes a symlinked `data/` as a LINK — node's rm does
not follow links — so deleting a relocated channel would leave its media on the
platter as an orphan nothing in the app can see or reclaim. `deleteChannel` now
removes `config.dataDir` first, and `<root>/<slug>` with it when that directory
holds nothing else of the operator's. An unmounted target is a no-op rather than
a refusal: a channel whose drive is gone for good has to stay deletable. A
`.relocating.json` marker IS a refusal — deleting mid-copy is the one case that
can destroy data.

`renameChannel` gains a fourth migration beside the store dir, the site
memberships and the scheduler state: a target following the `<root>/<slug>/data`
convention is renamed to `<root>/<newSlug>/data`, the symlink re-created and
`config.dataDir` rewritten, rolling the channel-dir and store moves back on
failure exactly as step 2 does, with the same EXDEV message. A target that does
NOT follow the convention is deliberately left alone: the link is absolute and
keeps working, and renaming a directory because its name happened to match would
be worse than leaving it. Rename is refused outright while a marker is present.

`controller/channels.test.ts` is new — deleteChannel had no test at all.

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

Diffstat:
Acommon/controller/channels.test.ts | 132+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/controller/channels.ts | 30++++++++++++++++++++++++++++++
Mcommon/controller/renameChannel.test.ts | 126++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcommon/controller/renameChannel.ts | 77++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
4 files changed, 359 insertions(+), 6 deletions(-)

diff --git a/common/controller/channels.test.ts b/common/controller/channels.test.ts @@ -0,0 +1,132 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + mkdir, + mkdtemp, + 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 { + relocatedDataDir, + RELOCATION_MARKER_FILENAME, +} from "../lib/channelMedia"; +import { channelExists, deleteChannel, writeChannelConfig } from "./channels"; + +// Run with: +// pnpm --filter yt-dlp-transcript-common exec tsx --test controller/channels.test.ts +// +// Deleting a RELOCATED channel is the one place `rm -r` on the channel dir is +// not the whole job: node's rm does not follow symlinks, so it takes the link +// and leaves the media behind as an orphan nothing in the app can see. + +const config: ChannelConfig = { handling: "youtube", name: "A channel" }; + +async function withPaths( + fn: (paths: Paths, mediaRoot: string) => Promise<void>, +): Promise<void> { + const dir = await mkdtemp(path.join(tmpdir(), "ttb-channels-")); + const paths = { channelsDir: path.join(dir, "channels") } as Paths; + const mediaRoot = path.join(dir, "platter"); + try { + await fn(paths, mediaRoot); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +async function there(p: string): Promise<boolean> { + try { + await stat(p); + return true; + } catch { + return false; + } +} + +test("deleting an in-place channel removes its directory", async () => { + await withPaths(async (paths) => { + await writeChannelConfig(paths, "alpha", config); + await mkdir(path.join(paths.channelsDir, "alpha", "data", "v1"), { + recursive: true, + }); + await deleteChannel(paths, "alpha"); + assert.equal(await channelExists(paths, "alpha"), false); + }); +}); + +test("deleting a relocated channel reclaims the media on the other drive", async () => { + await withPaths(async (paths, mediaRoot) => { + const target = relocatedDataDir(mediaRoot, "alpha"); + await writeChannelConfig(paths, "alpha", { ...config, dataDir: target }); + await mkdir(path.join(target, "v1"), { recursive: true }); + await writeFile(path.join(target, "v1", "audio.m4a"), "BYTES"); + await symlink(target, path.join(paths.channelsDir, "alpha", "data")); + + await deleteChannel(paths, "alpha"); + + assert.equal(await channelExists(paths, "alpha"), false); + assert.equal(await there(target), false); + // <root>/<slug> goes too, once it holds nothing. + assert.equal(await there(path.join(mediaRoot, "alpha")), false); + // ...but the root itself is the operator's and is never touched. + assert.equal(await there(mediaRoot), true); + }); +}); + +test("a non-empty <root>/<slug> survives the delete", async () => { + await withPaths(async (paths, mediaRoot) => { + const target = relocatedDataDir(mediaRoot, "alpha"); + await writeChannelConfig(paths, "alpha", { ...config, dataDir: target }); + await mkdir(target, { recursive: true }); + await symlink(target, path.join(paths.channelsDir, "alpha", "data")); + // Something of the operator's, alongside the data dir we own. + await writeFile(path.join(mediaRoot, "alpha", "NOTES.txt"), "mine"); + + await deleteChannel(paths, "alpha"); + assert.equal(await there(target), false); + assert.equal(await there(path.join(mediaRoot, "alpha", "NOTES.txt")), true); + }); +}); + +test("deleting is refused while a relocation is in flight", async () => { + await withPaths(async (paths, mediaRoot) => { + const target = relocatedDataDir(mediaRoot, "alpha"); + await writeChannelConfig(paths, "alpha", config); + await mkdir(path.join(paths.channelsDir, "alpha", "data"), { + recursive: true, + }); + await writeFile( + path.join(paths.channelsDir, "alpha", RELOCATION_MARKER_FILENAME), + JSON.stringify({ + target, + direction: "out", + startedAt: new Date().toISOString(), + phase: "copy", + }), + ); + await assert.rejects( + () => deleteChannel(paths, "alpha"), + /relocation in progress/, + ); + assert.equal(await channelExists(paths, "alpha"), true); + }); +}); + +test("an unmounted target does not make the channel undeletable", async () => { + await withPaths(async (paths, mediaRoot) => { + // `force: true` makes a missing target a no-op, so the channel goes and its + // media stays on the unmounted platter. Deliberate: refusing here would + // make a channel whose drive is gone for good impossible to remove. + const target = relocatedDataDir(mediaRoot, "alpha"); + await writeChannelConfig(paths, "alpha", { ...config, dataDir: target }); + await symlink(target, path.join(paths.channelsDir, "alpha", "data")); + await deleteChannel(paths, "alpha"); + assert.equal(await channelExists(paths, "alpha"), false); + }); +}); diff --git a/common/controller/channels.ts b/common/controller/channels.ts @@ -13,6 +13,7 @@ import { readVideoFiles, } from "../lib/videoStatus"; import { loadDigest } from "../lib/digest-server"; +import { readRelocationMarker } from "../lib/channelMedia"; // TYPE-ONLY, and it must stay that way: ./channelSnapshot imports // readChannelConfig from this module, and it drags in the snapshot generator's // whole dependency graph (lmdb, the archive reader, the digest layer). A value @@ -473,10 +474,39 @@ export async function createChannel( await writeChannelConfig(paths, slug, config); } +// DELETING A RELOCATED CHANNEL HAS TO REACH THE OTHER DRIVE. `rm -r` on the +// channel dir removes the symlink, not what it points at (node's rm does not +// follow links), so without this the media would survive the channel as an +// orphan nothing in the app can see or reclaim. The target is removed FIRST, so +// a target that cannot be removed for a reason OTHER than being absent takes +// the delete down with it and leaves the channel intact and retryable. An +// unmounted drive is NOT that case — `force: true` makes a missing path a +// no-op, and the channel is deleted with its media left on the platter. That is +// deliberate: refusing would make a channel whose drive is gone for good +// undeletable, and the marker check below is the refusal that matters. export async function deleteChannel( paths: Paths, slug: string, ): Promise<void> { + const marker = await readRelocationMarker(paths, slug); + if (marker) { + throw new Error( + `Channel "${slug}" has a media relocation in progress (phase ` + + `"${marker.phase}", target ${marker.target}). Finish or cancel it ` + + `before deleting the channel.`, + ); + } const dir = path.join(paths.channelsDir, slug); + const target = (await readChannelConfig(paths, slug))?.dataDir?.trim(); + if (target) { + await rm(target, { recursive: true, force: true }); + // <root>/<slug> is ours by construction (relocatedDataDir fixes the + // suffix), so take it too — but only when nothing else landed in it. + const slugRoot = path.dirname(target); + if (path.basename(slugRoot) === slug) { + const left = await readdir(slugRoot).catch(() => ["keep"]); + if (left.length === 0) await rm(slugRoot, { recursive: true, force: true }); + } + } await rm(dir, { recursive: true, force: true }); } diff --git a/common/controller/renameChannel.test.ts b/common/controller/renameChannel.test.ts @@ -1,6 +1,15 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { mkdir, mkdtemp, rm, stat, writeFile } from "node:fs/promises"; +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"; @@ -22,6 +31,11 @@ import { writeSchedulerState, } from "../jobs/syncSchedulerState"; import { renameChannel } from "./renameChannel"; +import { + inspectChannelMedia, + relocatedDataDir, + RELOCATION_MARKER_FILENAME, +} from "../lib/channelMedia"; // Run with: // pnpm --filter yt-dlp-transcript-common exec tsx --test controller/renameChannel.test.ts @@ -133,3 +147,113 @@ test("renameChannel rejects invalid, same, and existing targets", async () => { 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 = relocatedDataDir(mediaRoot, "old"); + await writeChannelConfig(paths, "old", { ...config, dataDir: 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", "data")); + + const result = await renameChannel(paths, "old", "new", { + ...config, + dataDir: target, + }); + assert.deepEqual(result.warnings, []); + + const newTarget = relocatedDataDir(mediaRoot, "new"); + assert.equal((await readChannelConfig(paths, "new"))?.dataDir, newTarget); + assert.equal( + await readlink(path.join(paths.channelsDir, "new", "data")), + newTarget, + ); + // The media reads through the new link at the old on-disk contract path. + 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 <root>/<slug> 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); + // <root>/<something-else>/data — 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", "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 renameChannel(paths, "old", "new", { ...config, dataDir: target }); + + assert.equal((await readChannelConfig(paths, "new"))?.dataDir, target); + assert.equal( + await readlink(path.join(paths.channelsDir, "new", "data")), + 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 = relocatedDataDir(mediaRoot, "old"); + await writeChannelConfig(paths, "old", { ...config, dataDir: target }); + await mkdir(path.join(target, "vid1"), { recursive: true }); + await symlink(target, path.join(paths.channelsDir, "old", "data")); + // Something is already sitting at <root>/new. + await mkdir(path.join(mediaRoot, "new"), { recursive: true }); + + await assert.rejects( + () => renameChannel(paths, "old", "new", { ...config, dataDir: 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", "data")), + target, + ); + 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/data", + 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); + }); +}); diff --git a/common/controller/renameChannel.ts b/common/controller/renameChannel.ts @@ -1,9 +1,18 @@ import path from "node:path"; -import { readdir, rename, stat } from "node:fs/promises"; +import { readdir, rename, stat, symlink, unlink } from "node:fs/promises"; import type { Paths } from "../lib/paths"; import type { ChannelConfig } from "../lib/channelConfig"; -import { channelExists, isValidChannelSlug } from "./channels"; +import { + channelExists, + isValidChannelSlug, + readChannelConfig, + writeChannelConfig, +} from "./channels"; import { savedVideoRoot } from "../lib/savedVideo"; +import { + readRelocationMarker, + relocatedDataDir, +} from "../lib/channelMedia"; import { rewriteSavedVideoDir } from "../lib/savedVideo-server"; import { getSite, listSiteIds, writeSite } from "../lib/site"; import { @@ -15,6 +24,8 @@ import { // (transcripts/channels/<slug>/), this moves the channel directory AND migrates // every other store that keys by slug and would otherwise be orphaned: // - the saved-video store dir + each saved-video.json pointer's absolute `dir` +// - a relocated media dir on another drive, when it follows the +// <root>/<slug>/data convention, plus the symlink and config.dataDir // - site.json memberships across all sites // - the sync scheduler's per-channel backoff state // @@ -70,6 +81,16 @@ export async function renameChannel( throw new Error(`A directory already exists at channels/${newSlug}`); } + // A rename mid-relocation would move the channel dir out from under a running + // copy and leave the marker pointing at a target named for the old slug. + const marker = await readRelocationMarker(paths, oldSlug); + if (marker) { + throw new Error( + `Channel "${oldSlug}" has a media relocation in progress (phase ` + + `"${marker.phase}"). Finish or cancel it before renaming.`, + ); + } + const storeRoot = savedVideoRoot(paths, config); const oldStoreDir = path.join(storeRoot, oldSlug); const newStoreDir = path.join(storeRoot, newSlug); @@ -101,9 +122,55 @@ export async function renameChannel( } } + // 3. Move the relocated media dir when it follows the <root>/<slug>/data + // convention, and re-point the symlink at it. The link is ABSOLUTE, so a + // target that does NOT follow the convention is deliberately left alone — + // it still works, and moving someone else's directory because its name + // happened to match would be worse than leaving it. Rolls the channel-dir + // (and store) move back on failure, same shape as step 2. + const relocated = config.dataDir?.trim(); + const conventional = + relocated && relocated === relocatedDataDir(path.dirname(path.dirname(relocated)), oldSlug) + ? relocated + : null; + if (conventional) { + const mediaRoot = path.dirname(path.dirname(conventional)); + const newTarget = relocatedDataDir(mediaRoot, newSlug); + try { + if (await pathExists(path.dirname(newTarget))) { + throw new Error(`A media directory already exists at ${path.dirname(newTarget)}`); + } + await rename(path.dirname(conventional), path.dirname(newTarget)); + await unlink(path.join(newChannelDir, "data")).catch(() => {}); + await symlink(newTarget, path.join(newChannelDir, "data")); + // Re-read: the channel dir has already moved, so this is the file that + // will actually be on disk afterwards. + const fresh = (await readChannelConfig(paths, newSlug)) ?? config; + await writeChannelConfig(paths, newSlug, { + ...fresh, + dataDir: newTarget, + }); + } catch (err) { + await rename(path.dirname(newTarget), path.dirname(conventional)).catch( + () => {}, + ); + if (hadStore) { + await rename(newStoreDir, oldStoreDir).catch(() => {}); + } + await rename(newChannelDir, oldChannelDir).catch(() => {}); + if ((err as NodeJS.ErrnoException).code === "EXDEV") { + throw new Error( + `Cannot rename across filesystems: the relocated media at ${conventional} ` + + `is on a different device from its own root. Move it manually, then retry.`, + ); + } + throw err; + } + } + const warnings: string[] = []; - // 3. Repoint each saved-video.json at the moved store dir. The pointer stores + // 4. Repoint each saved-video.json at the moved store dir. The pointer stores // an absolute `dir` that includes the slug, and resolveSavedVideo trusts it // verbatim, so a stale `dir` makes persisted source videos unresolvable. if (hadStore) { @@ -127,7 +194,7 @@ export async function renameChannel( } } - // 4. Rewrite site memberships that reference the old slug. + // 5. Rewrite site memberships that reference the old slug. try { for (const siteId of listSiteIds(paths)) { const site = getSite(siteId, paths); @@ -141,7 +208,7 @@ export async function renameChannel( warnings.push(`Site membership update failed: ${(err as Error).message}`); } - // 5. Move the scheduler's per-channel backoff entry so auto-sync state carries + // 6. Move the scheduler's per-channel backoff entry so auto-sync state carries // over (the historical run log is left as-is — it's observability only). try { const state = await readSchedulerState(paths);