60 lines
1.9 KiB
TypeScript
60 lines
1.9 KiB
TypeScript
import "server-only";
|
|
import { spawn } from "node:child_process";
|
|
|
|
const cache = new Map<string, number>();
|
|
|
|
const PROBE_TIMEOUT_MS = 10_000;
|
|
|
|
/**
|
|
* Probe a video file's duration in seconds via ffprobe. Cached per-path
|
|
* for the lifetime of the process — files don't change duration on us.
|
|
* Returns null if ffprobe fails or returns garbage.
|
|
*
|
|
* Caps the probe at PROBE_TIMEOUT_MS and ties to an optional AbortSignal
|
|
* so a hung ffprobe (network mount, weird codec, dead disk) can't leave
|
|
* the request awaiting forever or zombie the subprocess.
|
|
*/
|
|
export async function probeDuration(abs: string, signal?: AbortSignal): Promise<number | null> {
|
|
const cached = cache.get(abs);
|
|
if (cached !== undefined) return cached;
|
|
return new Promise((resolve) => {
|
|
const proc = spawn("ffprobe", [
|
|
"-v", "error",
|
|
"-show_entries", "format=duration",
|
|
"-of", "default=noprint_wrappers=1:nokey=1",
|
|
abs,
|
|
]);
|
|
let out = "";
|
|
let settled = false;
|
|
|
|
const settle = (n: number | null) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
if (timeoutId) clearTimeout(timeoutId);
|
|
if (signal && abortHandler) signal.removeEventListener("abort", abortHandler);
|
|
if (n != null && Number.isFinite(n) && n > 0) {
|
|
cache.set(abs, n);
|
|
resolve(n);
|
|
} else {
|
|
resolve(null);
|
|
}
|
|
};
|
|
|
|
const kill = () => {
|
|
try { proc.kill("SIGKILL"); } catch { /* ignore */ }
|
|
settle(null);
|
|
};
|
|
|
|
const timeoutId = setTimeout(kill, PROBE_TIMEOUT_MS);
|
|
const abortHandler = signal ? () => kill() : null;
|
|
if (signal && abortHandler) {
|
|
if (signal.aborted) { kill(); return; }
|
|
signal.addEventListener("abort", abortHandler, { once: true });
|
|
}
|
|
|
|
proc.stdout?.on("data", (d) => { out += d.toString(); });
|
|
proc.on("close", () => settle(Number(out.trim())));
|
|
proc.on("error", () => settle(null));
|
|
});
|
|
}
|