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:
1 parent
9c881e737f
commit
ef2e504a94
45 files changed
+5766
-1
No files matched your search
@@ -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 文档验证,未连接真实实例。
|
||||
@@ -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()];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./client";
|
||||
Reference in new issue
Block a user