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,104 @@
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
|
||||
/** 通用 JSON mock 服务底座:路由、调用日志、可编程故障。供 mock TMDB / mock Emby 使用。 */
|
||||
export interface MockCall {
|
||||
method: string;
|
||||
path: string;
|
||||
query: Record<string, string>;
|
||||
headers: Record<string, string | string[] | undefined>;
|
||||
status: number;
|
||||
}
|
||||
|
||||
export interface MockFault {
|
||||
status?: number;
|
||||
body?: unknown;
|
||||
headers?: Record<string, string>;
|
||||
delayMs?: number;
|
||||
/** 直接断开连接 */
|
||||
drop?: boolean;
|
||||
times?: number;
|
||||
}
|
||||
|
||||
export interface MockReply {
|
||||
status?: number;
|
||||
json?: unknown;
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
export type MockRoute = (m: RegExpMatchArray, q: Record<string, string>, req: IncomingMessage) => MockReply;
|
||||
|
||||
export interface MockHttp {
|
||||
url: string;
|
||||
calls: MockCall[];
|
||||
close(): Promise<void>;
|
||||
/** 对路径匹配 pattern(字符串前缀或正则)的请求注入故障 */
|
||||
fault(pattern: string | RegExp, f: MockFault): void;
|
||||
clearFaults(): void;
|
||||
callsTo(pattern: string | RegExp): MockCall[];
|
||||
}
|
||||
|
||||
const matches = (p: string | RegExp, path: string) => (typeof p === "string" ? path.startsWith(p) : p.test(path));
|
||||
|
||||
export async function startMockHttp(
|
||||
routes: [RegExp, MockRoute][],
|
||||
opts: { port?: number; guard?: (req: IncomingMessage, q: Record<string, string>) => MockReply | null } = {},
|
||||
): Promise<MockHttp> {
|
||||
const calls: MockCall[] = [];
|
||||
const faults: { pattern: string | RegExp; f: MockFault }[] = [];
|
||||
const server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
|
||||
const u = new URL(req.url ?? "/", "http://mock");
|
||||
const q = Object.fromEntries(u.searchParams);
|
||||
const call: MockCall = { method: req.method ?? "GET", path: u.pathname, query: q, headers: req.headers, status: 0 };
|
||||
calls.push(call);
|
||||
const reply = (r: MockReply) => {
|
||||
call.status = r.status ?? 200;
|
||||
if (res.destroyed) return;
|
||||
res.writeHead(call.status, { "content-type": "application/json", ...r.headers });
|
||||
res.end(r.json === undefined ? "" : JSON.stringify(r.json));
|
||||
};
|
||||
const fi = faults.findIndex((x) => matches(x.pattern, u.pathname));
|
||||
const fault = fi >= 0 ? faults[fi]!.f : undefined;
|
||||
if (fault) {
|
||||
fault.times = (fault.times ?? 1) - 1;
|
||||
if (fault.times <= 0) faults.splice(fi, 1);
|
||||
if (fault.delayMs) await new Promise((ok) => setTimeout(ok, fault.delayMs));
|
||||
if (fault.drop) {
|
||||
call.status = -1;
|
||||
req.socket.destroy();
|
||||
return;
|
||||
}
|
||||
if (fault.status)
|
||||
return reply({ status: fault.status, json: fault.body, ...(fault.headers ? { headers: fault.headers } : {}) });
|
||||
}
|
||||
const denied = opts.guard?.(req, q);
|
||||
if (denied) return reply(denied);
|
||||
for (const [re, h] of routes) {
|
||||
const m = re.exec(u.pathname);
|
||||
if (m) {
|
||||
try {
|
||||
return reply(h(m, q, req));
|
||||
} catch (e) {
|
||||
return reply({ status: 500, json: { error: String(e) } });
|
||||
}
|
||||
}
|
||||
}
|
||||
reply({ status: 404, json: { error: "no route" } });
|
||||
});
|
||||
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());
|
||||
}),
|
||||
fault: (pattern, f) => faults.push({ pattern, f: { ...f } }),
|
||||
clearFaults: () => {
|
||||
faults.length = 0;
|
||||
},
|
||||
callsTo: (p) => calls.filter((c) => matches(p, c.path)),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user