import { type MockHttp, type MockReply, startMockHttp } from "../tmdb/mock-http.ts"; import { EMBY_FIXTURE_ITEMS, EMBY_FIXTURE_USERS, type EmbyFixtureItem } from "./fixtures.ts"; export interface MockEmbyOptions { flavor?: "emby" | "jellyfin"; apiKey?: string; items?: EmbyFixtureItem[]; users?: { Id: string; Name: string; Policy?: { IsAdministrator?: boolean } }[]; port?: number; } export type MockEmby = MockHttp & { items: EmbyFixtureItem[]; flavor: "emby" | "jellyfin" }; /** * mock Emby / Jellyfin:/Users、/Users/{uid}/Items、/Items、/Users/{uid}/Items/{id}、/Shows/{id}/Seasons、 * /Shows/{id}/Episodes、/System/Info/Public。Emby 支持 AnyProviderIdEquals;Jellyfin flavor 忽略该参数。 * 路径可带 /emby 前缀。Fields 未请求的字段(ProviderIds/Path/MediaSources/OriginalTitle)不返回。 */ export async function createMockEmby(opts: MockEmbyOptions = {}): Promise { const flavor = opts.flavor ?? "emby"; const apiKey = opts.apiKey ?? "test-emby-key"; const items = opts.items ?? EMBY_FIXTURE_ITEMS; const users = opts.users ?? EMBY_FIXTURE_USERS; const project = (it: EmbyFixtureItem, fields: Set) => { const { ProviderIds, Path, MediaSources, OriginalTitle, ...rest } = it; return { ...rest, ...(fields.has("providerids") && ProviderIds ? { ProviderIds } : {}), ...(fields.has("path") && Path ? { Path } : {}), ...(fields.has("mediasources") && MediaSources ? { MediaSources } : {}), ...(fields.has("originaltitle") && OriginalTitle ? { OriginalTitle } : {}), }; }; const fieldsOf = (q: Record) => new Set( (q.Fields ?? q.fields ?? "") .split(",") .map((s) => s.trim().toLowerCase()) .filter(Boolean), ); const page = (list: EmbyFixtureItem[], q: Record): MockReply => { const start = Number(q.StartIndex ?? 0); const limit = q.Limit ? Number(q.Limit) : list.length; const f = fieldsOf(q); return { json: { Items: list.slice(start, start + limit).map((i) => project(i, f)), TotalRecordCount: list.length }, }; }; const knownUser = (uid: string | undefined) => !uid || users.some((u) => u.Id === uid); const unauthorizedUser: MockReply = { status: 400, json: "Invalid user" }; const queryItems = (q: Record) => { let list = items; const types = (q.IncludeItemTypes ?? "").split(",").filter(Boolean); if (types.length) list = list.filter((i) => types.includes(i.Type)); if (q.ParentId) list = list.filter((i) => i.SeriesId === q.ParentId || i.SeasonId === q.ParentId); const term = (q.SearchTerm ?? q.searchTerm ?? "").toLowerCase(); if (term) list = list.filter((i) => [i.Name, i.OriginalTitle].some((t) => t?.toLowerCase().includes(term))); if (flavor === "emby" && q.AnyProviderIdEquals) { const wanted = q.AnyProviderIdEquals.split(",").map((s) => s.toLowerCase()); list = list.filter((i) => Object.entries(i.ProviderIds ?? {}).some(([k, v]) => wanted.includes(`${k.toLowerCase()}.${v.toLowerCase()}`)), ); } return list; }; const r = (re: string) => new RegExp(`^(?:/emby)?${re}$`, "i"); const http = await startMockHttp( [ [ r("/System/Info/Public"), () => ({ json: { ServerName: "mock", Version: flavor === "emby" ? "4.8.10.0" : "10.10.3", Id: "srv" } }), ], [r("/Users"), () => ({ json: users })], [ r("/Users/([^/]+)/Items/([^/]+)"), (m, q) => { if (!knownUser(m[1])) return unauthorizedUser; const it = items.find((i) => i.Id === m[2]); return it ? { json: project(it, fieldsOf({ Fields: q.Fields ?? "ProviderIds,Path,MediaSources,OriginalTitle" })) } : { status: 404, json: "Not Found" }; }, ], [r("/Users/([^/]+)/Items"), (m, q) => (knownUser(m[1]) ? page(queryItems(q), q) : unauthorizedUser)], [r("/Items"), (_m, q) => (knownUser(q.UserId ?? q.userId) ? page(queryItems(q), q) : unauthorizedUser)], [ r("/Shows/([^/]+)/Seasons"), (m, q) => { if (!items.some((i) => i.Id === m[1] && i.Type === "Series")) return { status: 404, json: "Not Found" }; return page( items.filter((i) => i.Type === "Season" && i.SeriesId === m[1]), q, ); }, ], [ r("/Shows/([^/]+)/Episodes"), (m, q) => { if (!items.some((i) => i.Id === m[1] && i.Type === "Series")) return { status: 404, json: "Not Found" }; let list = items.filter((i) => i.Type === "Episode" && i.SeriesId === m[1]); if (q.SeasonId) list = list.filter((i) => i.SeasonId === q.SeasonId); if (q.Season) list = list.filter((i) => String(i.ParentIndexNumber) === q.Season); return page(list, q); }, ], ], { ...(opts.port !== undefined ? { port: opts.port } : {}), guard: (req, q) => { if (/\/System\/Info\/Public$/i.test(new URL(req.url ?? "/", "http://x").pathname)) return null; const h = req.headers["x-emby-token"]; const auth = /Token="([^"]+)"/.exec(req.headers.authorization ?? "")?.[1]; const ok = h === apiKey || auth === apiKey || q.api_key === apiKey || q.ApiKey === apiKey; return ok ? null : { status: 401, json: "Access token is invalid or expired." }; }, }, ); return Object.assign(http, { items, flavor }); }