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

+187
View File
@@ -0,0 +1,187 @@
import { createMockTmdb, type MockTmdb } from "@umt/testkit";
import { afterEach, describe, expect, it } from "vitest";
import {
compareNames,
createTmdbClient,
matchTmdb,
matchTmdbMulti,
type TmdbConfig,
TmdbError,
yearRange,
} from "./index";
let mock: MockTmdb | null = null;
afterEach(async () => {
await mock?.close();
mock = null;
});
async function setup(extra: Partial<TmdbConfig> = {}) {
mock = await createMockTmdb();
const tmdb = createTmdbClient({
baseUrl: `${mock.url}/3`,
apiKey: "test-tmdb-key",
retries: 2,
retryBaseMs: 5,
timeoutMs: 300,
...extra,
});
return { mock, tmdb };
}
describe("TmdbClient 搜索与详情", () => {
it("中文搜索电影,中英标题合并为一个候选", async () => {
const { tmdb } = await setup();
const r = await tmdb.search("奥本海默", { type: "movie" });
expect(r).toHaveLength(1);
expect(r[0]).toMatchObject({ provider: "tmdb", entityType: "movie", externalId: "872585", year: 2023 });
expect(r[0]!.title).toBe("奥本海默");
expect(r[0]!.titles.map((t) => `${t.kind}:${t.lang}:${t.text}`)).toEqual([
"localized:zh-CN:奥本海默",
"original:en:Oppenheimer",
"localized:en-US:Oppenheimer",
]);
});
it("英文搜剧 + 年份过滤", async () => {
const { tmdb } = await setup();
const r = await tmdb.search("the bear", { type: "tv" });
expect(r.map((c) => c.externalId)).toEqual(["136315"]);
expect(await tmdb.search("the bear", { type: "tv", year: 2019 })).toEqual([]);
const all = await tmdb.searchAll("流浪地球");
expect(all.map((c) => `${c.entityType}:${c.externalId}`)).toEqual(["movie:842675"]);
});
it("电影详情:别名与外部 ID", async () => {
const { tmdb } = await setup();
const d = await tmdb.details("movie", 693134);
expect(d.detailed).toBe(true);
expect(d.title).toBe("沙丘2");
expect(d.aliases).toEqual(["Dune 2", "沙丘:第二部", "沙丘:第二部", "沙丘2", "Dune: Part Two"]);
expect(d.titles.find((t) => t.kind === "alias" && t.region === "TW")?.text).toBe("沙丘:第二部");
expect(d.externalIds).toEqual({ imdb: "tt15239678" });
expect(d.seasons).toBeUndefined();
});
it("剧集详情:季列表与集列表", async () => {
const { tmdb } = await setup();
const d = await tmdb.details("tv", 136315);
expect(d.seasons?.map((s) => [s.seasonNumber, s.episodeCount])).toEqual([
[1, 8],
[2, 10],
[3, 10],
]);
expect(d.externalIds).toEqual({ imdb: "tt14452776", tvdb: "401654" });
const s2 = await tmdb.season(136315, 2);
expect(s2.episodes).toHaveLength(10);
expect(s2.episodes[0]).toMatchObject({ seasonNumber: 2, episodeNumber: 1, name: "第 1 集" });
});
it("国产综艺:特别篇季 0、按期命名、简称别名可召回", async () => {
const { tmdb } = await setup();
const r = await tmdb.search("向往", { type: "tv" });
expect(r.map((c) => c.externalId)).toEqual(["900001"]);
const d = await tmdb.details("tv", 900001);
expect(d.originalLanguage).toBe("zh");
expect(d.seasons?.map((s) => s.seasonNumber)).toEqual([0, 1, 2]);
expect(d.externalIds).toEqual({});
const s1 = await tmdb.season(900001, 1);
expect(s1.episodes.at(-1)).toMatchObject({ episodeNumber: 12, name: "第 12 期" });
});
});
describe("TmdbClient 缓存/限流/错误分类", () => {
it("LRU+TTL 缓存:命中不发请求,过期后重新拉取", async () => {
let now = 1_000_000;
const { mock, tmdb } = await setup({ now: () => now, cache: { ttlMs: 1000, max: 100 } });
await tmdb.details("movie", 872585);
const n1 = mock.calls.length;
await tmdb.details("movie", 872585);
expect(mock.calls.length).toBe(n1);
expect(tmdb.cacheStats().hits).toBeGreaterThan(0);
now += 5000;
await tmdb.details("movie", 872585);
expect(mock.calls.length).toBe(n1 * 2);
});
it("并发合并:同一请求并发只发一次", async () => {
const { mock, tmdb } = await setup();
await Promise.all(Array.from({ length: 5 }, () => tmdb.season(100088, 1)));
expect(mock.callsTo("/3/tv/100088/season/1")).toHaveLength(1);
expect(tmdb.cacheStats().coalesced).toBe(4);
});
it("429 按 Retry-After 退避后成功;持续 429 → rate", async () => {
const { mock, tmdb } = await setup();
mock.fault("/3/movie/842675", { status: 429, headers: { "retry-after": "0" }, times: 1 });
const d = await tmdb.details("movie", 842675);
expect(d.title).toBe("流浪地球2");
mock.fault("/3/tv/100088", { status: 429, headers: { "retry-after": "0" }, times: 10 });
const e = await tmdb.details("tv", 100088).catch((x) => x);
expect(e).toBeInstanceOf(TmdbError);
expect(e.kind).toBe("rate");
});
it("失败不进缓存:5xx 后重试成功,notfound 分类", async () => {
const { mock, tmdb } = await setup({ retries: 0 });
mock.fault("/3/tv/136315/season/3", { status: 503 });
expect((await tmdb.season(136315, 3).catch((e) => e)).kind).toBe("protocol");
expect((await tmdb.season(136315, 3)).episodes).toHaveLength(10);
expect((await tmdb.details("movie", 1).catch((e) => e)).kind).toBe("notfound");
expect((await tmdb.season(136315, 9).catch((e) => e)).kind).toBe("notfound");
});
it("凭据错误 → auth;超时 → timeout;连接失败 → network", async () => {
const { mock } = await setup();
const bad = createTmdbClient({ baseUrl: `${mock.url}/3`, apiKey: "wrong", retries: 0 });
expect((await bad.search("x", { type: "movie" }).catch((e) => e)).kind).toBe("auth");
const slow = createTmdbClient({ baseUrl: `${mock.url}/3`, apiKey: "test-tmdb-key", retries: 0, timeoutMs: 50 });
mock.fault("/3/search", { delayMs: 300, times: 2 });
expect((await slow.search("dune", { type: "movie" }).catch((e) => e)).kind).toBe("timeout");
const dead = createTmdbClient({ baseUrl: "http://127.0.0.1:1/3", apiKey: "k", retries: 0 });
expect((await dead.details("movie", 1).catch((e) => e)).kind).toBe("network");
});
it("Bearer token 鉴权可用", async () => {
mock = await createMockTmdb({ accessToken: "v4-token" });
const tmdb = createTmdbClient({ baseUrl: `${mock.url}/3`, accessToken: "v4-token" });
expect((await tmdb.search("last of us", { type: "tv" }))[0]?.externalId).toBe("100088");
expect(mock.calls.every((c) => c.query.api_key === undefined)).toBe(true);
});
});
describe("TMDB 匹配链(对照 MP TmdbApi.match)", () => {
it("名称比较:去标点/空白、大小写不敏感、完全相等", () => {
expect(compareNames("Dune Part Two", "Dune: Part Two")).toBe(true);
expect(compareNames("沙丘 第二部", ["沙丘:第二部"])).toBe(true);
expect(compareNames("Dune", "Dune: Part Two")).toBe(false);
expect(yearRange(2023)).toEqual(["2023", "2024", "2022"]);
expect(yearRange(undefined)).toEqual([undefined]);
});
it("年份容差 ±1:2023 未命中后按 2024 命中", async () => {
const { tmdb, mock } = await setup();
const r = await matchTmdb(tmdb, { name: "沙丘2", type: "movie", year: 2023 });
expect(r?.candidate.externalId).toBe("693134");
expect(r?.step).toEqual({ target: "movie_name", year: "2024" });
expect(mock.callsTo("/3/search/movie").map((c) => c.query.year)).toEqual(["2023", "2024"]);
});
it("标题不直达时拉详情用别名/译名匹配", async () => {
const { tmdb } = await setup();
const r = await matchTmdb(tmdb, { name: "奥本海默传", type: "movie" });
expect(r?.candidate).toMatchObject({ externalId: "872585", detailed: true });
const ja = await matchTmdb(tmdb, { name: "オッペンハイマー", type: "movie", year: 2023 });
expect(ja).toBeNull(); // mock 搜索不索引译名 → 搜不到候选,与 TMDB 行为一致:译名只在详情比较阶段参与
expect(await matchTmdb(tmdb, { name: "完全不存在", type: "movie", year: 2000 })).toBeNull();
});
it("季年份匹配优先:按季首播年份命中剧集;multi 电影优先", async () => {
const { tmdb } = await setup();
const r = await matchTmdb(tmdb, { name: "熊家餐馆", type: "tv", seasonYear: 2024, seasonNumber: 3, year: 2022 });
expect(r?.candidate.externalId).toBe("136315");
expect(r?.step.target).toBe("tv_season");
const m = await matchTmdbMulti(tmdb, "The Last of Us");
expect(m).toMatchObject({ entityType: "tv", externalId: "100088" });
});
});