feat(adapters): qBittorrent, filesystem, TMDB and Emby adapters with testkit mocks (W1-3a)

- qbittorrent: WebUI API v2 client (cookie/API-key auth, add/info/files/filePrio,
  pause/resume with 5.x stop/start fallback, tags, delete with explicit deleteFiles,
  maindata), error classes, idempotent submit with local infohash (own bencode)
  and post-timeout hash/probe-tag lookup; state mapping per MoviePilot
- fs: copyWithVerify (tmp + fsync + size/sha256/sample + no-clobber commit),
  moveSafe (EXDEV surfaced), resumable crossDeviceMove, statSafe/checkRoot with
  root sentinel, budgeted incremental scan with cursor, ffprobe, statfs usage
- tmdb: multi-language search/details/seasons, LRU+TTL cache with coalescing,
  rate-limit backoff, MoviePilot match chain (name normalization, year +-1)
- emby: Emby/Jellyfin items/seasons/episodes/MediaSources, failures -> unknown
- testkit: mock qB / TMDB / Emby servers, torrent builder, temp-tree fixtures
- docs: W1-3a porting table appended to downloader-and-sites ledger

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
rootandClaude Fable 5.1 committed 2026-09-27 18:07:48 +08:00
1 parent 9c881e737f
commit ef2e504a94
45 files changed
+5766 -1

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
# emby
Emby / Jellyfin 兼容库存查询(`EmbyClient`,`MediaServerRegistry` 管多实例):`/Users` 与查询用户解析(用户名 → 管理员回退)、`/Users/{id}/Items`(IncludeItemTypes、SearchTerm、AnyProviderIdEquals + 客户端复核 ProviderIds)、单条目、`/Shows/{id}/Seasons|Episodes`(排除 Virtual 缺集占位,多集文件展开)、MediaSources 归一化(分辨率/编码/HDR/音轨/字幕/路径/大小,接口没给的字段不补值)。所有查询返回 `ok` 或 `unknown`;`findByProviderId` 只有在查询成功且结果未截断时才给 `not_found`。Emby 走 `/emby` 前缀 + `X-Emby-Token`,Jellyfin 无前缀 + `Authorization: MediaBrowser Token=`。
仅对 mock 与官方 API 文档验证,未连接真实实例。
+575
View File
@@ -0,0 +1,575 @@
/**
* Emby / Jellyfin 兼容库存查询。
* 迁移来源:MoviePilot app/modules/emby/emby.py(e6820a8)
* get_user → resolveUserId;__get_emby_series_id_by_name → findSeriesIdByName;
* get_movies → getMovies;get_tv_episodes → getTvEpisodes;get_iteminfo → getItem;
* app/application/mediaserver.py MediaServerIdentityHelper → providerIdentity / identitiesCompatible。
* 调整:MP 用 None/""/[] 表达"连不通/未找到",这里统一为判别联合;失败一律 unknown,不当作"不存在"
* (REQ §7.4);api_key 走请求头而非 URL,避免凭证进日志。
*/
export type EmbyErrorKind = "auth" | "network" | "timeout" | "notfound" | "rate" | "protocol";
export interface EmbyConfig {
/** media_server_instance.id */
instanceId: string;
baseUrl: string;
apiKey: string;
flavor?: "emby" | "jellyfin";
/** 查询用户;缺省按 username 匹配,再回退管理员(对照 MP get_user) */
userId?: string;
username?: string;
/** API 路径前缀;默认 emby → "/emby",jellyfin → "" */
pathPrefix?: string;
timeoutMs?: number;
retries?: number;
retryBaseMs?: number;
/** 搜索/分页单页大小,默认 50 */
pageSize?: number;
fetch?: typeof fetch;
}
export type Observed<T> =
| { status: "ok"; value: T; observedAt: string }
| { status: "unknown"; reason: EmbyErrorKind | "truncated"; message: string; observedAt: string };
export interface EmbyVideoStream {
codec?: string;
width?: number;
height?: number;
videoRange?: string;
bitDepth?: number;
profile?: string;
}
export interface EmbyAudioStream {
codec?: string;
channels?: number;
language?: string;
title?: string;
isDefault?: boolean;
}
export interface EmbySubtitleStream {
codec?: string;
language?: string;
title?: string;
isExternal?: boolean;
}
/** 一个 MediaSource = 一个版本。接口未给出的字段保持缺省,不推断补值。 */
export interface EmbyMediaVersion {
id: string;
name?: string;
path?: string;
container?: string;
sizeBytes?: number;
bitrate?: number;
video?: EmbyVideoStream;
audio: EmbyAudioStream[];
subtitles: EmbySubtitleStream[];
}
export interface EmbyItem {
instanceId: string;
itemId: string;
type: string;
name: string;
originalTitle?: string;
year?: number;
/** 原样保留服务器返回的键(Tmdb/Imdb/Tvdb…) */
providerIds: Record<string, string>;
path?: string;
seriesId?: string;
seriesName?: string;
seasonId?: string;
/** Season: IndexNumber;Episode: ParentIndexNumber */
seasonNumber?: number;
episodeNumber?: number;
episodeNumberEnd?: number;
/** Virtual = 服务器生成的缺集占位,不代表有文件 */
locationType?: string;
versions: EmbyMediaVersion[];
}
export interface EmbyUser {
id: string;
name: string;
isAdministrator: boolean | null;
}
export interface ItemQuery {
types?: ("Movie" | "Series" | "Season" | "Episode")[];
searchTerm?: string;
/** 例 { Tmdb: "872585" };Emby 走 AnyProviderIdEquals,并总在客户端复核(Jellyfin 忽略该参数) */
providerIds?: Record<string, string>;
parentId?: string;
startIndex?: number;
limit?: number;
withMediaSources?: boolean;
}
export type MediaIdentitySource = "tmdb" | "douban" | "bangumi" | "anilist" | "imdb" | "tvdb" | "musicbrainz";
const PROVIDER_KEYS: [MediaIdentitySource, string[]][] = [
["tmdb", ["Tmdb", "TMDB", "tmdb", "tmdb_id"]],
["douban", ["Douban", "douban", "douban_id"]],
["bangumi", ["Bangumi", "bangumi", "bangumi_id"]],
["anilist", ["AniList", "Anilist", "anilist", "anilist_id"]],
["imdb", ["Imdb", "IMDb", "imdb", "imdb_id"]],
["tvdb", ["Tvdb", "TVDB", "tvdb", "tvdb_id"]],
["musicbrainz", ["MusicBrainz", "musicbrainz", "musicbrainz_id"]],
];
/** 按固定优先级从 ProviderIds 选一个规范身份(对照 MediaServerIdentityHelper.from_provider_ids)。 */
export function providerIdentity(
providerIds: Record<string, string> | undefined,
): { source: MediaIdentitySource; id: string } | null {
if (!providerIds) return null;
for (const [source, keys] of PROVIDER_KEYS) {
for (const k of keys) {
const v = providerIds[k];
if (v != null && String(v).trim()) return { source, id: String(v).trim() };
}
}
return null;
}
/** 两组身份是否"没有可证实的同来源 ID 冲突"(对照 are_compatible):任一为空或来源不同 → 兼容。 */
export function identitiesCompatible(
a: { source: string; id: string } | null | undefined,
b: { source: string; id: string } | null | undefined,
): boolean {
if (!a || !b) return true;
if (a.source.toLowerCase() !== b.source.toLowerCase()) return true;
return a.id === b.id;
}
type Json = Record<string, unknown>;
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
const str = (v: unknown) => (typeof v === "string" && v !== "" ? v : undefined);
const num = (v: unknown) => (typeof v === "number" && Number.isFinite(v) ? v : undefined);
const bool = (v: unknown) => (typeof v === "boolean" ? v : undefined);
function clean<T extends object>(o: T): T {
return Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined)) as T;
}
class EmbyHttpError extends Error {
constructor(
readonly kind: EmbyErrorKind,
message: string,
) {
super(message);
}
}
export function normalizeMediaSource(ms: Json): EmbyMediaVersion {
const streams = (Array.isArray(ms.MediaStreams) ? ms.MediaStreams : []) as Json[];
const v = streams.find((s) => s.Type === "Video");
return {
...clean({
id: String(ms.Id ?? ""),
name: str(ms.Name),
path: str(ms.Path),
container: str(ms.Container),
sizeBytes: num(ms.Size),
bitrate: num(ms.Bitrate),
video: v
? clean({
codec: str(v.Codec),
width: num(v.Width),
height: num(v.Height),
videoRange: str(v.VideoRange),
bitDepth: num(v.BitDepth),
profile: str(v.Profile),
})
: undefined,
}),
audio: streams
.filter((s) => s.Type === "Audio")
.map((s) =>
clean({
codec: str(s.Codec),
channels: num(s.Channels),
language: str(s.Language),
title: str(s.Title) ?? str(s.DisplayTitle),
isDefault: bool(s.IsDefault),
}),
),
subtitles: streams
.filter((s) => s.Type === "Subtitle")
.map((s) =>
clean({
codec: str(s.Codec),
language: str(s.Language),
title: str(s.Title),
isExternal: bool(s.IsExternal),
}),
),
};
}
export function normalizeItem(instanceId: string, it: Json): EmbyItem {
const type = String(it.Type ?? "");
const pids: Record<string, string> = {};
for (const [k, v] of Object.entries((it.ProviderIds as Json | undefined) ?? {})) {
if (v != null && String(v).trim()) pids[k] = String(v);
}
return {
...clean({
instanceId,
itemId: String(it.Id ?? ""),
type,
name: String(it.Name ?? ""),
originalTitle: str(it.OriginalTitle),
year: num(it.ProductionYear),
path: str(it.Path),
seriesId: str(it.SeriesId),
seriesName: str(it.SeriesName),
seasonId: str(it.SeasonId),
seasonNumber: type === "Season" ? num(it.IndexNumber) : num(it.ParentIndexNumber),
episodeNumber: type === "Episode" ? num(it.IndexNumber) : undefined,
episodeNumberEnd: type === "Episode" ? num(it.IndexNumberEnd) : undefined,
locationType: str(it.LocationType),
}),
providerIds: pids,
versions: Array.isArray(it.MediaSources) ? (it.MediaSources as Json[]).map(normalizeMediaSource) : [],
};
}
const ITEM_FIELDS = "ProviderIds,OriginalTitle,ProductionYear,Path,ParentId";
export class EmbyClient {
readonly instanceId: string;
private readonly base: string;
private readonly flavor: "emby" | "jellyfin";
private readonly fetchImpl: typeof fetch;
private userId: string | undefined;
constructor(private readonly cfg: EmbyConfig) {
this.instanceId = cfg.instanceId;
this.flavor = cfg.flavor ?? "emby";
const prefix = cfg.pathPrefix ?? (this.flavor === "emby" ? "/emby" : "");
this.base = `${cfg.baseUrl.replace(/\/+$/, "")}${prefix}`;
this.userId = cfg.userId;
this.fetchImpl = cfg.fetch ?? fetch;
}
private now() {
return new Date().toISOString();
}
private unknown<T>(e: unknown): Observed<T> {
const kind = e instanceof EmbyHttpError ? e.kind : "protocol";
return { status: "unknown", reason: kind, message: (e as Error)?.message ?? String(e), observedAt: this.now() };
}
private async getJson(path: string, query: Record<string, string | undefined> = {}): Promise<unknown> {
const qs = new URLSearchParams();
for (const [k, v] of Object.entries(query)) if (v !== undefined) qs.set(k, v);
const url = `${this.base}${path}${qs.size ? `?${qs}` : ""}`;
const headers: Record<string, string> = { accept: "application/json" };
if (this.flavor === "jellyfin") headers.authorization = `MediaBrowser Token="${this.cfg.apiKey}"`;
else headers["x-emby-token"] = this.cfg.apiKey;
const retries = this.cfg.retries ?? 2;
let last: EmbyHttpError | null = null;
for (let i = 0; i <= retries; i++) {
if (i > 0) await sleep((this.cfg.retryBaseMs ?? 300) * 2 ** (i - 1));
let res: Response;
try {
res = await this.fetchImpl(url, { headers, signal: AbortSignal.timeout(this.cfg.timeoutMs ?? 10_000) });
} catch (e) {
const name = (e as { name?: string })?.name;
last =
name === "TimeoutError" || name === "AbortError"
? new EmbyHttpError("timeout", `[emby:${this.instanceId}] ${path} timed out`)
: new EmbyHttpError("network", `[emby:${this.instanceId}] ${path} failed: ${(e as Error)?.message}`);
continue;
}
const s = res.status;
if (s === 401 || s === 403) {
await res.body?.cancel();
throw new EmbyHttpError("auth", `[emby:${this.instanceId}] ${path} unauthorized (HTTP ${s})`);
}
if (s === 404) {
await res.body?.cancel();
throw new EmbyHttpError("notfound", `[emby:${this.instanceId}] ${path} not found`);
}
if (s === 429 || s >= 500) {
await res.body?.cancel();
last = new EmbyHttpError(s === 429 ? "rate" : "protocol", `[emby:${this.instanceId}] ${path} HTTP ${s}`);
continue;
}
if (!res.ok) {
await res.body?.cancel();
throw new EmbyHttpError("protocol", `[emby:${this.instanceId}] ${path} HTTP ${s}`);
}
try {
return await res.json();
} catch {
throw new EmbyHttpError("protocol", `[emby:${this.instanceId}] ${path} invalid JSON`);
}
}
throw last!;
}
async ping(): Promise<Observed<{ serverName?: string; version?: string }>> {
try {
const j = (await this.getJson("/System/Info/Public")) as Json;
return {
status: "ok",
value: clean({ serverName: str(j.ServerName), version: str(j.Version) }),
observedAt: this.now(),
};
} catch (e) {
return this.unknown(e);
}
}
async users(): Promise<Observed<EmbyUser[]>> {
try {
const j = await this.getJson("/Users");
if (!Array.isArray(j)) throw new EmbyHttpError("protocol", "/Users did not return an array");
return {
status: "ok",
value: (j as Json[]).map((u) => ({
id: String(u.Id ?? ""),
name: String(u.Name ?? ""),
isAdministrator: bool((u.Policy as Json | undefined)?.IsAdministrator) ?? null,
})),
observedAt: this.now(),
};
} catch (e) {
return this.unknown(e);
}
}
/** 查询用户:配置 userId > 用户名匹配 > 管理员(对照 MP get_user)。 */
async resolveUserId(): Promise<Observed<string>> {
if (this.userId) return { status: "ok", value: this.userId, observedAt: this.now() };
const u = await this.users();
if (u.status !== "ok") return u;
const pick =
(this.cfg.username && u.value.find((x) => x.name === this.cfg.username)) ||
u.value.find((x) => x.isAdministrator === true);
if (!pick) {
return { status: "unknown", reason: "auth", message: "no usable user on server", observedAt: this.now() };
}
this.userId = pick.id;
return { status: "ok", value: pick.id, observedAt: this.now() };
}
/** 通用条目查询;总数大于已取回条目且需要客户端过滤时,调用方应把"未命中"当作 unknown。 */
async searchItems(q: ItemQuery): Promise<Observed<{ items: EmbyItem[]; total: number; fetched: number }>> {
const uid = await this.resolveUserId();
if (uid.status !== "ok") return uid;
try {
const anyPid = q.providerIds
? Object.entries(q.providerIds)
.map(([k, v]) => `${k.toLowerCase()}.${v}`)
.join(",")
: undefined;
const j = (await this.getJson(`/Users/${encodeURIComponent(uid.value)}/Items`, {
Recursive: "true",
IncludeItemTypes: q.types?.join(","),
SearchTerm: q.searchTerm,
AnyProviderIdEquals: anyPid,
ParentId: q.parentId,
StartIndex: String(q.startIndex ?? 0),
Limit: String(q.limit ?? this.cfg.pageSize ?? 50),
Fields: q.withMediaSources ? `${ITEM_FIELDS},MediaSources` : ITEM_FIELDS,
})) as Json;
const raw = (Array.isArray(j.Items) ? j.Items : []) as Json[];
let items = raw.map((it) => normalizeItem(this.instanceId, it));
if (q.providerIds) {
const want = Object.entries(q.providerIds).map(([k, v]) => [k.toLowerCase(), v] as const);
items = items.filter((it) =>
Object.entries(it.providerIds).some(([k, v]) => want.some(([wk, wv]) => wk === k.toLowerCase() && wv === v)),
);
}
return {
status: "ok",
value: { items, total: num(j.TotalRecordCount) ?? raw.length, fetched: raw.length },
observedAt: this.now(),
};
} catch (e) {
return this.unknown(e);
}
}
/**
* 按外部 ID 查有无。只有查询成功且结果完整(未截断)时才返回 not_found;
* 失败/截断 → unknown(REQ §7.4 "接口失败不能视为没有资源")。
*/
async findByProviderId(
provider: string,
id: string,
type: "Movie" | "Series",
): Promise<
{ status: "present"; items: EmbyItem[] } | { status: "not_found" } | { status: "unknown"; reason: string }
> {
const r = await this.searchItems({
types: [type],
providerIds: { [provider]: id },
withMediaSources: type === "Movie",
});
if (r.status !== "ok") return { status: "unknown", reason: r.reason };
if (r.value.items.length) return { status: "present", items: r.value.items };
if (r.value.total > r.value.fetched) return { status: "unknown", reason: "truncated" };
return { status: "not_found" };
}
async getItem(itemId: string): Promise<Observed<EmbyItem | null>> {
const uid = await this.resolveUserId();
if (uid.status !== "ok") return uid;
try {
const j = (await this.getJson(`/Users/${encodeURIComponent(uid.value)}/Items/${encodeURIComponent(itemId)}`, {
Fields: `${ITEM_FIELDS},MediaSources`,
})) as Json;
return { status: "ok", value: normalizeItem(this.instanceId, j), observedAt: this.now() };
} catch (e) {
// 单条目 404:ID 已失效(媒体被删后重新入库会换 ID),不是"没有资源"
if (e instanceof EmbyHttpError && e.kind === "notfound")
return { status: "ok", value: null, observedAt: this.now() };
return this.unknown(e);
}
}
/** 名称精确 + 年份匹配剧集 ID(对照 __get_emby_series_id_by_name):value=null 表示查询成功但未找到。 */
async findSeriesIdByName(name: string, year?: number | string): Promise<Observed<string | null>> {
const r = await this.searchItems({ types: ["Series"], searchTerm: name, limit: 10 });
if (r.status !== "ok") return r;
const hit = r.value.items.find((it) => it.name === name && (!year || String(it.year) === String(year)));
return { status: "ok", value: hit?.itemId ?? null, observedAt: r.observedAt };
}
/** 按标题(+年份)查电影,名称精确匹配且外部身份无冲突(对照 get_movies)。 */
async getMovies(
title: string,
opts: { year?: number | string; identity?: { source: string; id: string } } = {},
): Promise<Observed<EmbyItem[]>> {
const r = await this.searchItems({ types: ["Movie"], searchTerm: title, limit: 10, withMediaSources: true });
if (r.status !== "ok") return r;
const value = r.value.items.filter(
(it) =>
identitiesCompatible(providerIdentity(it.providerIds), opts.identity) &&
it.name === title &&
(!opts.year || String(it.year) === String(opts.year)),
);
return { status: "ok", value, observedAt: r.observedAt };
}
async seasons(seriesId: string): Promise<Observed<EmbyItem[]>> {
const uid = await this.resolveUserId();
if (uid.status !== "ok") return uid;
try {
const j = (await this.getJson(`/Shows/${encodeURIComponent(seriesId)}/Seasons`, {
UserId: uid.value,
Fields: ITEM_FIELDS,
})) as Json;
return {
status: "ok",
value: ((j.Items ?? []) as Json[]).map((x) => normalizeItem(this.instanceId, x)),
observedAt: this.now(),
};
} catch (e) {
return this.unknown(e);
}
}
/** 剧集下的集(排除 Virtual 缺集占位,对照 MP IsMissing=false)。 */
async episodes(
seriesId: string,
opts: { season?: number; seasonId?: string; withMediaSources?: boolean } = {},
): Promise<Observed<EmbyItem[]>> {
const uid = await this.resolveUserId();
if (uid.status !== "ok") return uid;
try {
const j = (await this.getJson(`/Shows/${encodeURIComponent(seriesId)}/Episodes`, {
UserId: uid.value,
Season: opts.season !== undefined ? String(opts.season) : undefined,
SeasonId: opts.seasonId,
IsMissing: "false",
Fields: opts.withMediaSources ? `${ITEM_FIELDS},MediaSources` : ITEM_FIELDS,
})) as Json;
const items = ((j.Items ?? []) as Json[])
.map((x) => normalizeItem(this.instanceId, x))
.filter((x) => x.locationType !== "Virtual");
return { status: "ok", value: items, observedAt: this.now() };
} catch (e) {
return this.unknown(e);
}
}
/**
* 每季已有集号(对照 get_tv_episodes):itemId 优先;itemId 失效时按标题重查;
* 身份冲突或未找到返回 value=null(找不到剧),查询失败返回 unknown。
* IndexNumberEnd(多集合一文件)展开为区间。
*/
async getTvEpisodes(opts: {
itemId?: string;
title?: string;
year?: number | string;
identity?: { source: string; id: string };
season?: number;
}): Promise<Observed<{ seriesId: string; seasons: Record<number, number[]> } | null>> {
let id = opts.itemId;
const byName = async () => {
if (!opts.title) return { status: "ok", value: null, observedAt: this.now() } as Observed<string | null>;
return this.findSeriesIdByName(opts.title, opts.year);
};
if (!id) {
const r = await byName();
if (r.status !== "ok") return r;
if (!r.value) return { status: "ok", value: null, observedAt: r.observedAt };
id = r.value;
}
let info = await this.getItem(id);
if (info.status !== "ok") return info;
if (!info.value && opts.itemId && opts.title) {
const r = await byName();
if (r.status !== "ok") return r;
if (!r.value) return { status: "ok", value: null, observedAt: r.observedAt };
id = r.value;
info = await this.getItem(id);
if (info.status !== "ok") return info;
}
if (!info.value) return { status: "ok", value: null, observedAt: info.observedAt };
if (!identitiesCompatible(providerIdentity(info.value.providerIds), opts.identity)) {
return { status: "ok", value: null, observedAt: info.observedAt };
}
const eps = await this.episodes(id, opts.season !== undefined ? { season: opts.season } : {});
if (eps.status !== "ok") return eps;
const seasons: Record<number, number[]> = {};
for (const e of eps.value) {
if (e.seasonNumber === undefined || e.episodeNumber === undefined) continue;
if (opts.season !== undefined && opts.season !== e.seasonNumber) continue;
if (!seasons[e.seasonNumber]) seasons[e.seasonNumber] = [];
const list = seasons[e.seasonNumber]!;
const end = e.episodeNumberEnd && e.episodeNumberEnd > e.episodeNumber ? e.episodeNumberEnd : e.episodeNumber;
for (let n = e.episodeNumber; n <= end; n++) if (!list.includes(n)) list.push(n);
}
for (const k of Object.keys(seasons)) seasons[Number(k)]!.sort((a, b) => a - b);
return { status: "ok", value: { seriesId: id, seasons }, observedAt: eps.observedAt };
}
}
export function createEmbyClient(cfg: EmbyConfig): EmbyClient {
return new EmbyClient(cfg);
}
/** 多实例注册表。 */
export class MediaServerRegistry {
private readonly clients = new Map<string, EmbyClient>();
constructor(configs: EmbyConfig[] = []) {
for (const c of configs) this.register(c);
}
register(cfg: EmbyConfig): EmbyClient {
const c = new EmbyClient(cfg);
this.clients.set(cfg.instanceId, c);
return c;
}
get(id: string): EmbyClient | undefined {
return this.clients.get(id);
}
list(): EmbyClient[] {
return [...this.clients.values()];
}
}
+158
View File
@@ -0,0 +1,158 @@
import { createMockEmby, type MockEmby } from "@umt/testkit";
import { afterEach, describe, expect, it } from "vitest";
import {
createEmbyClient,
type EmbyConfig,
identitiesCompatible,
MediaServerRegistry,
providerIdentity,
} from "./index";
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);
});
});
+1
View File
@@ -0,0 +1 @@
export * from "./client";