Initial commit

This commit is contained in:
admin
2026-05-26 22:46:00 +02:00
commit 7e2c2ff89c
256 changed files with 51523 additions and 0 deletions
+145
View File
@@ -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();