import "server-only"; import path from "node:path"; import fs from "node:fs"; import fsp from "node:fs/promises"; import crypto from "node:crypto"; /** * Bump on any change to srtToVtt, the ffmpeg arg recipe, or the * cache-key composition. Old entries become unreachable automatically. * v1 → initial. * v2 → added decodeSubtitleBuffer for non-UTF-8 SRTs/VTTs (cp936, * shift-jis, big5, UTF-16). Existing UTF-8-only entries would * still be correct but the version bump ensures any cached * output produced with a buggy decode path is regenerated. */ export const CONVERTER_VERSION = 2; const CACHE_DIR = path.join(process.cwd(), "data", "subtitle-cache"); fs.mkdirSync(CACHE_DIR, { recursive: true }); export type CacheKind = "embedded" | "srt" | "ass" | "ssa"; export interface CacheKeyInput { abs: string; size: number; mtimeMs: number; kind: CacheKind; /** ffmpeg stream index for embedded; ext for sidecar files. */ streamOrExt: string | number; } export function cachePath(input: CacheKeyInput): string { const raw = [ input.abs, input.size, Math.round(input.mtimeMs), input.kind, String(input.streamOrExt), CONVERTER_VERSION, ].join("|"); const hash = crypto.createHash("sha1").update(raw).digest("hex"); return path.join(CACHE_DIR, `${hash}.vtt`); } export async function readCache(file: string): Promise { try { const buf = await fsp.readFile(file); // Bump mtime so LRU pruning treats this entry as recently used. // Best effort: failure (read-only fs, locked file) is harmless. const now = Date.now() / 1000; fsp.utimes(file, now, now).catch(() => { /* ignore */ }); return buf; } catch { return null; } } let writesSinceLastPrune = 0; const PRUNE_WRITE_INTERVAL = 25; export async function writeCache(file: string, data: Buffer | string): Promise { // Atomic via rename — avoids partial files if the process is killed // mid-write or two requests race on the same key. const tmp = `${file}.${process.pid}.${Date.now()}.tmp`; try { await fsp.writeFile(tmp, data); await fsp.rename(tmp, file); } catch (e) { try { await fsp.unlink(tmp); } catch { /* ignore */ } throw e; } writesSinceLastPrune++; if (writesSinceLastPrune >= PRUNE_WRITE_INTERVAL) { writesSinceLastPrune = 0; void pruneSubtitleCacheIfNeeded(); } } interface PruneResult { scanned: number; removed: number; beforeBytes: number; afterBytes: number; } /** LRU sweep keyed on file mtime. Walks `data/subtitle-cache/`, * computes total size, and if it exceeds the configured limit, * deletes the oldest-mtime entries until size drops below 80% of * the cap. No-op when the limit setting is 0 (unlimited). */ export async function pruneSubtitleCacheIfNeeded(): Promise { const { getAppSetting } = await import("@/lib/db/appSettings"); const limitMb = Number(getAppSetting("subtitleCacheLimitMb")); const result: PruneResult = { scanned: 0, removed: 0, beforeBytes: 0, afterBytes: 0 }; if (!Number.isFinite(limitMb) || limitMb <= 0) return result; const limitBytes = limitMb * 1024 * 1024; const lowWatermark = Math.floor(limitBytes * 0.8); let entries: import("node:fs").Dirent[]; try { entries = await fsp.readdir(CACHE_DIR, { withFileTypes: true }); } catch { return result; } type CacheEntry = { abs: string; size: number; mtimeMs: number }; const items: CacheEntry[] = []; for (const e of entries) { if (!e.isFile() || !e.name.endsWith(".vtt")) continue; const abs = path.join(CACHE_DIR, e.name); try { const stat = await fsp.stat(abs); items.push({ abs, size: stat.size, mtimeMs: stat.mtimeMs }); result.scanned++; result.beforeBytes += stat.size; } catch { /* file vanished mid-walk; skip */ } } if (result.beforeBytes <= limitBytes) { result.afterBytes = result.beforeBytes; return result; } // Oldest first. items.sort((a, b) => a.mtimeMs - b.mtimeMs); let running = result.beforeBytes; for (const it of items) { if (running <= lowWatermark) break; try { await fsp.unlink(it.abs); running -= it.size; result.removed++; } catch { /* concurrent delete; skip */ } } result.afterBytes = running; if (result.removed > 0) { console.log( `[subtitle-cache] pruned ${result.removed}/${result.scanned} files; ${(result.beforeBytes / 1_048_576).toFixed(1)}MB → ${(running / 1_048_576).toFixed(1)}MB`, ); } return result; } /** Bootstrap entrypoint — fire one sweep on module load (delayed so * other startup work isn't blocked). */ function scheduleBootstrapPrune(): void { setTimeout(() => { void pruneSubtitleCacheIfNeeded(); }, 5_000); } scheduleBootstrapPrune();