/** * 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 = | { 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; 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; 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 | 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; 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(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 = {}; 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(e: unknown): Observed { 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 = {}): Promise { 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 = { 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> { 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> { 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> { 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> { 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> { 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> { 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> { 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> { 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> { 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 } | null>> { let id = opts.itemId; const byName = async () => { if (!opts.title) return { status: "ok", value: null, observedAt: this.now() } as Observed; 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 = {}; 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(); 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()]; } }