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

+122
View File
@@ -0,0 +1,122 @@
import { type MockHttp, type MockReply, startMockHttp } from "../tmdb/mock-http";
import { EMBY_FIXTURE_ITEMS, EMBY_FIXTURE_USERS, type EmbyFixtureItem } from "./fixtures";
export interface MockEmbyOptions {
flavor?: "emby" | "jellyfin";
apiKey?: string;
items?: EmbyFixtureItem[];
users?: { Id: string; Name: string; Policy?: { IsAdministrator?: boolean } }[];
port?: number;
}
export type MockEmby = MockHttp & { items: EmbyFixtureItem[]; flavor: "emby" | "jellyfin" };
/**
* mock Emby / Jellyfin:/Users、/Users/{uid}/Items、/Items、/Users/{uid}/Items/{id}、/Shows/{id}/Seasons、
* /Shows/{id}/Episodes、/System/Info/Public。Emby 支持 AnyProviderIdEquals;Jellyfin flavor 忽略该参数。
* 路径可带 /emby 前缀。Fields 未请求的字段(ProviderIds/Path/MediaSources/OriginalTitle)不返回。
*/
export async function createMockEmby(opts: MockEmbyOptions = {}): Promise<MockEmby> {
const flavor = opts.flavor ?? "emby";
const apiKey = opts.apiKey ?? "test-emby-key";
const items = opts.items ?? EMBY_FIXTURE_ITEMS;
const users = opts.users ?? EMBY_FIXTURE_USERS;
const project = (it: EmbyFixtureItem, fields: Set<string>) => {
const { ProviderIds, Path, MediaSources, OriginalTitle, ...rest } = it;
return {
...rest,
...(fields.has("providerids") && ProviderIds ? { ProviderIds } : {}),
...(fields.has("path") && Path ? { Path } : {}),
...(fields.has("mediasources") && MediaSources ? { MediaSources } : {}),
...(fields.has("originaltitle") && OriginalTitle ? { OriginalTitle } : {}),
};
};
const fieldsOf = (q: Record<string, string>) =>
new Set(
(q.Fields ?? q.fields ?? "")
.split(",")
.map((s) => s.trim().toLowerCase())
.filter(Boolean),
);
const page = (list: EmbyFixtureItem[], q: Record<string, string>): MockReply => {
const start = Number(q.StartIndex ?? 0);
const limit = q.Limit ? Number(q.Limit) : list.length;
const f = fieldsOf(q);
return {
json: { Items: list.slice(start, start + limit).map((i) => project(i, f)), TotalRecordCount: list.length },
};
};
const knownUser = (uid: string | undefined) => !uid || users.some((u) => u.Id === uid);
const unauthorizedUser: MockReply = { status: 400, json: "Invalid user" };
const queryItems = (q: Record<string, string>) => {
let list = items;
const types = (q.IncludeItemTypes ?? "").split(",").filter(Boolean);
if (types.length) list = list.filter((i) => types.includes(i.Type));
if (q.ParentId) list = list.filter((i) => i.SeriesId === q.ParentId || i.SeasonId === q.ParentId);
const term = (q.SearchTerm ?? q.searchTerm ?? "").toLowerCase();
if (term) list = list.filter((i) => [i.Name, i.OriginalTitle].some((t) => t?.toLowerCase().includes(term)));
if (flavor === "emby" && q.AnyProviderIdEquals) {
const wanted = q.AnyProviderIdEquals.split(",").map((s) => s.toLowerCase());
list = list.filter((i) =>
Object.entries(i.ProviderIds ?? {}).some(([k, v]) => wanted.includes(`${k.toLowerCase()}.${v.toLowerCase()}`)),
);
}
return list;
};
const r = (re: string) => new RegExp(`^(?:/emby)?${re}$`, "i");
const http = await startMockHttp(
[
[
r("/System/Info/Public"),
() => ({ json: { ServerName: "mock", Version: flavor === "emby" ? "4.8.10.0" : "10.10.3", Id: "srv" } }),
],
[r("/Users"), () => ({ json: users })],
[
r("/Users/([^/]+)/Items/([^/]+)"),
(m, q) => {
if (!knownUser(m[1])) return unauthorizedUser;
const it = items.find((i) => i.Id === m[2]);
return it
? { json: project(it, fieldsOf({ Fields: q.Fields ?? "ProviderIds,Path,MediaSources,OriginalTitle" })) }
: { status: 404, json: "Not Found" };
},
],
[r("/Users/([^/]+)/Items"), (m, q) => (knownUser(m[1]) ? page(queryItems(q), q) : unauthorizedUser)],
[r("/Items"), (_m, q) => (knownUser(q.UserId ?? q.userId) ? page(queryItems(q), q) : unauthorizedUser)],
[
r("/Shows/([^/]+)/Seasons"),
(m, q) => {
if (!items.some((i) => i.Id === m[1] && i.Type === "Series")) return { status: 404, json: "Not Found" };
return page(
items.filter((i) => i.Type === "Season" && i.SeriesId === m[1]),
q,
);
},
],
[
r("/Shows/([^/]+)/Episodes"),
(m, q) => {
if (!items.some((i) => i.Id === m[1] && i.Type === "Series")) return { status: 404, json: "Not Found" };
let list = items.filter((i) => i.Type === "Episode" && i.SeriesId === m[1]);
if (q.SeasonId) list = list.filter((i) => i.SeasonId === q.SeasonId);
if (q.Season) list = list.filter((i) => String(i.ParentIndexNumber) === q.Season);
return page(list, q);
},
],
],
{
...(opts.port !== undefined ? { port: opts.port } : {}),
guard: (req, q) => {
if (/\/System\/Info\/Public$/i.test(new URL(req.url ?? "/", "http://x").pathname)) return null;
const h = req.headers["x-emby-token"];
const auth = /Token="([^"]+)"/.exec(req.headers.authorization ?? "")?.[1];
const ok = h === apiKey || auth === apiKey || q.api_key === apiKey || q.ApiKey === apiKey;
return ok ? null : { status: 401, json: "Access token is invalid or expired." };
},
},
);
return Object.assign(http, { items, flavor });
}