Files
UHD_Media_Tool/packages/adapters/src/emby/emby.test.ts
T
rootandClaude Fable 5.1 304feea141 chore(deno): migrate toolchain from Node 24 + pnpm to Deno 2.9.7 + TypeScript 7.0.2 (W1.5)
- root deno.json workspace (nodeModulesDir auto), npm versions registered once in imports, deno.lock;
  remove pnpm-lock/pnpm-workspace/root package.json; members keep minimal package.json (name/type/exports)
- tasks: dev, typecheck (tsc 7.0.2 per member + deno check), lint/fmt (biome, useImportExtensions),
  test (vitest via deno), check:tokens, build (web + deno compile), ci, db:migrate/reset, gen:types
- tsconfig base moved to config/ (no root tsconfig.json); deno types -> types/deno.d.ts; web drops baseUrl
- server: Deno.serve entry with graceful shutdown, --role migrate|preflight|probe, WEB_ROOT static SPA hosting;
  drop @hono/node-server, tsx, esbuild build.mjs
- logger: redact child() bindings and serialize err by message/stack; sync stdout destination; tests
- relative imports get explicit extensions (sites/mp-sync paths left for follow-up)
- crash-recovery tests spawn `deno run -A`; tasks tests use unique per-file schemas
- single-container Dockerfile + entrypoint (migrate -> preflight -> api+worker watchdog), docker-compose.yml
- docs: README, runbook, ADR-001 superseded, plan §1 validation row

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-27 18:53:06 +08:00

159 lines
7.2 KiB
TypeScript

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<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);
});
});