Files
UHD_Media_Tool/packages/adapters/src/fs/ffprobe.ts
T
rootandClaude Opus 5.5 c2bf8a071a feat(storage): 存储实例与 Alist 虚拟路径(迁移 0090、设置接口、注册表、vfs、健康检查)
- storage_instance 表 + secret 用途 storage_credential;令牌 / 密码 / 路径密码只写不读
- /storage-instances CRUD 与测试连接(版本、用户、根路径、容量);被库根引用不能删
- 远端路径 /@alist/<id>/<路径>,vfs 统一本地 / Alist 的 stat、列目录、建目录、读写、移动、删除
- 整理矩阵中涉及 Alist 的传输(storage/transfer.ts)与远端 ffprobe(直链只读头)
- 媒体库目录可登记在 Alist 上(不写哨兵,目录可访问即在线);健康检查列出存储实例

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
2026-10-06 23:52:20 +08:00

184 lines
6.3 KiB
TypeScript

// 本项目新增: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<string, unknown> & {
tags?: Record<string, string>;
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<T extends object>(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<ProbeResult> {
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<string, unknown>; 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<DiskUsage> {
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) };
}
}