146 lines
4.7 KiB
TypeScript
146 lines
4.7 KiB
TypeScript
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<Buffer | null> {
|
|
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<void> {
|
|
// 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<PruneResult> {
|
|
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();
|