Files
UHD_Media_Tool/packages/testkit/src/qbittorrent/mock-qb.ts
T
rootandClaude Fable 5.1 ef2e504a94 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]>
2026-09-27 18:07:48 +08:00

531 lines
18 KiB
TypeScript

import { randomBytes } from "node:crypto";
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
import type { AddressInfo } from "node:net";
import { inspectTorrent } from "./bencode";
import { parseCookies, parseMultipart, readBody } from "./http-util";
export interface MockQbFile {
name: string;
size: number;
progress: number;
priority: number;
}
export interface MockQbTorrent {
hash: string;
name: string;
state: string;
progress: number;
size: number;
total_size: number;
amount_left: number;
save_path: string;
content_path: string;
category: string;
tags: string[];
added_on: number;
completion_on: number;
dlspeed: number;
upspeed: number;
eta: number;
ratio: number;
seeding_time: number;
infohash_v1: string;
infohash_v2: string;
force_start: boolean;
up_limit: number;
dl_limit: number;
ratio_limit: number;
seeding_time_limit: number;
files: MockQbFile[];
/** 被 torrents/info 查询到之前还需隐藏的次数(模拟磁力异步出现) */
hiddenPolls: number;
}
export interface MockQbCall {
method: string;
endpoint: string;
query: Record<string, string>;
form: Record<string, string>;
status: number;
}
export interface MockQbFault {
/** 返回该 HTTP 状态码(不执行动作) */
status?: number;
body?: string;
/** 在执行动作之后销毁连接(模拟"已收单但响应丢失") */
dropAfterApply?: boolean;
/** 不执行动作直接销毁连接 */
drop?: boolean;
/** 延迟响应(先执行动作) */
delayMs?: number;
/** 生效次数,默认 1 */
times?: number;
}
export interface MockQbOptions {
version?: "4.6.7" | "5.1.0";
username?: string;
password?: string;
apiKey?: string;
/** add 响应格式;默认 4.x 为 text("Ok."),5.x 为 json(含 added_torrent_ids) */
addResponse?: "text" | "json";
/** 新加入的任务在 torrents/info 中隐藏的查询次数 */
addVisibleAfterPolls?: number;
/** app/preferences 返回值(只读;客户端不应写入) */
preferences?: Record<string, unknown>;
/** 磁力无元数据时的默认大小 */
magnetDefaultSize?: number;
port?: number;
}
export interface MockQb {
url: string;
calls: MockQbCall[];
close(): Promise<void>;
torrents(): MockQbTorrent[];
get(hash: string): MockQbTorrent | undefined;
tagDefinitions(): string[];
seed(t: Partial<MockQbTorrent> & { hash: string; name: string }): MockQbTorrent;
setProgress(hash: string, progress: number): void;
complete(hash: string): void;
setState(hash: string, state: string): void;
setError(hash: string): void;
setMissingFiles(hash: string): void;
/** 为某个端点注入故障(endpoint 如 "torrents/add") */
fault(endpoint: string, f: MockQbFault): void;
clearFaults(): void;
/** 让已有会话失效(模拟 qB 重启/会话过期) */
expireSessions(): void;
callsTo(endpoint: string): MockQbCall[];
}
const now = () => Math.floor(Date.now() / 1000);
export async function createMockQb(opts: MockQbOptions = {}): Promise<MockQb> {
const version = opts.version ?? "4.6.7";
const v5 = version.startsWith("5");
const user = opts.username ?? "admin";
const pass = opts.password ?? "adminadmin";
const addResponse = opts.addResponse ?? (v5 ? "json" : "text");
const torrents = new Map<string, MockQbTorrent>();
const tagDefs = new Set<string>();
const sessions = new Set<string>();
const faults = new Map<string, MockQbFault[]>();
const calls: MockQbCall[] = [];
let rid = 0;
const pausedState = (done: boolean) => (v5 ? (done ? "stoppedUP" : "stoppedDL") : done ? "pausedUP" : "pausedDL");
function mk(t: Partial<MockQbTorrent> & { hash: string; name: string }): MockQbTorrent {
const size = t.size ?? t.files?.reduce((a, f) => a + f.size, 0) ?? 1024;
const progress = t.progress ?? 0;
const savePath = t.save_path ?? "/downloads";
return {
state: progress >= 1 ? "uploading" : "downloading",
progress,
size,
total_size: size,
amount_left: Math.round(size * (1 - progress)),
save_path: savePath,
content_path: `${savePath.replace(/\/$/, "")}/${t.name}`,
category: "",
tags: [],
added_on: now(),
completion_on: progress >= 1 ? now() : -1,
dlspeed: 0,
upspeed: 0,
eta: 8640000,
ratio: 0,
seeding_time: 0,
infohash_v1: t.hash,
infohash_v2: "",
force_start: false,
up_limit: -1,
dl_limit: -1,
ratio_limit: -2,
seeding_time_limit: -2,
files: [{ name: t.name, size, progress, priority: 1 }],
hiddenPolls: 0,
...t,
};
}
function need(hash: string): MockQbTorrent {
const t = torrents.get(hash.toLowerCase());
if (!t) throw new Error(`mock qb: no torrent ${hash}`);
return t;
}
function setProgress(t: MockQbTorrent, p: number) {
t.progress = Math.max(0, Math.min(1, p));
t.amount_left = Math.round(t.size * (1 - t.progress));
for (const f of t.files) f.progress = f.priority === 0 ? 0 : t.progress;
if (t.progress >= 1) {
t.amount_left = 0;
t.state =
t.state.toLowerCase().startsWith("paused") || t.state.startsWith("stopped") ? pausedState(true) : "uploading";
t.completion_on = now();
} else if (!/^(paused|stopped)/i.test(t.state)) {
t.state = "downloading";
}
}
function toInfo(t: MockQbTorrent) {
const { files: _f, hiddenPolls: _h, tags, ...rest } = t;
return { ...rest, tags: tags.join(", ") };
}
function hashesOf(v: string | undefined): string[] | "all" {
if (!v) return [];
if (v === "all") return "all";
return v.split("|").map((h) => h.toLowerCase());
}
function select(v: string | undefined): MockQbTorrent[] {
const hs = hashesOf(v);
if (hs === "all") return [...torrents.values()];
return hs.map((h) => torrents.get(h)).filter((t): t is MockQbTorrent => !!t);
}
function splitTags(v: string | undefined): string[] {
return (v ?? "")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
}
function matchFilter(t: MockQbTorrent, filter: string): boolean {
const s = t.state.toLowerCase();
switch (filter) {
case "all":
return true;
case "downloading":
return ["downloading", "stalleddl", "metadl", "forceddl", "queueddl", "pauseddl", "stoppeddl"].includes(s);
case "completed":
return t.progress >= 1;
case "seeding":
return ["uploading", "stalledup", "forcedup", "queuedup"].includes(s);
case "paused":
case "stopped":
return s.startsWith("paused") || s.startsWith("stopped");
case "errored":
return s === "error" || s === "missingfiles";
case "stalled":
return s.startsWith("stalled");
default:
return true;
}
}
type Handler = (
q: Record<string, string>,
form: Record<string, string>,
raw: IncomingMessage,
body: Buffer,
) => {
status?: number;
body: string;
json?: boolean;
headers?: Record<string, string>;
};
const handlers: Record<string, Handler> = {
"app/version": () => ({ body: `v${version}` }),
"app/webapiVersion": () => ({ body: v5 ? "2.11.4" : "2.9.3" }),
"torrents/info": (q) => {
let list = [...torrents.values()];
if (q.hashes) {
const hs = hashesOf(q.hashes);
if (hs !== "all") list = list.filter((t) => hs.includes(t.hash));
}
if (q.tag !== undefined)
list = list.filter((t) => (q.tag === "" ? t.tags.length === 0 : t.tags.includes(q.tag!)));
if (q.category !== undefined) list = list.filter((t) => t.category === q.category);
if (q.filter) list = list.filter((t) => matchFilter(t, q.filter!));
const visible = list.filter((t) => {
if (t.hiddenPolls > 0) {
t.hiddenPolls--;
return false;
}
return true;
});
return { body: JSON.stringify(visible.map(toInfo)), json: true };
},
"torrents/files": (q) => {
const t = torrents.get((q.hash ?? "").toLowerCase());
if (!t) return { status: 404, body: "Torrent hash was not found" };
return {
body: JSON.stringify(
t.files.map((f, index) => ({ index, ...f, is_seed: t.progress >= 1, availability: 1, piece_range: [0, 0] })),
),
json: true,
};
},
"torrents/add": (_q, form, raw, body) => {
const parts = parseMultipart(body, raw.headers["content-type"] ?? "");
const field = (n: string) => parts.find((p) => p.name === n)?.data.toString();
const added: MockQbTorrent[] = [];
let failed = 0;
const base = {
save_path: field("savepath") || "/downloads",
category: field("category") ?? "",
tags: splitTags(field("tags")),
};
const paused = (field(v5 ? "stopped" : "paused") ?? form.paused) === "true";
const rename = field("rename");
const create = (hash: string, name: string, files: { path: string; length: number }[]) => {
if (torrents.has(hash)) {
failed++;
return;
}
const nm = rename || name;
const size = files.reduce((a, f) => a + f.length, 0);
const t = mk({
hash,
name: nm,
...base,
size,
files: files.map((f) => ({ name: f.path, size: f.length, progress: 0, priority: 1 })),
hiddenPolls: opts.addVisibleAfterPolls ?? 0,
});
if (paused) t.state = pausedState(false);
for (const tg of t.tags) tagDefs.add(tg);
torrents.set(hash, t);
added.push(t);
};
for (const p of parts.filter((x) => x.name === "torrents")) {
try {
const m = inspectTorrent(p.data);
create(m.hash, m.name, m.files);
} catch {
return { status: 415, body: "Torrent file is not valid" };
}
}
for (const u of (field("urls") ?? "").split("\n").map((s) => s.trim())) {
if (!u) continue;
const m = /xt=urn:btih:([0-9a-fA-F]{40})/.exec(u);
if (!m) {
failed++;
continue;
}
const hash = m[1]!.toLowerCase();
const dn = new URLSearchParams(u.slice(u.indexOf("?") + 1)).get("dn") ?? hash;
create(hash, dn, [{ path: dn, length: opts.magnetDefaultSize ?? 1024 * 1024 * 1024 }]);
}
if (addResponse === "json") {
return {
body: JSON.stringify({
success_count: added.length,
failure_count: failed,
pending_count: 0,
added_torrent_ids: added.map((t) => t.hash),
}),
json: true,
};
}
return { body: added.length > 0 ? "Ok." : "Fails." };
},
"torrents/addTags": (_q, form) => {
const tags = splitTags(form.tags);
for (const tg of tags) tagDefs.add(tg);
for (const t of select(form.hashes)) for (const tg of tags) if (!t.tags.includes(tg)) t.tags.push(tg);
return { body: "" };
},
"torrents/removeTags": (_q, form) => {
const tags = splitTags(form.tags);
for (const t of select(form.hashes)) t.tags = t.tags.filter((x) => !tags.includes(x));
return { body: "" };
},
"torrents/deleteTags": (_q, form) => {
const tags = splitTags(form.tags);
for (const tg of tags) tagDefs.delete(tg);
for (const t of torrents.values()) t.tags = t.tags.filter((x) => !tags.includes(x));
return { body: "" };
},
"torrents/filePrio": (_q, form) => {
const t = torrents.get((form.hash ?? "").toLowerCase());
if (!t) return { status: 404, body: "Torrent hash was not found" };
const ids = (form.id ?? "").split("|").map(Number);
if (ids.some((i) => !Number.isInteger(i) || i < 0 || i >= t.files.length))
return { status: 409, body: "invalid file id" };
for (const i of ids) t.files[i]!.priority = Number(form.priority);
return { body: "" };
},
"torrents/setForceStart": (_q, form) => {
for (const t of select(form.hashes)) {
t.force_start = form.value === "true";
if (t.force_start) t.state = t.progress >= 1 ? "forcedUP" : "forcedDL";
}
return { body: "" };
},
"torrents/recheck": (_q, form) => {
for (const t of select(form.hashes)) t.state = t.progress >= 1 ? "checkingUP" : "checkingDL";
return { body: "" };
},
"torrents/setLocation": (_q, form) => {
if (!form.location) return { status: 400, body: "location" };
for (const t of select(form.hashes)) {
t.save_path = form.location;
t.content_path = `${form.location.replace(/\/$/, "")}/${t.name}`;
}
return { body: "" };
},
"torrents/setCategory": (_q, form) => {
for (const t of select(form.hashes)) t.category = form.category ?? "";
return { body: "" };
},
"torrents/setUploadLimit": (_q, form) => {
for (const t of select(form.hashes)) t.up_limit = Number(form.limit);
return { body: "" };
},
"torrents/setDownloadLimit": (_q, form) => {
for (const t of select(form.hashes)) t.dl_limit = Number(form.limit);
return { body: "" };
},
"torrents/setShareLimits": (_q, form) => {
for (const t of select(form.hashes)) {
t.ratio_limit = Number(form.ratioLimit);
t.seeding_time_limit = Number(form.seedingTimeLimit);
}
return { body: "" };
},
"app/preferences": () => ({
body: JSON.stringify({ torrent_content_layout: "Original", incomplete_files_ext: false, ...opts.preferences }),
json: true,
}),
"app/setPreferences": () => ({ status: 500, body: "mock: writing global preferences is not allowed" }),
"transfer/info": () => ({
body: JSON.stringify({ dl_info_speed: 0, up_info_speed: 0, connection_status: "connected" }),
json: true,
}),
"torrents/tags": () => ({ body: JSON.stringify([...tagDefs]), json: true }),
"torrents/delete": (_q, form) => {
if (form.deleteFiles !== "true" && form.deleteFiles !== "false") return { status: 400, body: "deleteFiles" };
for (const t of select(form.hashes)) torrents.delete(t.hash);
return { body: "" };
},
"sync/maindata": () => {
rid++;
return {
body: JSON.stringify({
rid,
full_update: true,
torrents: Object.fromEntries([...torrents.values()].map((t) => [t.hash, toInfo(t)])),
server_state: { free_space_on_disk: 1e12 },
}),
json: true,
};
},
};
const pauseH: Handler = (_q, form) => {
for (const t of select(form.hashes)) t.state = pausedState(t.progress >= 1);
return { body: "" };
};
const resumeH: Handler = (_q, form) => {
for (const t of select(form.hashes)) t.state = t.progress >= 1 ? "stalledUP" : "downloading";
return { body: "" };
};
if (v5) {
handlers["torrents/stop"] = pauseH;
handlers["torrents/start"] = resumeH;
} else {
handlers["torrents/pause"] = pauseH;
handlers["torrents/resume"] = resumeH;
}
const server: Server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
const u = new URL(req.url ?? "/", "http://mock");
const endpoint = u.pathname.replace(/^\/api\/v2\//, "");
const query = Object.fromEntries(u.searchParams);
const body = await readBody(req);
const ct = req.headers["content-type"] ?? "";
const form = ct.includes("urlencoded") ? Object.fromEntries(new URLSearchParams(body.toString())) : {};
const call: MockQbCall = { method: req.method ?? "GET", endpoint, query, form, status: 0 };
calls.push(call);
const send = (status: number, text: string, headers: Record<string, string> = {}) => {
call.status = status;
res.writeHead(status, { "content-type": "text/plain; charset=utf-8", ...headers });
res.end(text);
};
const fq = faults.get(endpoint);
const fault = fq?.[0];
if (fault) {
fault.times = (fault.times ?? 1) - 1;
if (fault.times <= 0) fq!.shift();
if (fault.drop) {
call.status = -1;
req.socket.destroy();
return;
}
if (fault.status) return send(fault.status, fault.body ?? "");
}
if (endpoint === "auth/login") {
if (form.username === user && form.password === pass) {
const sid = randomBytes(16).toString("hex");
sessions.add(sid);
return send(200, "Ok.", { "set-cookie": `SID=${sid}; HttpOnly; path=/` });
}
return send(200, "Fails.");
}
const bearer = /^Bearer (.+)$/.exec(req.headers.authorization ?? "")?.[1];
const sid = parseCookies(req.headers.cookie).SID;
const authed = (opts.apiKey && bearer === opts.apiKey) || (sid !== undefined && sessions.has(sid));
if (!authed) return send(403, "Forbidden");
const h = handlers[endpoint];
if (!h) return send(404, "Not Found");
const r = h(query, form, req, body);
if (fault?.dropAfterApply) {
call.status = -1;
req.socket.destroy();
return;
}
if (fault?.delayMs) await new Promise((ok) => setTimeout(ok, fault.delayMs));
if (res.destroyed) return;
send(r.status ?? 200, r.body, r.json ? { "content-type": "application/json" } : {});
});
await new Promise<void>((ok) => server.listen(opts.port ?? 0, "127.0.0.1", ok));
const { port } = server.address() as AddressInfo;
return {
url: `http://127.0.0.1:${port}`,
calls,
close: () =>
new Promise<void>((ok) => {
server.closeAllConnections();
server.close(() => ok());
}),
torrents: () => [...torrents.values()],
get: (h) => torrents.get(h.toLowerCase()),
tagDefinitions: () => [...tagDefs],
seed: (t) => {
const m = mk({ ...t, hash: t.hash.toLowerCase() });
for (const tg of m.tags) tagDefs.add(tg);
torrents.set(m.hash, m);
return m;
},
setProgress: (h, p) => setProgress(need(h), p),
complete: (h) => setProgress(need(h), 1),
setState: (h, s) => {
need(h).state = s;
},
setError: (h) => {
need(h).state = "error";
},
setMissingFiles: (h) => {
need(h).state = "missingFiles";
},
fault: (endpoint, f) => {
const q = faults.get(endpoint) ?? [];
q.push({ ...f });
faults.set(endpoint, q);
},
clearFaults: () => faults.clear(),
expireSessions: () => sessions.clear(),
callsTo: (e) => calls.filter((c) => c.endpoint === e),
};
}