- 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]>
123 lines
5.4 KiB
TypeScript
123 lines
5.4 KiB
TypeScript
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<MockEmby> {
|
|
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<string>) => {
|
|
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<string, string>) =>
|
|
new Set(
|
|
(q.Fields ?? q.fields ?? "")
|
|
.split(",")
|
|
.map((s) => s.trim().toLowerCase())
|
|
.filter(Boolean),
|
|
);
|
|
const page = (list: EmbyFixtureItem[], q: Record<string, string>): 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<string, string>) => {
|
|
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 });
|
|
}
|