feat(adapters): qBittorrent, filesystem, TMDB and Emby adapters with testkit mocks (W1-3a)
- qbittorrent: WebUI API v2 client (cookie/API-key auth, add/info/files/filePrio, pause/resume with 5.x stop/start fallback, tags, delete with explicit deleteFiles, maindata), error classes, idempotent submit with local infohash (own bencode) and post-timeout hash/probe-tag lookup; state mapping per MoviePilot - fs: copyWithVerify (tmp + fsync + size/sha256/sample + no-clobber commit), moveSafe (EXDEV surfaced), resumable crossDeviceMove, statSafe/checkRoot with root sentinel, budgeted incremental scan with cursor, ffprobe, statfs usage - tmdb: multi-language search/details/seasons, LRU+TTL cache with coalescing, rate-limit backoff, MoviePilot match chain (name normalization, year +-1) - emby: Emby/Jellyfin items/seasons/episodes/MediaSources, failures -> unknown - testkit: mock qB / TMDB / Emby servers, torrent builder, temp-tree fixtures - docs: W1-3a porting table appended to downloader-and-sites ledger Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
1 parent
9c881e737f
commit
ef2e504a94
45 files changed
+5766
-1
No files matched your search
@@ -0,0 +1,181 @@
|
||||
// 本项目新增: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";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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", "-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) };
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user