- 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]>
576 lines
21 KiB
TypeScript
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()];
|
|
}
|
|
}
|