- 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]>
159 lines
7.2 KiB
TypeScript
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);
|
|
});
|
|
});
|