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

+158
View File
@@ -0,0 +1,158 @@
import { createMockEmby, type MockEmby } from "@umt/testkit";
import { afterEach, describe, expect, it } from "vitest";
import {
createEmbyClient,
type EmbyConfig,
identitiesCompatible,
MediaServerRegistry,
providerIdentity,
} from "./index";
let mocks: MockEmby[] = [];
afterEach(async () => {
await Promise.all(mocks.map((m) => m.close()));
mocks = [];
});
async function setup(flavor: "emby" | "jellyfin" = "emby", extra: Partial<EmbyConfig> = {}) {
const mock = await createMockEmby({ flavor });
mocks.push(mock);
const emby = createEmbyClient({
instanceId: `${flavor}-1`,
baseUrl: mock.url,
apiKey: "test-emby-key",
flavor,
retries: 1,
retryBaseMs: 5,
timeoutMs: 300,
...extra,
});
return { mock, emby };
}
describe("EmbyClient 查询", () => {
it("用户解析:用户名匹配优先,否则回退管理员", async () => {
const { emby } = await setup();
const u = await emby.resolveUserId();
expect(u).toMatchObject({ status: "ok", value: "u-admin" });
const { emby: e2 } = await setup("emby", { username: "kid" });
expect(await e2.resolveUserId()).toMatchObject({ status: "ok", value: "u-kid" });
});
it("按 TMDB ID 查电影:返回多版本 MediaSources(分辨率/编码/音轨/路径)", async () => {
const { emby, mock } = await setup();
const r = await emby.findByProviderId("Tmdb", "872585", "Movie");
expect(r.status).toBe("present");
if (r.status !== "present") return;
const [m] = r.items;
expect(m).toMatchObject({ itemId: "m1", name: "奥本海默", year: 2023, instanceId: "emby-1" });
expect(m!.versions.map((v) => [v.video?.height, v.video?.codec, v.video?.videoRange])).toEqual([
[2160, "hevc", "HDR"],
[800, "h264", "SDR"],
]);
expect(m!.versions[0]!.audio[0]).toEqual({
codec: "truehd",
channels: 8,
language: "eng",
title: "TrueHD 7.1",
isDefault: true,
});
expect(m!.versions[0]!.path).toContain("2160p.mkv");
expect(mock.calls.at(-1)!.path).toBe("/emby/Users/u-admin/Items");
});
it("未知字段不补值", async () => {
const { emby } = await setup();
const r = await emby.findByProviderId("Tmdb", "842675", "Movie");
if (r.status !== "present") throw new Error("expected present");
const v = r.items[0]!.versions[0]!;
expect(v).toEqual({
id: "ms-m2",
path: "/media/movies/流浪地球2 (2023)/流浪地球2.mkv",
video: { codec: "hevc" },
audio: [{}],
subtitles: [],
});
expect(r.items[0]!.year).toBeUndefined();
});
it("按名称+年份查电影,身份冲突排除", async () => {
const { emby } = await setup();
const ok = await emby.getMovies("奥本海默", { year: 2023, identity: { source: "tmdb", id: "872585" } });
expect(ok.status === "ok" && ok.value.map((i) => i.itemId)).toEqual(["m1"]);
const conflict = await emby.getMovies("奥本海默", { identity: { source: "tmdb", id: "1" } });
expect(conflict.status === "ok" && conflict.value).toEqual([]);
const wrongYear = await emby.getMovies("奥本海默", { year: 2020 });
expect(wrongYear.status === "ok" && wrongYear.value).toEqual([]);
});
it("季集覆盖:Virtual 缺集不计入,多集文件展开", async () => {
const { emby } = await setup();
const r = await emby.getTvEpisodes({ title: "熊家餐馆", year: 2022, identity: { source: "tmdb", id: "136315" } });
expect(r).toMatchObject({
status: "ok",
value: { seriesId: "s1", seasons: { 1: [1, 2, 3, 4, 5, 6, 7, 8], 2: [1, 2, 3] } },
});
const v = await emby.getTvEpisodes({ itemId: "s2", season: 1 });
expect(v.status === "ok" && v.value?.seasons).toEqual({ 1: [1, 3, 4] });
const seasons = await emby.seasons("s1");
expect(seasons.status === "ok" && seasons.value.map((s) => s.seasonNumber)).toEqual([1, 2]);
});
it("缓存的 itemId 失效时按标题重查;确实没有返回 null", async () => {
const { emby } = await setup();
const r = await emby.getTvEpisodes({ itemId: "stale-id", title: "熊家餐馆" });
expect(r.status === "ok" && r.value?.seriesId).toBe("s1");
const none = await emby.getTvEpisodes({ title: "不存在的剧" });
expect(none).toMatchObject({ status: "ok", value: null });
expect(await emby.findByProviderId("Tmdb", "999", "Movie")).toEqual({ status: "not_found" });
});
it("Jellyfin:无 /emby 前缀、Authorization 头;忽略 AnyProviderIdEquals 时结果截断 → unknown", async () => {
const { emby, mock } = await setup("jellyfin", { pageSize: 1 });
const r = await emby.findByProviderId("Tmdb", "842675", "Movie");
expect(r).toEqual({ status: "unknown", reason: "truncated" });
expect(mock.calls.at(-1)!.path).toBe("/Users/u-admin/Items");
expect(String(mock.calls.at(-1)!.headers.authorization)).toContain('Token="test-emby-key"');
const { emby: full } = await setup("jellyfin");
expect((await full.findByProviderId("Tmdb", "842675", "Movie")).status).toBe("present");
});
});
describe("EmbyClient 失败即 unknown", () => {
it("凭据错误 → unknown(auth),而不是不存在", async () => {
const { mock } = await setup();
const bad = createEmbyClient({ instanceId: "x", baseUrl: mock.url, apiKey: "wrong", retries: 0 });
expect(await bad.findByProviderId("Tmdb", "872585", "Movie")).toEqual({ status: "unknown", reason: "auth" });
expect((await bad.getTvEpisodes({ title: "熊家餐馆" })).status).toBe("unknown");
});
it("5xx 重试后成功;持续 5xx → unknown(protocol);超时 → unknown(timeout);不可达 → network", async () => {
const { emby, mock } = await setup();
await emby.resolveUserId();
mock.fault("/emby/Users/u-admin/Items", { status: 502 });
expect((await emby.getMovies("奥本海默")).status).toBe("ok");
mock.fault("/emby/Users/u-admin/Items", { status: 500, times: 5 });
expect(await emby.getMovies("奥本海默")).toMatchObject({ status: "unknown", reason: "protocol" });
mock.clearFaults();
mock.fault("/emby/Shows/s1/Episodes", { delayMs: 500, times: 5 });
expect(await emby.episodes("s1")).toMatchObject({ status: "unknown", reason: "timeout" });
const dead = createEmbyClient({ instanceId: "d", baseUrl: "http://127.0.0.1:1", apiKey: "k", retries: 0 });
expect(await dead.ping()).toMatchObject({ status: "unknown", reason: "network" });
});
it("多实例注册表;身份兼容规则", async () => {
const a = await setup("emby");
const b = await setup("jellyfin");
const reg = new MediaServerRegistry([
{ instanceId: "a", baseUrl: a.mock.url, apiKey: "test-emby-key" },
{ instanceId: "b", baseUrl: b.mock.url, apiKey: "test-emby-key", flavor: "jellyfin" },
]);
const res = await Promise.all(reg.list().map((c) => c.ping()));
expect(res.map((r) => r.status === "ok" && r.value.version)).toEqual(["4.8.10.0", "10.10.3"]);
expect(providerIdentity({ Imdb: "tt1", Tmdb: "5" })).toEqual({ source: "tmdb", id: "5" });
expect(identitiesCompatible({ source: "tmdb", id: "1" }, { source: "imdb", id: "tt1" })).toBe(true);
expect(identitiesCompatible({ source: "tmdb", id: "1" }, { source: "TMDB", id: "2" })).toBe(false);
expect(identitiesCompatible(null, { source: "tmdb", id: "2" })).toBe(true);
});
});