- TMDB:流行趋势(日 / 周)、电影 / 剧集榜单、discover 参数映射、分页搜索;列表类请求 TTL 6 小时 - 豆瓣 / bgm:列表与搜索请求经 MetaRequestCache 与限流,失败(403 / 风控页)不缓存 - contracts:发现页条目、分页、查询校验与筛选字典;storage.cacheTtl 追加 meta* 字段 - testkit:mock TMDB 加列表接口 Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
479 lines
19 KiB
TypeScript
479 lines
19 KiB
TypeScript
/**
|
|
* 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";
|
|
|
|
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;原名为原始语言;别名为 null */
|
|
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 };
|
|
/**
|
|
* 跨进程共享的请求缓存(服务端传 PG 缓存);给了就不再用进程内 LRU。
|
|
* 语义对照 MP @cached(skip_none、业务失败不缓存、results 为空按 emptyTtlMs)。
|
|
*/
|
|
sharedCache?: TmdbSharedCache;
|
|
fetch?: typeof fetch;
|
|
now?: () => number;
|
|
}
|
|
|
|
type Json = Record<string, unknown>;
|
|
|
|
/**
|
|
* 共享请求缓存端口(对照 MP TMDb.request 的 @cached;服务端用 PG 实现):key 为 `path?query`(不含凭证);
|
|
* get 未命中返回 null;set 的 ttlMs 由客户端按 ttl / 空结果短 TTL 决定。
|
|
*/
|
|
export interface TmdbSharedCache {
|
|
get(key: string): Promise<unknown | null>;
|
|
set(key: string, value: unknown, ttlMs: number): Promise<void>;
|
|
}
|
|
|
|
function requestQuery(params: Record<string, string | undefined>): 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, string | undefined> = {}): 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<unknown>;
|
|
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<T>(path: string, params: Record<string, string | undefined>): Promise<T> {
|
|
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;
|
|
let ttl = /^\/(search|find)\//.test(path) ? (this.cfg.cache?.searchTtlMs ?? baseTtl) : baseTtl;
|
|
if (isTmdbListPath(path)) {
|
|
ttl = this.cfg.cache?.listTtlMs ?? Math.min(baseTtl, 6 * 3600_000);
|
|
emptyTtl = Math.min(emptyTtl, ttl);
|
|
}
|
|
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<T>;
|
|
}
|
|
|
|
private async fetchWithRetry(path: string, qs: URLSearchParams): Promise<unknown> {
|
|
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<unknown> {
|
|
const q = new URLSearchParams(qs);
|
|
const headers: Record<string, string> = { 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<T = Record<string, unknown>>(
|
|
path: string,
|
|
params: Record<string, string | undefined> = {},
|
|
): Promise<T> {
|
|
return this.get<T>(path, params);
|
|
}
|
|
|
|
/** 单语言原始搜索(匹配链使用,对照 MP search.movies/tv_shows,语言取首选)。 */
|
|
async searchRaw(type: MediaEntityType, query: string, year?: string | number): Promise<MetadataCandidate[]> {
|
|
const lang = this.langs[0]!;
|
|
const p = await this.get<Json>(`/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<MetadataCandidate[]> {
|
|
const lang = this.langs[0]!;
|
|
const p = await this.get<Json>("/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<MetadataCandidate[]> {
|
|
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<Json>(`/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<string, MetadataCandidate>();
|
|
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<MetadataCandidate[]> {
|
|
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<MetadataCandidate[]> {
|
|
if (!/^tt\d{5,10}$/.test(imdbId)) return [];
|
|
const lang = this.langs[0]!;
|
|
const p = await this.get<Json>(`/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<MetadataCandidate> {
|
|
const [first, ...rest] = await Promise.all(
|
|
this.langs.map((lang, i) =>
|
|
this.get<Json>(`/${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);
|
|
for (const { lang, j } of rest) {
|
|
const t = s(type === "movie" ? j.title : j.name);
|
|
if (t) 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<string>();
|
|
for (const a of altList) {
|
|
const text = s(a.title);
|
|
if (!text) continue;
|
|
aliases.add(text);
|
|
const region = s(a.iso_3166_1);
|
|
c.titles.push({ text, lang: null, 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;
|
|
aliases.add(text);
|
|
const l = s(tr.iso_639_1);
|
|
const region = s(tr.iso_3166_1);
|
|
TmdbClient.mergeTitle(c, {
|
|
text,
|
|
lang: l ? (region ? `${l}-${region}` : l) : null,
|
|
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<TmdbSeasonDetail> {
|
|
const j = await this.get<Json>(`/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);
|
|
}
|