Files
UHD_Media_Tool/packages/adapters/src/emby/client.ts
T
rootandClaude Fable 5.1 ef2e504a94 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]>
2026-09-27 18:07:48 +08:00

576 lines
21 KiB
TypeScript

/**
* 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()];
}
}