import { createMockEmby, type MockEmby } from "@umt/testkit"; import { afterEach, describe, expect, it } from "vitest"; import { createEmbyClient, type EmbyConfig, identitiesCompatible, MediaServerRegistry, providerIdentity, } from "./index.ts"; let mocks: MockEmby[] = []; afterEach(async () => { await Promise.all(mocks.map((m) => m.close())); mocks = []; }); async function setup(flavor: "emby" | "jellyfin" = "emby", extra: Partial = {}) { 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); }); });