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

+44
View File
@@ -0,0 +1,44 @@
export type FsErrorCode =
| "EXDEV" // 跨设备,不会自动降级
| "EEXIST" // 目标已存在(no-clobber)
| "ENOENT"
| "EACCES"
| "ENOSPC"
| "VERIFY_FAILED"
| "SOURCE_CHANGED"
| "ROOT_OFFLINE"
| "ABORTED"
| "IO";
export class FsError extends Error {
override readonly name = "FsError";
constructor(
readonly code: FsErrorCode,
message: string,
readonly detail: { path?: string; syscall?: string; cause?: unknown } = {},
) {
super(message);
}
}
export function errnoOf(e: unknown): string | undefined {
return (e as NodeJS.ErrnoException | undefined)?.code;
}
export function toFsError(e: unknown, path?: string): FsError {
if (e instanceof FsError) return e;
const code = errnoOf(e);
const mapped: FsErrorCode =
code === "EXDEV" || code === "EEXIST" || code === "ENOENT" || code === "ENOSPC"
? code
: code === "EACCES" || code === "EPERM"
? "EACCES"
: (e as Error)?.name === "AbortError"
? "ABORTED"
: "IO";
return new FsError(mapped, (e as Error)?.message ?? String(e), {
...(path !== undefined ? { path } : {}),
...((e as NodeJS.ErrnoException)?.syscall ? { syscall: (e as NodeJS.ErrnoException).syscall! } : {}),
cause: e,
});
}