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,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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user