import path from "node:path"; import { lstat, readdir, rename, stat, symlink, unlink } from "node:fs/promises"; import type { Paths } from "../lib/paths"; import type { ChannelConfig } from "../lib/channelConfig"; import { channelExists, isValidChannelSlug, patchChannelConfig, writeChannelConfig, } from "./channels"; import { savedVideoRoot } from "../lib/savedVideo"; import { legacyDetail, readRelocationMarker } from "../lib/channelMedia"; import { MEDIA_LINK_NAME, relocatedMediaDir } from "../lib/mediaTier-server"; import { rewriteSavedVideoDir } from "../lib/savedVideo-server"; import { getSite, listSiteIds, writeSite } from "../lib/site"; import { readSchedulerState, writeSchedulerState, } from "../jobs/syncSchedulerState"; // Rename a channel's slug. Because the slug IS the on-disk directory name // (transcripts/channels//), 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 // //media convention, plus the `media` symlink and // config.mediaDir (the per-file links in `data//` are RELATIVE to // `media`, so they move with the channel dir and need nothing) // - site.json memberships across all sites // - the sync scheduler's per-channel backoff state // // The two filesystem moves (channel dir, then store dir) run first and roll back // on failure so a channel is never left half-renamed. The metadata updates that // follow are each individually atomic and best-effort: a failure there is // surfaced as a warning rather than aborting an already-completed directory move. // // In-memory job registry state is NOT migrated (it's process-local); callers // must block rename while a channel has running/queued jobs. export type RenameChannelResult = { // Non-fatal problems with the post-move metadata migrations. Empty on a clean // rename. The directory move itself always succeeded if this returns. warnings: string[]; }; async function pathExists(p: string): Promise { try { await stat(p); return true; } catch { return false; } } export async function renameChannel( paths: Paths, oldSlug: string, newSlug: string, config: ChannelConfig, ): Promise { if (!isValidChannelSlug(newSlug)) { throw new Error( `"${newSlug}" is not a valid slug (letters, digits, ".", "_", "-"; must start with a letter or digit)`, ); } if (oldSlug === newSlug) { throw new Error("The new slug is the same as the current one"); } if (!(await channelExists(paths, oldSlug))) { throw new Error(`Channel "${oldSlug}" not found`); } if (await channelExists(paths, newSlug)) { throw new Error(`Channel "${newSlug}" already exists`); } const oldChannelDir = path.join(paths.channelsDir, oldSlug); const newChannelDir = path.join(paths.channelsDir, newSlug); // channelExists only checks for config.json; a stray directory at the target // (no config) would make rename() nest instead of replace, so reject it too. if (await pathExists(newChannelDir)) { 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.`, ); } // THE RETIRED LAYOUT (release 17) is migrated before it is renamed: the // tier migration renames `//data` to `//media` and // reads `dataDir` as exactly that shape, so a rename that left the tree // under the old slug would break it. Refused before anything moves. let dataIsLink = false; try { dataIsLink = (await lstat(path.join(oldChannelDir, "data"))).isSymbolicLink(); } catch { /* no data/ */ } if (dataIsLink || config.dataDir?.trim()) { throw new Error( `Channel "${oldSlug}" cannot be renamed: ${legacyDetail(oldSlug)}.`, ); } const storeRoot = savedVideoRoot(paths, config); const oldStoreDir = path.join(storeRoot, oldSlug); const newStoreDir = path.join(storeRoot, newSlug); // 1. Move the channel directory. config.json, data/*, playlist, snapshot.json, // shard-*.json, the failed-* lists, and the saved-video.json pointer // sidecars all live under here and move together. await rename(oldChannelDir, newChannelDir); // 2. Move the saved-video store dir when present. On any failure, roll the // channel-dir move back so the operator can retry from a clean state. const hadStore = await pathExists(oldStoreDir); if (hadStore) { try { if (await pathExists(newStoreDir)) { throw new Error( `A saved-video store already exists at ${newStoreDir}`, ); } await rename(oldStoreDir, newStoreDir); } catch (err) { await rename(newChannelDir, oldChannelDir).catch(() => {}); if ((err as NodeJS.ErrnoException).code === "EXDEV") { throw new Error( `Cannot rename across filesystems: the saved-video store at ${oldStoreDir} is on a different device. Move it manually, then retry.`, ); } throw err; } } // 3. Move the relocated media dir when it follows the //media // convention, and re-point the `media` 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.mediaDir?.trim(); const conventional = relocated && relocated === relocatedMediaDir(path.dirname(path.dirname(relocated)), oldSlug) ? relocated : null; if (conventional) { const mediaRoot = path.dirname(path.dirname(conventional)); const newTarget = relocatedMediaDir(mediaRoot, newSlug); const link = path.join(newChannelDir, MEDIA_LINK_NAME); // A ROLLBACK MUST ONLY UNDO WHAT ACTUALLY RAN. The pre-check below fails // BECAUSE something unrelated already occupies / — and the // old catch then renamed that stranger to /, destroying a // directory this function had never touched, in the name of undoing a move // it had not made. Each step records that it happened; the catch replays // only those, in reverse. let movedMedia = false; // THE UNLINK IS A STEP TOO. It was untracked, so a throw from the symlink // below (EACCES on a read-only channel dir, ENOSPC) rolled the media // directory back while the link stayed DELETED — config still naming the old // target, nothing on disk pointing at it: `inconsistent`, which is now a // state move-back refuses. The catch replays only what ran, and removing the // link ran. let unlinked = false; let relinked = false; 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)); movedMedia = true; await unlink(link) .then(() => { unlinked = true; }) .catch(() => {}); await symlink(newTarget, link); relinked = true; // Re-read: the channel dir has already moved, so this is the file that // will actually be on disk afterwards. const patched = await patchChannelConfig(paths, newSlug, { mediaDir: newTarget, }); if (!patched) { // No readable config.json at the new slug: write the one this rename // started from, so the moved media is not left unrecorded. await writeChannelConfig(paths, newSlug, { ...config, mediaDir: newTarget }); } } catch (err) { // The link goes back too, and before the directory under it moves: a // failure between the symlink and the config write left `media` // pointing at //media while everything else was rolled // back to the old slug — a dangling link, which reads as an unmounted // drive. if (relinked || unlinked) { await unlink(link).catch(() => {}); await symlink(conventional, link).catch(() => {}); } if (movedMedia) { 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[] = []; // 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) { const dataDir = path.join(newChannelDir, "data"); const ids = await readdir(dataDir).catch(() => [] as string[]); let failed = 0; for (const id of ids) { try { await rewriteSavedVideoDir( path.join(dataDir, id), path.join(newStoreDir, id), ); } catch { failed++; } } if (failed > 0) { warnings.push( `${failed} saved-video pointer(s) could not be updated; their persisted source videos may be unresolvable.`, ); } } // 5. Rewrite site memberships that reference the old slug. try { for (const siteId of listSiteIds(paths)) { const site = getSite(siteId, paths); if (!site.channels.some((c) => c.slug === oldSlug)) continue; const channels = site.channels.map((c) => c.slug === oldSlug ? { ...c, slug: newSlug } : c, ); await writeSite({ ...site, channels }, paths); } } catch (err) { warnings.push(`Site membership update failed: ${(err as Error).message}`); } // 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); const entry = state.channels[oldSlug]; if (entry) { state.channels[newSlug] = entry; delete state.channels[oldSlug]; await writeSchedulerState(paths, state); } } catch (err) { warnings.push( `Scheduler state migration failed: ${(err as Error).message}`, ); } return { warnings }; }