/** * TMDB v3 查询适配。 * 迁移来源:MoviePilot app/modules/themoviedb/tmdbapi.py(e6820a8)与 tmdbv3api/tmdb.py 的请求缓存: * search_movies/search_tvs/search_multiis → search/searchAll/searchMulti; * __get_movie_detail/__get_tv_detail(alternative_titles、translations、external_ids)→ details; * get_tv_season_detail → season;tmdb.py request 的 @cached(maxsize/ttl、空结果短 TTL、业务失败不缓存)→ TtlLruCache; * match/match_multi 的匹配链见 match.ts。 * 调整:网络/鉴权/限流错误以 TmdbError 抛出而非返回 None,调用方不得把"查询失败"当成"没有结果"。 */ import { redactUrl } from "@umt/contracts"; import { RequestLimiter, TtlLruCache } from "./cache.ts"; import { allowedLanguage, altTitleLanguage, isAllowedLanguage } from "./language-policy.ts"; export type TmdbErrorKind = "auth" | "network" | "timeout" | "notfound" | "rate" | "protocol"; export class TmdbError extends Error { override readonly name = "TmdbError"; constructor( readonly kind: TmdbErrorKind, message: string, readonly detail: { path?: string; status?: number; retryAfterMs?: number; cause?: unknown } = {}, ) { super(message); } } export type MediaEntityType = "movie" | "tv"; export interface CandidateTitle { text: string; /** BCP47,如 zh-CN;原名为原始语言;别名为按地区 + 文字推断的语言(language-policy altTitleLanguage) */ lang: string | null; kind: "localized" | "original" | "alias" | "translation"; /** 别名/翻译的地区(iso_3166_1) */ region?: string; } export interface CandidateSeason { seasonNumber: number; episodeCount: number; name: string | null; airDate: string | null; } /** 归一化的元数据候选(provider 无关结构,供 domain 身份匹配使用)。 */ export interface MetadataCandidate { provider: "tmdb"; entityType: MediaEntityType; externalId: string; /** 首选语言标题(配置 languages[0]) */ title: string; originalTitle: string | null; originalLanguage: string | null; titles: CandidateTitle[]; year: number | null; releaseDate: string | null; aliases: string[]; originCountry: string[]; popularity: number | null; overview: string | null; /** TMDB poster_path(如 `/abc.jpg`),拼图片 CDN 用;没有为 null */ posterPath: string | null; /** TMDB backdrop_path;没有为 null(旧数据 / 测试构造可缺省) */ backdropPath?: string | null; seasons?: CandidateSeason[]; externalIds?: { imdb?: string; tvdb?: string }; /** 是否已拉过详情(别名/季信息只在详情中完整) */ detailed: boolean; } export interface TmdbEpisode { seasonNumber: number; episodeNumber: number; name: string | null; airDate: string | null; } export interface TmdbSeasonDetail { tvId: string; seasonNumber: number; name: string | null; airDate: string | null; episodes: TmdbEpisode[]; } export interface TmdbConfig { baseUrl?: string; /** v3 API key(query api_key) */ apiKey?: string; /** v4 read access token(Bearer) */ accessToken?: string; /** 查询语言,默认 ["zh-CN", "en-US"];第一个为首选 */ languages?: string[]; timeoutMs?: number; /** 429/5xx/网络错误的重试次数,默认 3 */ retries?: number; retryBaseMs?: number; /** 429 Retry-After 的最大等待,默认 10s */ maxRetryAfterMs?: number; maxConcurrent?: number; minIntervalMs?: number; /** * 默认 max 1024、ttl 24h;空结果(搜索 0 条)用 emptyTtlMs,默认 30min(对照 MP EMPTY_RESULT_CACHE_TTL); * searchTtlMs:/search/、/find/ 请求的 TTL(缺省同 ttlMs),其余(详情 / 季)用 ttlMs。 * listTtlMs:流行趋势 / 热门 / 正在上映等列表与 /discover/ 的 TTL(缺省 6 小时且不超过 ttlMs;列表会变,不能按详情缓存 30 天), * 这类请求的空结果 TTL 也不超过它。 */ cache?: { max?: number; ttlMs?: number; searchTtlMs?: number; emptyTtlMs?: number; listTtlMs?: number; /** 人物 / 图片列表(/person/*、/images);缺省同 ttlMs */ imageTtlMs?: number; }; /** * 跨进程共享的请求缓存(服务端传 PG 缓存);给了就不再用进程内 LRU。 * 语义对照 MP @cached(skip_none、业务失败不缓存、results 为空按 emptyTtlMs)。 */ sharedCache?: TmdbSharedCache; fetch?: typeof fetch; now?: () => number; } type Json = Record; /** * 共享请求缓存端口(对照 MP TMDb.request 的 @cached;服务端用 PG 实现):key 为 `path?query`(不含凭证); * get 未命中返回 null;set 的 ttlMs 由客户端按 ttl / 空结果短 TTL 决定。 */ export interface TmdbSharedCache { get(key: string): Promise; set(key: string, value: unknown, ttlMs: number): Promise; /** * 给了就由它负责整条读取路径(服务端统一元数据缓存:两级、过期先用旧值、同请求合并、统计、关注作品续期), * TTL 按 req.kind 取服务端策略表;load 抛错(网络、鉴权、限流、5xx)不缓存。没给时走 get / set。 */ fetch?( req: { key: string; kind: TmdbRequestKind; isEmpty: (v: unknown) => boolean }, load: () => Promise, ): Promise; } /** 请求类别(缓存 TTL 的依据):搜索 / find、列表、人物 / 图片、其余为详情(含季、剧集组) */ export type TmdbRequestKind = "search" | "list" | "image" | "detail"; /** 人物 / 图片类请求:/person/*、…/images */ export const isTmdbImagePath = (path: string) => /^\/person\//.test(path) || /\/images$/.test(path); export function tmdbRequestKind(path: string): TmdbRequestKind { if (/^\/(search|find)\//.test(path)) return "search"; if (isTmdbListPath(path)) return "list"; if (isTmdbImagePath(path)) return "image"; return "detail"; } function requestQuery(params: Record): URLSearchParams { const qs = new URLSearchParams(); for (const k of Object.keys(params).sort()) if (params[k] !== undefined) qs.set(k, params[k]!); return qs; } /** 请求缓存键 `path?query`(参数按名排序、不含凭证);只读缓存的离线重算(MD-2)据此找已缓存的响应 */ export function tmdbRequestKey(path: string, params: Record = {}): string { return `${path}?${requestQuery(params)}`; } /** 列表类请求(发现页):流行趋势、按条件筛选、电影 / 剧集榜单 */ export const isTmdbListPath = (path: string) => /^\/(trending|discover)\//.test(path) || /^\/(movie|tv)\/(popular|now_playing|upcoming|top_rated|on_the_air|airing_today)$/.test(path); /** 空结果(搜索 0 条)用短 TTL(对照 MP EMPTY_RESULT_CACHE_TTL) */ const isEmptyResult = (v: unknown) => Array.isArray((v as Json | null)?.results) && ((v as Json).results as unknown[]).length === 0; const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); const s = (v: unknown): string | null => (typeof v === "string" && v !== "" ? v : null); const n = (v: unknown): number | null => (typeof v === "number" && Number.isFinite(v) ? v : null); const yearOf = (d: string | null) => (d && /^\d{4}/.test(d) ? Number(d.slice(0, 4)) : null); export class TmdbClient { private readonly base: string; private readonly langs: string[]; private readonly cache: TtlLruCache; private readonly limiter: RequestLimiter; private readonly fetchImpl: typeof fetch; constructor(private readonly cfg: TmdbConfig) { if (!cfg.apiKey && !cfg.accessToken) throw new TypeError("TMDB apiKey or accessToken required"); this.base = (cfg.baseUrl ?? "https://api.themoviedb.org/3").replace(/\/+$/, ""); this.langs = cfg.languages?.length ? cfg.languages : ["zh-CN", "en-US"]; this.cache = new TtlLruCache(cfg.cache?.max ?? 1024, cfg.cache?.ttlMs ?? 24 * 3600_000, cfg.now); this.limiter = new RequestLimiter(cfg.maxConcurrent ?? 8, cfg.minIntervalMs ?? 0); this.fetchImpl = cfg.fetch ?? fetch; } /** 错误信息里去掉 api_key / access token(query 参数与明文两种形态都遮) */ private redact(text: string): string { return redactUrl(text, [this.cfg.apiKey, this.cfg.accessToken]); } /** 首选查询语言(识别缓存键里的语言段,对照 MP TMDB_LOCALE) */ get language(): string { return this.langs[0]!; } cacheStats() { return { size: this.cache.size, hits: this.cache.hits, misses: this.cache.misses, coalesced: this.cache.coalesced }; } private async get(path: string, params: Record): Promise { const qs = requestQuery(params); const key = `${path}?${qs}`; let emptyTtl = this.cfg.cache?.emptyTtlMs ?? 30 * 60_000; const baseTtl = this.cfg.cache?.ttlMs ?? 24 * 3600_000; const kind = tmdbRequestKind(path); let ttl = kind === "search" ? (this.cfg.cache?.searchTtlMs ?? baseTtl) : baseTtl; if (kind === "list") { ttl = this.cfg.cache?.listTtlMs ?? Math.min(baseTtl, 6 * 3600_000); emptyTtl = Math.min(emptyTtl, ttl); } else if (kind === "image") ttl = this.cfg.cache?.imageTtlMs ?? baseTtl; if (this.cfg.sharedCache?.fetch) { return (await this.cfg.sharedCache.fetch({ key, kind, isEmpty: isEmptyResult }, () => this.limiter.run(() => this.fetchWithRetry(path, qs)), )) as T; } if (this.cfg.sharedCache) { const shared = this.cfg.sharedCache; const hit = await shared.get(key); if (hit !== null && hit !== undefined) return hit as T; // 网络错误 / 业务失败在 fetchWithRetry 抛出,不会走到写缓存(MP skip_none / skip_if) const v = await this.limiter.run(() => this.fetchWithRetry(path, qs)); if (v !== null && v !== undefined) { await shared.set(key, v, isEmptyResult(v) ? emptyTtl : ttl); } return v as T; } return this.cache.getOrLoad( key, () => this.limiter.run(() => this.fetchWithRetry(path, qs)), (v) => (isEmptyResult(v) ? emptyTtl : ttl === baseTtl ? undefined : ttl), ) as Promise; } private async fetchWithRetry(path: string, qs: URLSearchParams): Promise { const retries = this.cfg.retries ?? 3; let last: TmdbError | null = null; for (let i = 0; i <= retries; i++) { try { return await this.fetchOnce(path, qs); } catch (e) { if (!(e instanceof TmdbError)) throw e; last = e; if (!["rate", "network", "timeout", "protocol"].includes(e.kind) || i === retries) break; const ra = e.detail.retryAfterMs; await sleep(ra ?? (this.cfg.retryBaseMs ?? 500) * 2 ** i); } } throw last!; } private async fetchOnce(path: string, qs: URLSearchParams): Promise { const q = new URLSearchParams(qs); const headers: Record = { accept: "application/json" }; if (this.cfg.accessToken) headers.authorization = `Bearer ${this.cfg.accessToken}`; else q.set("api_key", this.cfg.apiKey!); let res: Response; try { res = await this.fetchImpl(`${this.base}${path}?${q}`, { headers, signal: AbortSignal.timeout(this.cfg.timeoutMs ?? 10_000), }); } catch (e) { const name = (e as { name?: string })?.name; if (name === "TimeoutError" || name === "AbortError") throw new TmdbError("timeout", "TMDB request timed out", { path }); // Deno fetch 的错误信息带完整 URL(含 api_key):message 先脱敏,cause 只留脱敏后的名字与信息,不透传原对象 const msg = this.redact(String((e as Error)?.message ?? e)); throw new TmdbError("network", `TMDB request failed: ${msg}`, { path, cause: { name: (e as Error)?.name ?? "Error", message: msg }, }); } if (res.status === 401) { await res.body?.cancel(); throw new TmdbError("auth", "TMDB rejected credentials", { path, status: 401 }); } if (res.status === 404) { await res.body?.cancel(); throw new TmdbError("notfound", `TMDB resource not found: ${path}`, { path, status: 404 }); } if (res.status === 429) { await res.body?.cancel(); const ra = Number(res.headers.get("retry-after")); const err = new TmdbError("rate", "TMDB rate limited", { path, status: 429 }); if (Number.isFinite(ra) && ra >= 0) { err.detail.retryAfterMs = Math.min(ra * 1000, this.cfg.maxRetryAfterMs ?? 10_000); } throw err; } if (!res.ok) { await res.body?.cancel(); throw new TmdbError("protocol", `TMDB HTTP ${res.status}`, { path, status: res.status }); } try { return await res.json(); } catch (e) { throw new TmdbError("protocol", "TMDB returned invalid JSON", { path, cause: { name: (e as Error)?.name ?? "Error", message: this.redact(String((e as Error)?.message ?? e)) }, }); } } /** * 原始 JSON 请求(走同一套缓存 / 限流 / 重试 / 脱敏):details.ts 的多语言详情、季集详情用。 * path 以 `/` 开头(不含 /3 前缀),params 不含凭证。 */ async getJson>( path: string, params: Record = {}, ): Promise { return this.get(path, params); } /** 单语言原始搜索(匹配链使用,对照 MP search.movies/tv_shows,语言取首选)。 */ async searchRaw(type: MediaEntityType, query: string, year?: string | number): Promise { const lang = this.langs[0]!; const p = await this.get(`/search/${type}`, { query: query.trim(), language: lang, include_adult: "false", page: "1", [type === "movie" ? "year" : "first_air_date_year"]: year !== undefined && year !== "" ? String(year) : undefined, }); return ((Array.isArray(p.results) ? p.results : []) as Json[]).map((r) => this.fromBrief(type, r, lang)); } /** /search/multi,只保留 movie/tv(对照 _project_multi_search_results)。 */ async searchMulti(query: string): Promise { const lang = this.langs[0]!; const p = await this.get("/search/multi", { query: query.trim(), language: lang, include_adult: "false", page: "1", }); return ((Array.isArray(p.results) ? p.results : []) as Json[]) .filter((r) => r.media_type === "movie" || r.media_type === "tv") .map((r) => this.fromBrief(r.media_type as MediaEntityType, r, lang)); } private fromBrief(type: MediaEntityType, r: Json, lang: string): MetadataCandidate { const title = s(type === "movie" ? r.title : r.name) ?? ""; const original = s(type === "movie" ? r.original_title : r.original_name); const date = s(type === "movie" ? r.release_date : r.first_air_date); const origLang = s(r.original_language); const titles: CandidateTitle[] = []; if (title) titles.push({ text: title, lang, kind: "localized" }); if (original) titles.push({ text: original, lang: origLang, kind: "original" }); return { provider: "tmdb", entityType: type, externalId: String(r.id), title, originalTitle: original, originalLanguage: origLang, titles, year: yearOf(date), releaseDate: date, aliases: [], originCountry: Array.isArray(r.origin_country) ? (r.origin_country as string[]) : [], popularity: n(r.popularity), overview: s(r.overview), posterPath: s(r.poster_path), backdropPath: s(r.backdrop_path), detailed: false, }; } private static mergeTitle(c: MetadataCandidate, t: CandidateTitle) { if (!c.titles.some((x) => x.text === t.text && x.lang === t.lang && x.kind === t.kind)) c.titles.push(t); } /** 按配置的多语言搜索并按 ID 合并;首选语言结果顺序优先。 */ async search(query: string, opts: { type: MediaEntityType; year?: number }): Promise { const q = query.trim(); if (!q) return []; const yearKey = opts.type === "movie" ? "year" : "first_air_date_year"; const pages = await Promise.all( this.langs.map((lang) => this.get(`/search/${opts.type}`, { query: q, language: lang, include_adult: "false", page: "1", [yearKey]: opts.year !== undefined ? String(opts.year) : undefined, }).then((p) => ({ lang, results: (Array.isArray(p.results) ? p.results : []) as Json[] })), ), ); const byId = new Map(); for (const { lang, results } of pages) { for (const r of results) { const c = this.fromBrief(opts.type, r, lang); const prev = byId.get(c.externalId); if (!prev) byId.set(c.externalId, c); else for (const t of c.titles) TmdbClient.mergeTitle(prev, t); } } return [...byId.values()]; } /** 同时搜电影与剧集。 */ async searchAll(query: string, opts: { year?: number } = {}): Promise { const [m, t] = await Promise.all([ this.search(query, { type: "movie", ...opts }), this.search(query, { type: "tv", ...opts }), ]); return [...m, ...t].sort((a, b) => (b.popularity ?? 0) - (a.popularity ?? 0)); } /** /find/{imdb_id}?external_source=imdb_id:IMDb ID → 电影 / 剧集候选(站点详情页给出的 IMDb 链接走这里) */ async findByImdb(imdbId: string): Promise { if (!/^tt\d{5,10}$/.test(imdbId)) return []; const lang = this.langs[0]!; const p = await this.get(`/find/${imdbId}`, { external_source: "imdb_id", language: lang }); const movies = (Array.isArray(p.movie_results) ? p.movie_results : []) as Json[]; const tvs = (Array.isArray(p.tv_results) ? p.tv_results : []) as Json[]; return [...movies.map((r) => this.fromBrief("movie", r, lang)), ...tvs.map((r) => this.fromBrief("tv", r, lang))]; } /** 详情:多语言标题、别名(alternative_titles)、外部 ID、季列表(剧集)。不存在抛 notfound。 */ async details(type: MediaEntityType, id: string | number): Promise { const [first, ...rest] = await Promise.all( this.langs.map((lang, i) => this.get(`/${type}/${id}`, { language: lang, append_to_response: i === 0 ? "alternative_titles,external_ids,translations" : undefined, }).then((j) => ({ lang, j })), ), ); const c = this.fromBrief(type, first!.j, first!.lang); // 多语言保留规则(language-policy.ts):本地化名 / 译名只留简中 / 繁中 / 英文 / 原语言;别名按地区 + 文字推断语言, // 推断不出的不要。MP __get_names 把全部别名与译名都计入可匹配名称,这里收窄(用户规则 2026-10-06) const work = { originalLanguage: c.originalLanguage, originCountry: c.originCountry }; c.titles = c.titles.filter((t) => t.kind !== "localized" || isAllowedLanguage(t.lang, work)); for (const { lang, j } of rest) { const t = s(type === "movie" ? j.title : j.name); if (t && isAllowedLanguage(lang, work)) TmdbClient.mergeTitle(c, { text: t, lang, kind: "localized" }); } const alt = first!.j.alternative_titles as Json | undefined; const altList = ((type === "movie" ? alt?.titles : alt?.results) ?? []) as Json[]; const aliases = new Set(); for (const a of altList) { const text = s(a.title); if (!text) continue; const region = s(a.iso_3166_1); const lang = altTitleLanguage({ title: text, region, type: s(a.type) }, work); if (!lang) continue; aliases.add(text); c.titles.push({ text, lang, kind: "alias", ...(region ? { region } : {}) }); } // translations:MP __get_names 同样把各语言译名计入可匹配名称(这里只留保留语言) const trs = (((first!.j.translations as Json | undefined)?.translations ?? []) as Json[]).filter(Boolean); for (const tr of trs) { const data = tr.data as Json | undefined; const text = s(type === "movie" ? data?.title : data?.name); if (!text) continue; const l = s(tr.iso_639_1); const region = s(tr.iso_3166_1); const lang = allowedLanguage(l ? (region ? `${l}-${region}` : l) : null, work); if (!lang) continue; aliases.add(text); TmdbClient.mergeTitle(c, { text, lang, kind: "translation", ...(region ? { region } : {}) }); } c.aliases = [...aliases]; const ext = first!.j.external_ids as Json | undefined; if (ext) { const imdb = s(ext.imdb_id); const tvdb = ext.tvdb_id != null ? String(ext.tvdb_id) : null; c.externalIds = { ...(imdb ? { imdb } : {}), ...(tvdb ? { tvdb } : {}) }; } if (type === "tv") { c.seasons = ((first!.j.seasons ?? []) as Json[]).map((x) => ({ seasonNumber: n(x.season_number) ?? 0, episodeCount: n(x.episode_count) ?? 0, name: s(x.name), airDate: s(x.air_date), })); } c.detailed = true; return c; } /** 季详情与集列表(首选语言)。 */ async season(tvId: string | number, seasonNumber: number): Promise { const j = await this.get(`/tv/${tvId}/season/${seasonNumber}`, { language: this.langs[0] }); return { tvId: String(tvId), seasonNumber: n(j.season_number) ?? seasonNumber, name: s(j.name), airDate: s(j.air_date), episodes: ((j.episodes ?? []) as Json[]).map((e) => ({ seasonNumber: n(e.season_number) ?? seasonNumber, episodeNumber: n(e.episode_number) ?? 0, name: s(e.name), airDate: s(e.air_date), })), }; } } export function createTmdbClient(cfg: TmdbConfig): TmdbClient { return new TmdbClient(cfg); }