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

+1
View File
@@ -0,0 +1 @@
export * from "./temp-tree";
+60
View File
@@ -0,0 +1,60 @@
import { randomBytes } from "node:crypto";
import { existsSync, statSync } from "node:fs";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
export interface TempTree {
root: string;
/** 根目录下的绝对路径 */
path(rel: string): string;
/** 写文件(自动建父目录);content 为数字时写入该长度的随机字节 */
write(rel: string, content: string | Buffer | number): Promise<string>;
cleanup(): Promise<void>;
}
/**
* 临时目录夹具。spec 的 key 为相对路径(以 / 结尾表示目录),value 同 write。
* sentinel 为真时写入库根哨兵 .uhd-root。
*/
export async function createTempTree(
spec: Record<string, string | Buffer | number | null> = {},
opts: { base?: string; sentinel?: boolean | string; prefix?: string } = {},
): Promise<TempTree> {
const root = await mkdtemp(join(opts.base ?? tmpdir(), opts.prefix ?? "umt-fs-"));
const tree: TempTree = {
root,
path: (rel) => join(root, rel),
write: async (rel, content) => {
const p = join(root, rel);
await mkdir(dirname(p), { recursive: true });
await writeFile(p, typeof content === "number" ? randomBytes(content) : content);
return p;
},
cleanup: () => rm(root, { recursive: true, force: true }),
};
if (opts.sentinel) {
await writeFile(join(root, typeof opts.sentinel === "string" ? opts.sentinel : ".uhd-root"), "sentinel\n");
}
for (const [rel, v] of Object.entries(spec)) {
if (rel.endsWith("/") || v === null) await mkdir(join(root, rel), { recursive: true });
else await tree.write(rel, v);
}
return tree;
}
/**
* 找一个与 os.tmpdir() 不在同一设备上的可写目录(优先 /dev/shm),用于 EXDEV 测试。
* 找不到返回 null,测试应 skip 并注明。
*/
export function findCrossDeviceBase(candidates = ["/dev/shm", "/run/user", "/var/tmp"]): string | null {
const baseDev = statSync(tmpdir()).dev;
for (const c of candidates) {
try {
if (existsSync(c) && statSync(c).dev !== baseDev) return c;
} catch {
// ignore
}
}
return null;
}