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,130 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
/** 测试用 bencode 编解码与种子构造器(独立于 adapters 实现,用于交叉校验 infohash)。 */
|
||||
export type BVal = number | string | Uint8Array | BVal[] | { [k: string]: BVal };
|
||||
|
||||
export function bencode(v: BVal): Buffer {
|
||||
if (typeof v === "number") return Buffer.from(`i${Math.trunc(v)}e`);
|
||||
if (typeof v === "string") {
|
||||
const b = Buffer.from(v, "utf8");
|
||||
return Buffer.concat([Buffer.from(`${b.length}:`), b]);
|
||||
}
|
||||
if (v instanceof Uint8Array) return Buffer.concat([Buffer.from(`${v.length}:`), v]);
|
||||
if (Array.isArray(v)) return Buffer.concat([Buffer.from("l"), ...v.map(bencode), Buffer.from("e")]);
|
||||
const keys = Object.keys(v).sort((a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b)));
|
||||
return Buffer.concat([Buffer.from("d"), ...keys.flatMap((k) => [bencode(k), bencode(v[k]!)]), Buffer.from("e")]);
|
||||
}
|
||||
|
||||
type Dec = number | Buffer | Dec[] | { [k: string]: Dec };
|
||||
|
||||
export function bdecodeLoose(buf: Buffer): Dec {
|
||||
let pos = 0;
|
||||
const readUntil = (ch: string) => {
|
||||
const end = buf.indexOf(ch, pos);
|
||||
if (end < 0) throw new Error("bad bencode");
|
||||
const s = buf.subarray(pos, end).toString();
|
||||
pos = end + 1;
|
||||
return s;
|
||||
};
|
||||
const val = (): Dec => {
|
||||
const c = String.fromCharCode(buf[pos]!);
|
||||
if (c === "i") {
|
||||
pos++;
|
||||
return Number(readUntil("e"));
|
||||
}
|
||||
if (c === "l") {
|
||||
pos++;
|
||||
const out: Dec[] = [];
|
||||
while (buf[pos] !== 0x65) out.push(val());
|
||||
pos++;
|
||||
return out;
|
||||
}
|
||||
if (c === "d") {
|
||||
pos++;
|
||||
const out: { [k: string]: Dec } = {};
|
||||
while (buf[pos] !== 0x65) {
|
||||
const k = (val() as Buffer).toString();
|
||||
out[k] = val();
|
||||
}
|
||||
pos++;
|
||||
return out;
|
||||
}
|
||||
const n = Number(readUntil(":"));
|
||||
const b = buf.subarray(pos, pos + n);
|
||||
pos += n;
|
||||
return b;
|
||||
};
|
||||
return val();
|
||||
}
|
||||
|
||||
export interface MakeTorrentInput {
|
||||
name: string;
|
||||
/** 省略则为单文件种子,长度用 length */
|
||||
files?: { path: string; length: number }[];
|
||||
length?: number;
|
||||
pieceLength?: number;
|
||||
private?: boolean;
|
||||
announce?: string;
|
||||
/** 改变伪 pieces 内容,用于构造同名同大小但 infohash 不同的种子 */
|
||||
pieceSeed?: number;
|
||||
}
|
||||
|
||||
export interface MadeTorrent {
|
||||
bytes: Buffer;
|
||||
hash: string;
|
||||
name: string;
|
||||
totalSize: number;
|
||||
files: { path: string; length: number }[];
|
||||
}
|
||||
|
||||
/** 构造一个合法的 v1 .torrent(pieces 为伪值,不可真正下载)。 */
|
||||
export function makeTorrent(input: MakeTorrentInput): MadeTorrent {
|
||||
const pieceLength = input.pieceLength ?? 4 * 1024 * 1024;
|
||||
const info: { [k: string]: BVal } = { name: input.name, "piece length": pieceLength };
|
||||
let files: { path: string; length: number }[];
|
||||
if (input.files) {
|
||||
info.files = input.files.map((f) => ({ length: f.length, path: f.path.split("/") }));
|
||||
files = input.files.map((f) => ({ path: `${input.name}/${f.path}`, length: f.length }));
|
||||
} else {
|
||||
info.length = input.length ?? 1024 * 1024 * 1024;
|
||||
files = [{ path: input.name, length: info.length as number }];
|
||||
}
|
||||
const total = files.reduce((a, f) => a + f.length, 0);
|
||||
const pieces = Math.max(1, Math.ceil(total / pieceLength));
|
||||
info.pieces = Buffer.alloc(20 * Math.min(pieces, 4), input.pieceSeed ?? 7);
|
||||
if (input.private) info.private = 1;
|
||||
const root: { [k: string]: BVal } = { announce: input.announce ?? "https://tracker.example.invalid/announce", info };
|
||||
const bytes = bencode(root);
|
||||
const hash = createHash("sha1").update(bencode(info)).digest("hex");
|
||||
return { bytes, hash, name: input.name, totalSize: total, files };
|
||||
}
|
||||
|
||||
export function makeMagnet(hash: string, name?: string): string {
|
||||
return `magnet:?xt=urn:btih:${hash}${name ? `&dn=${encodeURIComponent(name)}` : ""}`;
|
||||
}
|
||||
|
||||
/** 从 .torrent 字节解析(mock 服务器使用):hash = sha1(bencode(info)) */
|
||||
export function inspectTorrent(bytes: Buffer): MadeTorrent {
|
||||
const root = bdecodeLoose(bytes) as { [k: string]: Dec };
|
||||
const info = root.info as { [k: string]: Dec };
|
||||
if (!info) throw new Error("no info");
|
||||
const reenc = (d: Dec): BVal =>
|
||||
Buffer.isBuffer(d)
|
||||
? d
|
||||
: Array.isArray(d)
|
||||
? d.map(reenc)
|
||||
: typeof d === "number"
|
||||
? d
|
||||
: Object.fromEntries(Object.entries(d).map(([k, v]) => [k, reenc(v)]));
|
||||
const hash = createHash("sha1")
|
||||
.update(bencode(reenc(info)))
|
||||
.digest("hex");
|
||||
const name = (info.name as Buffer).toString();
|
||||
const files = Array.isArray(info.files)
|
||||
? (info.files as { [k: string]: Dec }[]).map((f) => ({
|
||||
path: [name, ...(f.path as Buffer[]).map((p) => p.toString())].join("/"),
|
||||
length: f.length as number,
|
||||
}))
|
||||
: [{ path: name, length: info.length as number }];
|
||||
return { bytes, hash, name, totalSize: files.reduce((a, f) => a + f.length, 0), files };
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { IncomingMessage } from "node:http";
|
||||
|
||||
export async function readBody(req: IncomingMessage): Promise<Buffer> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const c of req) chunks.push(c as Buffer);
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
export interface MultipartPart {
|
||||
name: string;
|
||||
filename?: string;
|
||||
data: Buffer;
|
||||
}
|
||||
|
||||
/** 极简 multipart/form-data 解析,仅供 mock 使用。 */
|
||||
export function parseMultipart(body: Buffer, contentType: string): MultipartPart[] {
|
||||
const m = /boundary=(?:"([^"]+)"|([^;]+))/i.exec(contentType);
|
||||
if (!m) return [];
|
||||
const boundary = Buffer.from(`--${m[1] ?? m[2]}`);
|
||||
const parts: MultipartPart[] = [];
|
||||
let pos = body.indexOf(boundary);
|
||||
while (pos >= 0) {
|
||||
const start = pos + boundary.length;
|
||||
if (body.subarray(start, start + 2).toString() === "--") break;
|
||||
const next = body.indexOf(boundary, start);
|
||||
if (next < 0) break;
|
||||
const chunk = body.subarray(start + 2, next - 2); // 去掉起始 CRLF 与结尾 CRLF
|
||||
const sep = chunk.indexOf("\r\n\r\n");
|
||||
const head = chunk.subarray(0, sep).toString();
|
||||
const data = chunk.subarray(sep + 4);
|
||||
const name = /name="([^"]*)"/.exec(head)?.[1] ?? "";
|
||||
const filename = /filename="([^"]*)"/.exec(head)?.[1];
|
||||
parts.push({ name, ...(filename !== undefined ? { filename } : {}), data });
|
||||
pos = next;
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
export function parseCookies(header: string | undefined): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const p of (header ?? "").split(";")) {
|
||||
const i = p.indexOf("=");
|
||||
if (i > 0) out[p.slice(0, i).trim()] = p.slice(i + 1).trim();
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
inspectTorrent as inspectTorrentFixture,
|
||||
type MadeTorrent,
|
||||
type MakeTorrentInput,
|
||||
makeMagnet,
|
||||
makeTorrent,
|
||||
} from "./bencode";
|
||||
export * from "./mock-qb";
|
||||
@@ -0,0 +1,530 @@
|
||||
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),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user