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:
1 parent
9c881e737f
commit
ef2e504a94
45 files changed
+5766
-1
No files matched your search
@@ -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}`,
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./fixtures";
|
||||
export * from "./mock-http";
|
||||
export * from "./mock-tmdb";
|
||||
@@ -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)),
|
||||
};
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
Reference in new issue
Block a user