// The filesystem half of the clip-window store. See clipWindow.ts for the // layout and for why a `clips/` subdirectory is invisible to every video-dir // enumerator in the repo. import { writeJsonAtomic } from "./jsonFile-server"; import path from "node:path"; import { mkdir, readFile, readdir, stat } from "node:fs/promises"; import { clipsDirFor, clipWindowFile, clipWindowSidecar, parseClipProvenance, hasClipWindowExt, parseClipWindowName, tightestClipWindow, type ClipProvenance, } from "./clipWindow"; export type ClipWindow = { // `-.mp4`, the file's own name. file: string; // Absolute path to it. path: string; from: number; to: number; bytes: number; // The sidecar's contents, when there is one. A window fetched before the // sidecar existed (or one whose sidecar was hand-removed) is still a window. provenance: ClipProvenance | null; }; // Every fetched window in a video dir, oldest-window-first by start second. // // A missing `clips/` is an empty list, not an error: most video dirs have none, // and the video page asks on every render. export async function listClipWindows(videoDir: string): Promise { const dir = clipsDirFor(videoDir); const entries = await readdir(dir).catch(() => [] as string[]); const out: ClipWindow[] = []; for (const name of entries) { // THE SHARED PREDICATE, not a second copy of the extension list — see // `hasClipWindowExt`. A `.json` sidecar has none of these extensions, so it // never lists as a window; it is read BY the window it describes. if (!hasClipWindowExt(name)) continue; const span = parseClipWindowName(name); if (!span) continue; const abs = path.join(dir, name); const st = await stat(abs).catch(() => null); if (!st?.isFile()) continue; const sidecar = await readFile( path.join(dir, clipWindowSidecar(span.from, span.to)), "utf8", ).catch(() => null); let provenance: ClipProvenance | null = null; if (sidecar !== null) { try { provenance = parseClipProvenance(JSON.parse(sidecar)); } catch { provenance = null; } } out.push({ file: name, path: abs, from: span.from, to: span.to, bytes: st.size, provenance, }); } out.sort((a, b) => a.from - b.from || a.to - b.to); return out; } // The tightest already-fetched window covering [from, to], or null. This is // what makes a second request for the same seconds free — and what makes a // deliberately generous fetch BE the next caller's cache rather than a second // download of the same bytes. export async function findContainingClipWindow( videoDir: string, from: number, to: number, ): Promise { return tightestClipWindow(await listClipWindows(videoDir), from, to); } // Atomic (jsonFile-server's writeJsonAtomic, as every sidecar), so a crash // mid-write never leaves a half-parsed provenance under the real name. export async function writeClipProvenance( videoDir: string, from: number, to: number, provenance: ClipProvenance, ): Promise { const dir = clipsDirFor(videoDir); await mkdir(dir, { recursive: true }); const file = path.join(dir, clipWindowSidecar(from, to)); await writeJsonAtomic(file, provenance); return file; } // Where a window for [from, to] would live. Exported so a caller can name the // destination before deciding to fetch it. export function clipWindowPath( videoDir: string, from: number, to: number, ): string { return path.join(clipsDirFor(videoDir), clipWindowFile(from, to)); }