- 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
184 lines
6.3 KiB
TypeScript
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) };
|
|
}
|
|
}
|