import { copyFileAtomic, writeJsonAtomic } from "./jsonFile-server"; import path from "node:path"; import { mkdir, readFile, rename, rm, stat, } from "node:fs/promises"; import { SAVED_VIDEO_POINTER_FILENAME, parseSavedVideoPointer, savedVideoPath, type SavedVideoFormat, type SavedVideoKeepReason, type SavedVideoOrigin, type SavedVideoPointer, } from "./savedVideo"; import { assertSavedVideosStoreWritable } from "./savedVideoStore"; import type { Paths } from "./paths"; // Filesystem side of the saved-video store. See savedVideo.ts for the layout. export function savedVideoPointerPath(videoDir: string): string { return path.join(videoDir, SAVED_VIDEO_POINTER_FILENAME); } export async function loadSavedVideo( videoDir: string, ): Promise { try { const raw = await readFile(savedVideoPointerPath(videoDir), "utf8"); return parseSavedVideoPointer(JSON.parse(raw)); } catch { return null; } } export async function isSavedVideo(videoDir: string): Promise { return (await loadSavedVideo(videoDir)) !== null; } async function writePointer( videoDir: string, pointer: SavedVideoPointer, ): Promise { const file = savedVideoPointerPath(videoDir); await writeJsonAtomic(file, pointer); } // Repoint a video's saved-video.json at a new absolute store dir. Used when a // channel is renamed: the pointer's `dir` is stored absolutely and includes the // channel slug, so it must be rewritten after the store dir is moved or // resolveSavedVideo (which trusts `dir` verbatim) can no longer find the file. // No-op (returns false) when there's no pointer. export async function rewriteSavedVideoDir( videoDir: string, newDir: string, ): Promise { const pointer = await loadSavedVideo(videoDir); if (!pointer) return false; if (pointer.dir === newDir) return true; await writePointer(videoDir, { ...pointer, dir: newDir }); return true; } // Move a file, crossing device boundaries safely. A plain rename() works within // one filesystem; EXDEV (the store is on a different disk) falls back to a // copy-to-temp + atomic rename + unlink so a crash mid-copy never leaves a // partial file under the final name. async function moveFileCrossDevice(src: string, dest: string): Promise { await mkdir(path.dirname(dest), { recursive: true }); try { await rename(src, dest); return; } catch (err) { if ((err as NodeJS.ErrnoException).code !== "EXDEV") throw err; } await copyFileAtomic(src, dest); await rm(src, { force: true }); } // Resolve a usable absolute path to a video's persisted source container, or // null when there's no pointer or the stored file has gone missing. export async function resolveSavedVideo( videoDir: string, ): Promise { const pointer = await loadSavedVideo(videoDir); if (!pointer) return null; const file = savedVideoPath(pointer); try { await stat(file); return file; } catch { return null; } } // Move a downloaded source container OUT of the main data dir into the saved // store and write a pointer sidecar back into the data dir. Returns the pointer. // Overwrites any existing stored container for this video (the redownload case). export async function persistSourceVideo(opts: { videoDir: string; sourceFilename: string; storeDir: string; keepReason?: SavedVideoKeepReason; // Who asked for this container, for a manually-requested persist. See // SavedVideoOrigin — it records the requester WITHOUT changing keepReason, // which is what keeps the container out of the retention prune. origin?: SavedVideoOrigin; // What the persist asked for and what yt-dlp took; see SavedVideoPointer.format. format?: SavedVideoFormat; // WHEN THE STORE IS BEING MOVED, THIS DOES NOT RUN. Passed by the download // path, which has them; a caller that omits them opts out, which is right for // a store that is not the corpus one. See lib/savedVideoStore.ts for the // three ways a write landing mid-move loses a container silently — the worst // of them is `moveFileCrossDevice`'s unconditional `mkdir -p` recreating // `saved-videos` as a real directory in the instant between the mover's // rename and its symlink. paths?: Pick; // THE FILE TO MOVE, when it is not `/`. A container // attached from a local archive (controller/attachMedia.ts) is copied out of // the archive into a staging file BESIDE `storeDir`, on the store's disk, so // the move is a same-filesystem rename — copying 36 GB through the corpus // SSD first would be the wrong disk twice. The stored name is still // `sourceFilename`. sourcePath?: string; // The bytes' sha256, when the caller already hashed them (an attach hashes // while it copies); the backup step otherwise fills it in later. sha256?: string; }): Promise { if (opts.paths) { await assertSavedVideosStoreWritable(opts.paths, opts.storeDir); } const src = opts.sourcePath ?? path.join(opts.videoDir, opts.sourceFilename); const dest = path.join(opts.storeDir, opts.sourceFilename); const st = await stat(src); await moveFileCrossDevice(src, dest); const pointer: SavedVideoPointer = { storedAt: new Date().toISOString(), dir: opts.storeDir, file: opts.sourceFilename, bytes: st.size, ...(opts.keepReason ? { keepReason: opts.keepReason } : {}), ...(opts.sha256 ? { sha256: opts.sha256 } : {}), ...(opts.origin ? { origin: opts.origin } : {}), ...(opts.format ? { format: opts.format } : {}), }; await writePointer(opts.videoDir, pointer); return pointer; } // Record a content hash on a video's pointer (populated by the backup step so // the store + UI can show a verified checksum). Best-effort: a missing pointer // is a no-op. export async function updateSavedVideoChecksum( videoDir: string, sha256: string, ): Promise { const pointer = await loadSavedVideo(videoDir); if (!pointer) return; await writePointer(videoDir, { ...pointer, sha256 }); } // Put a replaced pointer's provenance back on its replacement: the old // `keepReason` (absent stays absent — a legacy pointer the prune already treats // as non-evictable) and `origin`. A persist that re-fetched a container is the // same video's container, not a new decision about why it is kept. No-op when // there is no pointer. export async function restoreSavedVideoProvenance( videoDir: string, from: Pick, ): Promise { const pointer = await loadSavedVideo(videoDir); if (!pointer) return; const { keepReason: _k, origin: _o, ...rest } = pointer; await writePointer(videoDir, { ...rest, ...(from.keepReason ? { keepReason: from.keepReason } : {}), ...(from.origin ?? pointer.origin ? { origin: from.origin ?? pointer.origin } : {}), }); } // Best-effort removal of a now-empty store dir (and its empty parent). async function pruneEmptyStoreDirs(storeDir: string): Promise { await rm(storeDir, { recursive: false, force: true }).catch(() => {}); await rm(path.dirname(storeDir), { recursive: false, force: true }).catch( () => {}, ); } // Reverse persistSourceVideo: move the stored container back into the data dir // and remove the pointer. Returns false when there was no pointer to reverse. export async function unpersistSavedVideo( videoDir: string, // Same opt-in guard as persistSourceVideo, and for the same window: this // READS out of the store and then prunes its empty dirs, both of which race a // move in flight. paths?: Pick, ): Promise { const pointer = await loadSavedVideo(videoDir); if (!pointer) return false; if (paths) await assertSavedVideosStoreWritable(paths, pointer.dir); const src = savedVideoPath(pointer); const dest = path.join(videoDir, pointer.file); try { await moveFileCrossDevice(src, dest); } catch { // The stored container is already gone; just drop the dangling pointer. } await rm(savedVideoPointerPath(videoDir), { force: true }); await pruneEmptyStoreDirs(pointer.dir); return true; } // Permanently delete a video's persisted container + pointer (retention prune of // a video that rolled out of the keep-latest window). Returns bytes freed. export async function dropSavedVideo(videoDir: string): Promise { const pointer = await loadSavedVideo(videoDir); if (!pointer) return 0; await rm(savedVideoPath(pointer), { force: true }); await rm(savedVideoPointerPath(videoDir), { force: true }); await pruneEmptyStoreDirs(pointer.dir); return pointer.bytes; }