Initial commit
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
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();
|
||||
Reference in New Issue
Block a user