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

+219
View File
@@ -0,0 +1,219 @@
/**
* mock TMDB 固定数据。电影/剧集 ID 取自公开 TMDB 条目以便对照,但标题/集数/日期只保证"合理",
* 不作为真实数据的权威来源;国产综艺条目 ID(900001)为虚构。
*/
export interface TmdbFixtureEpisode {
episode_number: number;
air_date: string | null;
names: Record<string, string>;
}
export interface TmdbFixtureSeason {
season_number: number;
air_date: string | null;
names: Record<string, string>;
episodes: TmdbFixtureEpisode[];
}
export interface TmdbFixture {
type: "movie" | "tv";
id: number;
original_title: string;
original_language: string;
origin_country: string[];
date: string;
titles: Record<string, string>;
overviews?: Record<string, string>;
popularity: number;
alternative_titles: { iso_3166_1: string; title: string; type: string }[];
external_ids: { imdb_id?: string | null; tvdb_id?: number | null };
/** 额外译名(titles 中的语言会自动生成 translations) */
extra_translations?: { iso_639_1: string; iso_3166_1: string; title: string }[];
seasons?: TmdbFixtureSeason[];
}
function eps(n: number, start: string, zh: (i: number) => string, en: (i: number) => string): TmdbFixtureEpisode[] {
const base = Date.parse(start);
return Array.from({ length: n }, (_, k) => ({
episode_number: k + 1,
air_date: new Date(base + k * 7 * 86400_000).toISOString().slice(0, 10),
names: { "zh-CN": zh(k + 1), "en-US": en(k + 1) },
}));
}
export const TMDB_FIXTURES: TmdbFixture[] = [
{
type: "movie",
id: 872585,
original_title: "Oppenheimer",
original_language: "en",
origin_country: ["US"],
date: "2023-07-19",
titles: { "zh-CN": "奥本海默", "en-US": "Oppenheimer" },
overviews: { "zh-CN": "原子弹之父的故事。", "en-US": "The story of J. Robert Oppenheimer." },
popularity: 120.5,
alternative_titles: [
{ iso_3166_1: "TW", title: "奧本海默", type: "" },
{ iso_3166_1: "CN", title: "奥本海默传", type: "" },
],
external_ids: { imdb_id: "tt15398776" },
extra_translations: [{ iso_639_1: "ja", iso_3166_1: "JP", title: "オッペンハイマー" }],
},
{
type: "movie",
id: 693134,
original_title: "Dune: Part Two",
original_language: "en",
origin_country: ["US"],
date: "2024-02-27",
titles: { "zh-CN": "沙丘2", "en-US": "Dune: Part Two" },
popularity: 210.1,
alternative_titles: [
{ iso_3166_1: "US", title: "Dune 2", type: "" },
{ iso_3166_1: "CN", title: "沙丘:第二部", type: "" },
{ iso_3166_1: "TW", title: "沙丘:第二部", type: "" },
],
external_ids: { imdb_id: "tt15239678" },
},
{
type: "movie",
id: 842675,
original_title: "流浪地球2",
original_language: "zh",
origin_country: ["CN"],
date: "2023-01-22",
titles: { "zh-CN": "流浪地球2", "en-US": "The Wandering Earth II" },
popularity: 45.3,
alternative_titles: [
{ iso_3166_1: "US", title: "The Wandering Earth 2", type: "" },
{ iso_3166_1: "CN", title: "流浪地球 2", type: "" },
],
external_ids: { imdb_id: "tt13539646" },
},
{
type: "tv",
id: 136315,
original_title: "The Bear",
original_language: "en",
origin_country: ["US"],
date: "2022-06-23",
titles: { "zh-CN": "熊家餐馆", "en-US": "The Bear" },
popularity: 88.2,
alternative_titles: [
{ iso_3166_1: "TW", title: "大熊餐廳", type: "" },
{ iso_3166_1: "CN", title: "熊家小馆", type: "" },
],
external_ids: { imdb_id: "tt14452776", tvdb_id: 401654 },
seasons: [
{
season_number: 1,
air_date: "2022-06-23",
names: { "zh-CN": "第 1 季", "en-US": "Season 1" },
episodes: eps(
8,
"2022-06-23",
(i) => `第 ${i} 集`,
(i) => `Episode ${i}`,
),
},
{
season_number: 2,
air_date: "2023-06-22",
names: { "zh-CN": "第 2 季", "en-US": "Season 2" },
episodes: eps(
10,
"2023-06-22",
(i) => `第 ${i} 集`,
(i) => `Episode ${i}`,
),
},
{
season_number: 3,
air_date: "2024-06-26",
names: { "zh-CN": "第 3 季", "en-US": "Season 3" },
episodes: eps(
10,
"2024-06-26",
(i) => `第 ${i} 集`,
(i) => `Episode ${i}`,
),
},
],
},
{
type: "tv",
id: 100088,
original_title: "The Last of Us",
original_language: "en",
origin_country: ["US"],
date: "2023-01-15",
titles: { "zh-CN": "最后生还者", "en-US": "The Last of Us" },
popularity: 150.7,
alternative_titles: [
{ iso_3166_1: "CN", title: "最后的生还者", type: "" },
{ iso_3166_1: "TW", title: "最後生還者", type: "" },
],
external_ids: { imdb_id: "tt3581920", tvdb_id: 392256 },
seasons: [
{
season_number: 1,
air_date: "2023-01-15",
names: { "zh-CN": "第 1 季", "en-US": "Season 1" },
episodes: eps(
9,
"2023-01-15",
(i) => `第 ${i} 集`,
(i) => `Episode ${i}`,
),
},
],
},
{
type: "tv",
id: 900001,
original_title: "向往的生活",
original_language: "zh",
origin_country: ["CN"],
date: "2017-01-15",
titles: { "zh-CN": "向往的生活", "en-US": "Back to Field" },
popularity: 12.4,
alternative_titles: [
{ iso_3166_1: "CN", title: "向往的生活 第一季", type: "" },
{ iso_3166_1: "CN", title: "向往", type: "简称" },
],
external_ids: { imdb_id: null, tvdb_id: null },
seasons: [
{
season_number: 0,
air_date: "2017-01-10",
names: { "zh-CN": "特别篇", "en-US": "Specials" },
episodes: eps(
2,
"2017-01-10",
(i) => `先导片 ${i}`,
(i) => `Special ${i}`,
),
},
{
season_number: 1,
air_date: "2017-01-15",
names: { "zh-CN": "第 1 季", "en-US": "Season 1" },
episodes: eps(
12,
"2017-01-15",
(i) => `第 ${i} 期`,
(i) => `Episode ${i}`,
),
},
{
season_number: 2,
air_date: "2018-04-20",
names: { "zh-CN": "第 2 季", "en-US": "Season 2" },
episodes: eps(
3,
"2018-04-20",
(i) => `第 ${i} 期`,
(i) => `Episode ${i}`,
),
},
],
},
];
+3
View File
@@ -0,0 +1,3 @@
export * from "./fixtures";
export * from "./mock-http";
export * from "./mock-tmdb";
+104
View File
@@ -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)),
};
}
+158
View File
@@ -0,0 +1,158 @@
import { TMDB_FIXTURES, type TmdbFixture } from "./fixtures";
import { type MockHttp, type MockReply, startMockHttp } from "./mock-http";
export interface MockTmdbOptions {
apiKey?: string;
accessToken?: string;
fixtures?: TmdbFixture[];
port?: number;
}
export type MockTmdb = MockHttp & { fixtures: TmdbFixture[] };
const norm = (s: string) => s.toLowerCase().replace(/[\s\p{P}\p{S}]+/gu, "");
const pick = (m: Record<string, string>, lang: string | undefined, fb: string) =>
m[lang ?? "en-US"] ?? m[(lang ?? "").split("-")[0] ?? ""] ?? m["en-US"] ?? fb;
const notFound: MockReply = {
status: 404,
json: { success: false, status_code: 34, status_message: "The resource you requested could not be found." },
};
/** mock TMDB v3:/3/search/{movie,tv}、/3/{movie,tv}/:id(append_to_response)、/3/tv/:id/season/:n */
export async function createMockTmdb(opts: MockTmdbOptions = {}): Promise<MockTmdb> {
const fx = opts.fixtures ?? TMDB_FIXTURES;
const apiKey = opts.apiKey ?? "test-tmdb-key";
const find = (type: string, id: string) => fx.find((f) => f.type === type && String(f.id) === id);
const matchText = (f: TmdbFixture, needle: string) =>
[f.original_title, ...Object.values(f.titles), ...f.alternative_titles.map((a) => a.title)].some((t) =>
norm(t).includes(needle),
);
const brief = (f: TmdbFixture, lang?: string) => {
const title = pick(f.titles, lang, f.original_title);
const overview = f.overviews ? pick(f.overviews, lang, "") : "";
return f.type === "movie"
? {
id: f.id,
title,
original_title: f.original_title,
release_date: f.date,
original_language: f.original_language,
overview,
popularity: f.popularity,
}
: {
id: f.id,
name: title,
original_name: f.original_title,
first_air_date: f.date,
original_language: f.original_language,
origin_country: f.origin_country,
overview,
popularity: f.popularity,
};
};
const http = await startMockHttp(
[
[
/^\/3\/search\/multi$/,
(_m, q) => {
const needle = norm(q.query ?? "");
const results = fx
.filter((f) => needle && matchText(f, needle))
.sort((a, b) => b.popularity - a.popularity)
.map((f) => ({ ...brief(f, q.language), media_type: f.type }));
return { json: { page: 1, results, total_pages: 1, total_results: results.length } };
},
],
[
/^\/3\/search\/(movie|tv)$/,
(m, q) => {
const type = m[1]!;
const needle = norm(q.query ?? "");
const year = q.year ?? q.primary_release_year ?? q.first_air_date_year;
const results = fx
.filter((f) => f.type === type)
.filter((f) => !year || f.date.startsWith(year))
.filter((f) => !!needle && matchText(f, needle))
.sort((a, b) => b.popularity - a.popularity)
.map((f) => brief(f, q.language));
return { json: { page: 1, results, total_pages: 1, total_results: results.length } };
},
],
[
/^\/3\/tv\/(\d+)\/season\/(\d+)$/,
(m, q) => {
const f = find("tv", m[1]!);
const s = f?.seasons?.find((x) => String(x.season_number) === m[2]);
if (!f || !s) return notFound;
return {
json: {
id: f.id * 100 + s.season_number,
season_number: s.season_number,
name: pick(s.names, q.language, `Season ${s.season_number}`),
air_date: s.air_date,
episodes: s.episodes.map((e) => ({
episode_number: e.episode_number,
season_number: s.season_number,
name: pick(e.names, q.language, ""),
air_date: e.air_date,
})),
},
};
},
],
[
/^\/3\/(movie|tv)\/(\d+)$/,
(m, q) => {
const f = find(m[1]!, m[2]!);
if (!f) return notFound;
const append = new Set((q.append_to_response ?? "").split(",").filter(Boolean));
const out: Record<string, unknown> = { ...brief(f, q.language), origin_country: f.origin_country };
if (f.type === "tv") {
out.number_of_seasons = f.seasons?.filter((s) => s.season_number > 0).length ?? 0;
out.seasons = (f.seasons ?? []).map((s) => ({
season_number: s.season_number,
episode_count: s.episodes.length,
name: pick(s.names, q.language, ""),
air_date: s.air_date,
}));
}
if (append.has("alternative_titles")) {
out.alternative_titles =
f.type === "movie" ? { titles: f.alternative_titles } : { results: f.alternative_titles };
}
if (append.has("external_ids")) out.external_ids = f.external_ids;
if (append.has("translations")) {
const key = f.type === "movie" ? "title" : "name";
out.translations = {
translations: [
...Object.entries(f.titles).map(([lang, t]) => {
const [l, r] = lang.split("-");
return { iso_639_1: l, iso_3166_1: r ?? "", data: { [key]: t } };
}),
...(f.extra_translations ?? []).map((x) => ({
iso_639_1: x.iso_639_1,
iso_3166_1: x.iso_3166_1,
data: { [key]: x.title },
})),
],
};
}
return { json: out };
},
],
],
{
...(opts.port !== undefined ? { port: opts.port } : {}),
guard: (req, q) => {
const bearer = /^Bearer (.+)$/.exec(req.headers.authorization ?? "")?.[1];
const ok = q.api_key === apiKey || (opts.accessToken !== undefined && bearer === opts.accessToken);
return ok ? null : { status: 401, json: { success: false, status_code: 7, status_message: "Invalid API key" } };
},
},
);
return Object.assign(http, { fixtures: fx });
}