// THE MEDIA TIER ON DISK — the hook that moves a finished media file out of // `data//` and leaves a link behind, and the one way to remove one. // // The layout (plans/release-17.md, "The model (A′)"): // // channels//data//audio.mp3 -> ../../media//audio.mp3 (a RELATIVE link) // channels//media a real directory on the corpus disk (tiered in place), // or ONE absolute symlink to //media (relocated), // or absent (classic: the media is real files in data//) // // Readers never change: they open `data//` and the kernel follows the // link. Writers change in exactly two ways, and both are here: // // - EVERY SITE THAT FINALISES A MEDIA FILE calls the hook (`tierMediaFile`, // `tierVideoDir`, `tierChannelMedia`) once it has renamed its temp into // place. yt-dlp's postprocessor and this app's transcode both `rename()` a // temp OVER the final name, which replaces a link with a real file — the // hook then moves that file into the tier, over the stale copy there. // - EVERY SITE THAT DELETES ONE calls `removeMediaFile` / `removeVideoDirMedia`. // A plain `rm` of `data//audio.mp3` removes the LINK and orphans the // bytes on the media drive, which no sweep would ever find again. // // THE HOOK NEVER THROWS INTO A DOWNLOAD. A classic channel (no `media/`), a // relocated one whose drive is unmounted (the link dangles), a stalled drive, // a full disk: the file stays a real file in `data//`, readers are // unaffected, and the next sweep that calls the hook tiers it. // // lib/, so no controller import (architecture.test.ts). import path from "node:path"; import { link, lstat, lutimes, mkdir, readdir, readlink, rename, rm, rmdir, stat, symlink, utimes, } from "node:fs/promises"; import type { Paths } from "./paths"; import { copyFileAtomic } from "./jsonFile-server"; import { isTierable } from "./mediaTier"; import { onDrive, stalledLocationForPath } from "./storageHealth"; // The one name a channel's media tier is reached by: `channels//media`. export const MEDIA_LINK_NAME = "media"; // channelMedia.ts's RELOCATION_MARKER_FILENAME, as a literal because that // module imports this one (mediaTier-server.test.ts pins that they agree). export const TIER_RELOCATION_MARKER = ".relocating.json"; export function channelMediaLink( paths: Pick, slug: string, ): string { return path.join(paths.channelsDir, slug, MEDIA_LINK_NAME); } // A relocated channel's media root: `//media`. The suffix is fixed, // not configurable, so an empty mountpoint can never be mistaken for the media // and the movers can recognise a target by its shape (the same reason the // retired `relocatedDataDir` fixed `/data`). export function relocatedMediaDir(root: string, slug: string): string { return path.join(root.trim(), slug, MEDIA_LINK_NAME); } // The link a tiered file leaves in `data//`: RELATIVE, so it survives a // channel rename, `reconcileVideoDirs`' renames of a video dir (the link and // its target move together only when the target's `` is renamed too — open // question 3 of the plan) and a move back, which makes `media/` a real // directory without touching a single link. export function tierLinkTarget(id: string, name: string): string { return path.join("..", "..", MEDIA_LINK_NAME, id, name); } // `data/` → `channels//media/`, by shape (the video dir is // always `channels//data/`). function mediaDirOfVideoDir(videoDir: string): string { const id = path.basename(videoDir); return path.join(path.dirname(path.dirname(videoDir)), MEDIA_LINK_NAME, id); } function errCode(err: unknown): string | undefined { return (err as NodeJS.ErrnoException | null)?.code; } // Test seam: the filesystem calls a test needs to fail on cue (an injected // `link` throwing EXDEV, the copy the fallback makes). export type TierFs = { link: (existing: string, linkPath: string) => Promise; copyFileAtomic: (src: string, dest: string) => Promise; // Called with ONE argument — never `{ recursive: true }` (below). mkdir: (dir: string) => Promise; }; const DEFAULT_FS: TierFs = { link: (a, b) => link(a, b), copyFileAtomic: (s, d) => copyFileAtomic(s, d), mkdir: (d) => mkdir(d), }; // A hard link cannot be made here: another filesystem (a relocated channel), // or one that has none. const NO_HARD_LINK = new Set(["EXDEV", "EPERM", "ENOTSUP", "EOPNOTSUPP", "EMLINK"]); async function markerStands(mediaRoot: string): Promise { try { await lstat(path.join(path.dirname(mediaRoot), TIER_RELOCATION_MARKER)); return true; } catch { return false; } } export type TierOptions = { onLog?: (line: string) => void; fs?: Partial; }; // Whether the channel's media tier can take a file now: `channels//media` // resolves to a directory, on a drive that is not known to be stalled and that // answers within the watchdog's budget. False for a classic channel (no // `media`), a dangling link (an unmounted drive), a stalled drive. async function mediaTierReady(mediaRoot: string): Promise { // A move of this channel's media in flight (or interrupted): its marker // stands in the channel dir, and nothing is written into `media/` while it // does — the file stays real and the next sweep tiers it. (The writers that // call the hook are held by the media guard during a move; this is the // backstop for one that started before the marker.) if (await markerStands(mediaRoot)) return false; let target = mediaRoot; try { const l = await lstat(mediaRoot); if (l.isSymbolicLink()) { target = path.resolve(path.dirname(mediaRoot), await readlink(mediaRoot)); if (stalledLocationForPath(target)) return false; } else { // A real `media/` is on the corpus disk: no drive, no watchdog slot. return l.isDirectory(); } } catch { return false; } try { const st = await onDrive(target, () => stat(mediaRoot)); return st.isDirectory(); } catch { return false; } } export type TierResult = "tiered" | "left" | "already"; // MOVE ONE FINISHED MEDIA FILE INTO THE TIER and leave a relative link behind. // // "already" — the name is a link already, or there is no such file; // "left" — it stays a real file (not tierable, no media tier, the drive is // not there or not answering, or a step failed and was undone); // "tiered" — the bytes are in `media//` and `data//` is // the link. // // `mkdir(media/)` is NOT recursive: `media/` was just seen to be a // directory, and a recursive mkdir aimed at a mountpoint that went away in // between would build the path on the root filesystem and fill it. // // THE NAME ALWAYS RESOLVES, crash or not. The bytes are put into the tier // WITHOUT touching the name — same filesystem: a hard link (instant, the same // inode), renamed into place; across filesystems (a relocated channel) or with // no hard links: an atomic copy, given the file's times so `stat` and `lstat` // agree — and only then does the temp link replace the name, in one rename. // A process killed anywhere leaves the real file under its name (and at worst // a stray temp link, which the next sweep removes). // // A MOVE THAT BEGAN MEANWHILE: the marker is asked again after the bytes land // and before the name changes; if one stands, the tier's copy is removed and // the file stays real. export async function tierMediaFile( videoDir: string, name: string, opts: TierOptions = {}, ): Promise { const fsx: TierFs = { ...DEFAULT_FS, ...opts.fs }; const file = path.join(videoDir, name); let st; try { st = await lstat(file); } catch { return "already"; } if (st.isSymbolicLink()) return "already"; if (!st.isFile() || !isTierable(name)) return "left"; const mediaRoot = path.dirname(mediaDirOfVideoDir(videoDir)); if (!(await mediaTierReady(mediaRoot))) return "left"; const id = path.basename(videoDir); const destDir = mediaDirOfVideoDir(videoDir); const dest = path.join(destDir, name); try { await fsx.mkdir(destDir); } catch (err) { if (errCode(err) !== "EEXIST") { opts.onLog?.(`[media tier] ${id}/${name} left in place: ${(err as Error).message}\n`); return "left"; } } const tmpLink = path.join(videoDir, `.${name}.tierlink-${process.pid}`); const destTmp = path.join(destDir, `.${name}.tiering-${process.pid}`); let placed = false; try { await rm(tmpLink, { force: true }); await symlink(tierLinkTarget(id, name), tmpLink); // The FILE's times on the LINK, so an `lstat` of the name answers what a // `stat` of the file did before it was tiered — the live-chat freshness // check and the index's sub-track key read it there, on the corpus disk, // instead of reaching the media drive. await lutimes(tmpLink, st.atime, st.mtime).catch(() => {}); await rm(destTmp, { force: true }); try { await fsx.link(file, destTmp); await rename(destTmp, dest); } catch (err) { await rm(destTmp, { force: true }).catch(() => {}); if (!NO_HARD_LINK.has(errCode(err) ?? "")) throw err; await fsx.copyFileAtomic(file, dest); await utimes(dest, st.atime, st.mtime).catch(() => {}); } placed = true; if (await markerStands(mediaRoot)) { throw new Error("a move of this channel's media began"); } await rename(tmpLink, file); return "tiered"; } catch (err) { await rm(tmpLink, { force: true }).catch(() => {}); // The name still holds the real file; the tier's copy goes. if (placed) await rm(dest, { force: true }).catch(() => {}); opts.onLog?.(`[media tier] ${id}/${name} left in place: ${(err as Error).message}\n`); return "left"; } } function processAlive(pid: number): boolean { if (pid === process.pid) return true; try { process.kill(pid, 0); return true; } catch (err) { return errCode(err) === "EPERM"; } } export type TierCounts = { tiered: number; left: number; already: number }; function emptyCounts(): TierCounts { return { tiered: 0, left: 0, already: 0 }; } // Every tierable file in one video dir. A missing dir counts nothing. export async function tierVideoDir( videoDir: string, opts: TierOptions = {}, ): Promise { const counts = emptyCounts(); let names: string[]; try { names = await readdir(videoDir); } catch { return counts; } for (const name of names) { // A stray temp link from a process that is gone (killed between placing // the bytes and the rename): the name still holds the file, so it is only // removed. const stray = /^\..+\.tierlink-(\d+)$/.exec(name); if (stray) { if (!processAlive(Number(stray[1]))) { await rm(path.join(videoDir, name), { force: true }).catch(() => {}); } continue; } if (!isTierable(name)) continue; counts[await tierMediaFile(videoDir, name, opts)] += 1; } // The media side's strays: bytes a dead process was placing // (`..tiering-` in `media//`). Only while the tier answers. const destDir = mediaDirOfVideoDir(videoDir); if (await mediaTierReady(path.dirname(destDir))) { for (const name of await readdir(destDir).catch(() => [] as string[])) { const stray = /^\..+\.tiering-(\d+)$/.exec(name); if (stray && !processAlive(Number(stray[1]))) { await rm(path.join(destDir, name), { force: true }).catch(() => {}); } } } return counts; } export type TierChannelOptions = TierOptions & { // Only video dirs whose mtime is at or after this instant (ms since epoch) — // a batch download's run start: a dir yt-dlp wrote into had an entry added // or renamed, which moves its mtime. since?: number; // Make `channels//media` a real directory when neither a link nor a // directory is there (the mover's preflight tiers a classic channel in // place, same filesystem, before it copies `media/`). createMediaDir?: boolean; }; // Every video dir of a channel. A classic channel without `createMediaDir` // tiers nothing (every file is "left" — no work is attempted, and none is // counted). Never throws. export async function tierChannelMedia( paths: Pick, slug: string, opts: TierChannelOptions = {}, ): Promise { const counts = emptyCounts(); const mediaRoot = channelMediaLink(paths, slug); if (opts.createMediaDir) { try { await lstat(mediaRoot); } catch (err) { if (errCode(err) === "ENOENT") { await mkdir(mediaRoot).catch(() => {}); } } } if (!(await mediaTierReady(mediaRoot))) return counts; const dataDir = path.join(paths.channelsDir, slug, "data"); let ids: string[]; try { ids = await readdir(dataDir); } catch { return counts; } for (const id of ids) { const videoDir = path.join(dataDir, id); if (opts.since !== undefined) { try { const st = await stat(videoDir); if (!st.isDirectory() || st.mtimeMs < opts.since) continue; } catch { continue; } } const c = await tierVideoDir(videoDir, opts); counts.tiered += c.tiered; counts.left += c.left; counts.already += c.already; } return counts; } // REMOVE ONE FILE FROM A VIDEO DIR, through its link when it is one: the link's // target is removed first (only when it resolves inside this channel's // `media//` — a link pointing anywhere else is removed alone, never // followed out), then the name. Missing is not an error. // // THE ONE `rm` OF A VIDEO-DIR ENTRY. Every deleter in common/ and the editor // goes through here (the grep gate in plans/release-17.md), text sidecars // included, so no call site has to know which of its names may be a link. export async function removeMediaFile(videoDir: string, name: string): Promise { const file = path.join(videoDir, name); let st; try { st = await lstat(file); } catch { return; } if (st.isSymbolicLink()) { // Any id's dir under the channel's own `media/`: a video dir renamed by // reconcileVideoDirs keeps links into `media//`. Never followed // out of `media/`. const mediaRoot = path.dirname(mediaDirOfVideoDir(videoDir)); let target = ""; try { target = path.resolve(videoDir, await readlink(file)); } catch { /* unreadable link: removed alone below */ } if (target && path.dirname(path.dirname(target)) === mediaRoot) { await rm(target, { force: true }); // Its dir goes when it empties (a non-recursive rmdir refuses otherwise). await rmdir(path.dirname(target)).catch(() => {}); } } await rm(file, { force: true }); } // Before a whole video dir is deleted: every link's target in it, then the // video's own `media//` (whatever else is left there — the migration's // platter copies before a `--reclaim`). The caller removes the dir itself. export async function removeVideoDirMedia(videoDir: string): Promise { let names: string[] = []; try { names = await readdir(videoDir); } catch { /* no dir: only the tier's side to clear */ } for (const name of names) { let st; try { st = await lstat(path.join(videoDir, name)); } catch { continue; } if (st.isSymbolicLink()) await removeMediaFile(videoDir, name); } const tierDir = mediaDirOfVideoDir(videoDir); try { const l = await lstat(tierDir); if (l.isDirectory()) await rm(tierDir, { recursive: true, force: true }); } catch { /* no tier dir for this video */ } }