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; form: Record; 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; /** 磁力无元数据时的默认大小 */ magnetDefaultSize?: number; port?: number; } export interface MockQb { url: string; calls: MockQbCall[]; close(): Promise; torrents(): MockQbTorrent[]; get(hash: string): MockQbTorrent | undefined; tagDefinitions(): string[]; seed(t: Partial & { 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 { 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(); const tagDefs = new Set(); const sessions = new Set(); const faults = new Map(); const calls: MockQbCall[] = []; let rid = 0; const pausedState = (done: boolean) => (v5 ? (done ? "stoppedUP" : "stoppedDL") : done ? "pausedUP" : "pausedDL"); function mk(t: Partial & { 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, form: Record, raw: IncomingMessage, body: Buffer, ) => { status?: number; body: string; json?: boolean; headers?: Record; }; const handlers: Record = { "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 = {}) => { 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((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((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), }; }