// 本项目新增:ffprobe 封装与 statfs 磁盘用量(REQ §8.2、§8.4.1);失败一律返回 unknown。 import { execFile } from "node:child_process"; import { statfs } from "node:fs/promises"; import { errnoOf } from "./errors.ts"; export interface ProbeVideo { codec?: string; profile?: string; width?: number; height?: number; pixFmt?: string; /** 仅在流信息明确时给出;未知不补值 */ hdr?: "HDR10" | "HLG" | "DV"; frameRate?: string; } export interface ProbeAudio { codec?: string; profile?: string; channels?: number; channelLayout?: string; language?: string; title?: string; } export interface ProbeSubtitle { codec?: string; language?: string; title?: string; forced?: boolean; } export type ProbeResult = | { status: "ok"; container?: string; durationSec?: number; bitRate?: number; sizeBytes?: number; video: ProbeVideo[]; audio: ProbeAudio[]; subtitles: ProbeSubtitle[]; } | { status: "unknown"; reason: "ffprobe_unavailable" | "timeout" | "probe_failed" | "parse_failed"; message?: string; }; export interface ProbeOptions { /** ffprobe 可执行文件,默认 "ffprobe"(PATH 查找) */ bin?: string; timeoutMs?: number; /** 输入前的附加参数(远端直链用 -rw_timeout 等) */ inputArgs?: string[]; } const optNum = (v: unknown): number | undefined => { const n = typeof v === "string" ? Number(v) : v; return typeof n === "number" && Number.isFinite(n) ? n : undefined; }; const optStr = (v: unknown): string | undefined => (typeof v === "string" && v !== "" ? v : undefined); type Stream = Record & { tags?: Record; side_data_list?: { side_data_type?: string }[]; }; function hdrOf(s: Stream): ProbeVideo["hdr"] { if (s.side_data_list?.some((d) => /dovi|dolby vision/i.test(d.side_data_type ?? ""))) return "DV"; if (s.color_transfer === "smpte2084") return "HDR10"; if (s.color_transfer === "arib-std-b67") return "HLG"; return undefined; } function clean(o: T): T { return Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined)) as T; } /** ffprobe 封装:任何失败(含本机无 ffprobe)返回 { status: "unknown" },不抛。 */ export function ffprobe(path: string, opts: ProbeOptions = {}): Promise { return new Promise((resolve) => { execFile( opts.bin ?? "ffprobe", ["-v", "error", ...(opts.inputArgs ?? []), "-print_format", "json", "-show_format", "-show_streams", "--", path], { timeout: opts.timeoutMs ?? 30_000, maxBuffer: 16 * 1024 * 1024 }, (err, stdout, stderr) => { if (err) { const e = err as NodeJS.ErrnoException & { killed?: boolean; signal?: string }; if (errnoOf(e) === "ENOENT" || errnoOf(e) === "EACCES") return resolve({ status: "unknown", reason: "ffprobe_unavailable" }); if (e.killed || e.signal === "SIGTERM") return resolve({ status: "unknown", reason: "timeout" }); return resolve({ status: "unknown", reason: "probe_failed", message: String(stderr).slice(0, 500) }); } try { const j = JSON.parse(stdout) as { format?: Record; streams?: Stream[] }; const streams = j.streams ?? []; const f = j.format ?? {}; resolve( clean({ status: "ok" as const, container: optStr(f.format_name), durationSec: optNum(f.duration), bitRate: optNum(f.bit_rate), sizeBytes: optNum(f.size), video: streams .filter((s) => s.codec_type === "video" && !(s.disposition as { attached_pic?: number })?.attached_pic) .map((s) => clean({ codec: optStr(s.codec_name), profile: optStr(s.profile), width: optNum(s.width), height: optNum(s.height), pixFmt: optStr(s.pix_fmt), hdr: hdrOf(s), frameRate: optStr(s.avg_frame_rate) === "0/0" ? undefined : optStr(s.avg_frame_rate), }), ), audio: streams .filter((s) => s.codec_type === "audio") .map((s) => clean({ codec: optStr(s.codec_name), profile: optStr(s.profile), channels: optNum(s.channels), channelLayout: optStr(s.channel_layout), language: optStr(s.tags?.language), title: optStr(s.tags?.title), }), ), subtitles: streams .filter((s) => s.codec_type === "subtitle") .map((s) => clean({ codec: optStr(s.codec_name), language: optStr(s.tags?.language), title: optStr(s.tags?.title), forced: (s.disposition as { forced?: number } | undefined)?.forced === 1 ? true : undefined, }), ), }), ); } catch (e) { resolve({ status: "unknown", reason: "parse_failed", message: (e as Error).message }); } }, ); }); } export type DiskUsage = | { status: "ok"; path: string; totalBytes: number; freeBytes: number; /** 非 root 可用空间 */ availableBytes: number; usedBytes: number; /** used / (used + available),与 df 的 Use% 一致 */ usedRatio: number; } /** 无法读取:调用方应保守阻止新增分配并告警(REQ §8.4.1) */ | { status: "unknown"; path: string; code: string; message: string }; export async function diskUsage(path: string): Promise { try { const s = await statfs(path); const total = s.blocks * s.bsize; const free = s.bfree * s.bsize; const avail = s.bavail * s.bsize; const used = total - free; return { status: "ok", path, totalBytes: total, freeBytes: free, availableBytes: avail, usedBytes: used, usedRatio: used + avail > 0 ? used / (used + avail) : 0, }; } catch (e) { return { status: "unknown", path, code: errnoOf(e) ?? "UNKNOWN", message: (e as Error)?.message ?? String(e) }; } }