- 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]>
531 lines
18 KiB
TypeScript
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),
|
|
};
|
|
}
|