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:
rootandClaude Fable 5.1 committed 2026-09-27 18:07:48 +08:00
1 parent 9c881e737f
commit ef2e504a94
45 files changed
+5766 -1

No files matched your search

+130
View File
@@ -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 };
}