import path from "node:path"; import type { Paths } from "./paths"; import type { ChannelConfig } from "./channelConfig"; import { isSourceVideoQuality, type SourceVideoQuality, } from "../ytdlp/downloadFormat"; // The saved-video store (Phase 3 of the video-persistence feature). // // When the per-download persistence rule decides to keep a source video, the // downloaded container (data//source-media. from Phase 2) is MOVED out // of the per-video data dir into a separate store and a small pointer sidecar is // left behind in the data dir. This keeps the main data volume holding only // audio + transcripts while the (large) source videos can live on another disk. // // Layout: // ///source-media. (the moved container) // channels//data//saved-video.json (the pointer back to it) // // = ChannelConfig.savedVideosDir (per-channel override) || Paths.savedVideosDir. // // This module is pure (path math + pointer parse) so it's safe to import from // both client and server; the filesystem operations live in savedVideo-server.ts. export const SAVED_VIDEO_POINTER_FILENAME = "saved-video.json"; // Why this source was persisted (mirrors persistencePlan's PersistCategory, minus // "none"). The retention prune only evicts "keep-latest" containers once they // roll out of the window; "pin" (irreplaceable) and "override" (manually // archived) containers are kept until explicitly unpersisted. export type SavedVideoKeepReason = "keep-latest" | "pin" | "override"; export type SavedVideoPointer = { // ISO timestamp the container was persisted into the store. storedAt: string; // Absolute store dir that holds the container (savedVideoDir() result). dir: string; // Container basename within `dir` (e.g. "source-media.mp4"). file: string; // Size of the stored container in bytes (for backup manifests + disk reports). bytes: number; // Why it was persisted (governs retention pruning). Absent on legacy pointers, // which the prune treats conservatively as non-evictable. keepReason?: SavedVideoKeepReason; // Optional content hash, populated by the backup/verify step (Phase 4). sha256?: string; // WHO asked for this container, when it was not the keep-latest rule. // // The clip-window feature lets umtool ask the editor for a whole source // ("full: true"), and the operator then needs to know months later why a // 4 GB file is on the platter. `keepReason` cannot say it: it stays // "override"/"pin" precisely so pruneSavedVideos never evicts a container // somebody asked for. Absent on every pointer written before this and on // every automatic persist. origin?: SavedVideoOrigin; // WHAT WAS DOWNLOADED: the quality the persist asked for and the format // yt-dlp actually took (its `requested_downloads[0]`, printed at download // time). A "video_720" persist can fall through to its last-resort rung, so // the asked-for preset alone does not say how tall the file is. Absent on // every pointer written before this; `height`/`vcodec` absent when yt-dlp // did not report them. format?: SavedVideoFormat; }; export type SavedVideoFormat = { preset: SourceVideoQuality; height?: number; vcodec?: string; }; function parseFormat(raw: unknown): SavedVideoFormat | null { if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null; const r = raw as Record; if (!isSourceVideoQuality(r.preset)) return null; const out: SavedVideoFormat = { preset: r.preset }; if (typeof r.height === "number" && Number.isInteger(r.height) && r.height > 0) { out.height = r.height; } if (typeof r.vcodec === "string" && r.vcodec !== "") out.vcodec = r.vcodec; return out; } // The format a persist pass printed (runOneYtdlp's FORMAT_MARKER line: // " ", yt-dlp's "NA" for a missing field), as the // pointer's `format`. With no line, the asked-for preset alone. export function savedVideoFormatFromLine( line: string | null | undefined, preset: SourceVideoQuality, ): SavedVideoFormat { const out: SavedVideoFormat = { preset }; if (!line) return out; const [heightRaw, vcodecRaw] = line.trim().split(/\s+/); const height = Number(heightRaw); if (Number.isInteger(height) && height > 0) out.height = height; if (vcodecRaw && vcodecRaw !== "NA" && vcodecRaw !== "none") { out.vcodec = vcodecRaw; } return out; } // The requester of a manually-sourced container: the tool, and the report clip // it was wanted for. Deliberately the same vocabulary as ClipProvenance // (lib/clipWindow.ts) so the video page renders both the same way. // // A CONTAINER ATTACHED FROM A LOCAL ARCHIVE (release 21 D1, `attach-media`) // carries `kind: "local-archive"` and says which archive, which entry in it, // the bytes' sha256 and when — the provenance of a file nobody downloaded. // `requestedBy` is still set (the tool, "attach-media"), so every reader that // renders "requested by" keeps working on it unchanged; a pointer without // `kind` is the requested-container shape it always was. export type SavedVideoOrigin = { requestedBy: string; manifest?: string; clipId?: string; reason?: string; requestedAt?: string; kind?: SavedVideoOriginKind; // local-archive: the archive (a .zip or .7z file, or a directory) as an // absolute path, the entry's path inside it, the copied bytes' sha256 (hex) // and when the attach wrote the pointer. archive?: string; entry?: string; sha256?: string; attachedAt?: string; }; export const SAVED_VIDEO_ORIGIN_KINDS = ["local-archive"] as const; export type SavedVideoOriginKind = (typeof SAVED_VIDEO_ORIGIN_KINDS)[number]; function parseOrigin(raw: unknown): SavedVideoOrigin | null { if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null; const r = raw as Record; if (typeof r.requestedBy !== "string" || r.requestedBy === "") return null; const out: SavedVideoOrigin = { requestedBy: r.requestedBy }; for (const k of ["manifest", "clipId", "reason", "requestedAt"] as const) { const v = r[k]; if (typeof v === "string" && v !== "") out[k] = v; } // An unknown kind is dropped with its fields rather than failing the // pointer: the container is still there, only its provenance goes unread. if ( typeof r.kind === "string" && (SAVED_VIDEO_ORIGIN_KINDS as readonly string[]).includes(r.kind) ) { out.kind = r.kind as SavedVideoOriginKind; for (const k of ["archive", "entry", "sha256", "attachedAt"] as const) { const v = r[k]; if (typeof v === "string" && v !== "") out[k] = v; } } return out; } function isKeepReason(v: unknown): v is SavedVideoKeepReason { return v === "keep-latest" || v === "pin" || v === "override"; } // The store root for a channel: the per-channel override when set, else the // global default. Whitespace-only overrides fall back to the global default. export function savedVideoRoot( paths: Paths, config: Pick | null | undefined, ): string { const override = config?.savedVideosDir?.trim(); return override ? override : paths.savedVideosDir; } // The store dir for a single video: ///. export function savedVideoDir( paths: Paths, config: Pick | null | undefined, channelSlug: string, videoId: string, ): string { return path.join(savedVideoRoot(paths, config), channelSlug, videoId); } // Absolute path to the stored container a pointer references. export function savedVideoPath(pointer: SavedVideoPointer): string { return path.join(pointer.dir, pointer.file); } export function parseSavedVideoPointer(raw: unknown): SavedVideoPointer | null { if (!raw || typeof raw !== "object") return null; const r = raw as Record; if (typeof r.dir !== "string" || r.dir === "") return null; if (typeof r.file !== "string" || r.file === "") return null; if (typeof r.bytes !== "number" || !Number.isFinite(r.bytes)) return null; const pointer: SavedVideoPointer = { storedAt: typeof r.storedAt === "string" ? r.storedAt : "", dir: r.dir, file: r.file, bytes: r.bytes, }; if (isKeepReason(r.keepReason)) pointer.keepReason = r.keepReason; if (typeof r.sha256 === "string" && r.sha256 !== "") { pointer.sha256 = r.sha256; } // TOLERANT: a legacy pointer has no `origin` and must parse exactly as it did. const origin = parseOrigin(r.origin); if (origin) pointer.origin = origin; // Same rule: a pointer without `format` (every one before it) parses as before. const format = parseFormat(r.format); if (format) pointer.format = format; return pointer; }