/** * TMDB 富详情与季集详情(MD-1,媒体详情页 / 字段级元数据用)。 * 迁移来源:MoviePilot app/modules/themoviedb/tmdbapi.py(e6820a8) * __get_movie_detail / __get_tv_detail(append_to_response=credits,alternative_titles,translations,external_ids …)→ fetchRichDetail; * get_tv_season_detail(季名、简介、海报、episodes[]:name/overview/air_date/runtime/still_path/vote_average)→ fetchRichSeason; * 请求缓存语义同 client.ts(MP tmdbv3api request 的 @cached:成功才缓存、空结果短 TTL)。 * 调整:标题 / 简介按语言回退链取(translations.ts);季集在首选语言缺标题(占位名)或缺简介时按链逐个语言补拉, * 链走完或全部补齐即停。MD-2:简介 / 标语只按 overviewLanguages(缺省只到中文变体,不回退英文)取,取不到留空。主创只留前 10 位演员 + 导演 / 主创(created_by),头像只存路径(前端经图片代理)。 */ import type { MediaEntityType, TmdbClient } from "./client.ts"; import { DEFAULT_METADATA_LANGUAGES, isPlaceholderEpisodeName, isPlaceholderSeasonName, overviewLanguages, parseAltTitles, parseTranslations, pickChineseTitle, pickText, pickTitle, type TmdbAltTitle, type TmdbTranslation, } from "./translations.ts"; type Json = Record; const s = (v: unknown): string | null => (typeof v === "string" && v.trim() !== "" ? v.trim() : null); const n = (v: unknown): number | null => (typeof v === "number" && Number.isFinite(v) ? v : null); const int = (v: unknown): number | null => { const x = n(v); return x === null ? null : Math.round(x); }; const yearOf = (d: string | null) => (d && /^\d{4}/.test(d) ? Number(d.slice(0, 4)) : null); export interface TmdbCredit { id: number; name: string; originalName: string | null; /** 演员:角色名;幕后:职务 */ role: string | null; profilePath: string | null; } export interface TmdbRichSeason { seasonNumber: number; episodeCount: number; name: string | null; airDate: string | null; overview: string | null; posterPath: string | null; } export interface TmdbRichDetail { entityType: MediaEntityType; externalId: string; /** 回退链取到的标题及语言(null = 链全落空后用的请求语言值 / 原名) */ title: string; titleLanguage: string | null; /** 中文名(链中第一个中文值,zh-CN 优先);没有为 null */ titleZh: string | null; titleZhLanguage: string | null; originalTitle: string | null; originalLanguage: string | null; overview: string | null; overviewLanguage: string | null; tagline: string | null; year: number | null; releaseDate: string | null; lastAirDate: string | null; genres: string[]; voteAverage: number | null; voteCount: number | null; /** TMDB status 原文 */ status: string | null; /** 分钟:电影片长 / 剧集单集时长(episode_run_time 首项) */ runtime: number | null; seasonCount: number | null; episodeCount: number | null; networks: string[]; originCountry: string[]; posterPath: string | null; backdropPath: string | null; cast: TmdbCredit[]; crew: TmdbCredit[]; externalIds: { imdb: string | null; tvdb: string | null; wikidata: string | null }; translations: TmdbTranslation[]; alternativeTitles: TmdbAltTitle[]; seasons: TmdbRichSeason[]; /** 本次使用的语言链 */ languages: string[]; } export interface TmdbRichEpisode { seasonNumber: number; episodeNumber: number; name: string | null; nameLanguage: string | null; overview: string | null; overviewLanguage: string | null; airDate: string | null; runtime: number | null; stillPath: string | null; voteAverage: number | null; voteCount: number | null; } export interface TmdbRichSeasonDetail { tvId: string; seasonNumber: number; name: string | null; nameLanguage: string | null; overview: string | null; overviewLanguage: string | null; airDate: string | null; posterPath: string | null; episodes: TmdbRichEpisode[]; /** 实际请求过的语言(首选 + 补拉) */ fetchedLanguages: string[]; } /** MD-2 取值选项 */ export interface RichTextOptions { /** 简介 / 标语是否允许回退到非中文(设置 metadata.overviewNonChineseFallback,缺省否) */ overviewNonChineseFallback?: boolean; } const chainOf = (languages?: readonly string[]) => languages?.length ? [...languages] : [...DEFAULT_METADATA_LANGUAGES]; function credits(raw: unknown, type: MediaEntityType, createdBy: unknown): { cast: TmdbCredit[]; crew: TmdbCredit[] } { const c = (raw ?? {}) as Json; const cast = ((Array.isArray(c.cast) ? c.cast : []) as Json[]) .filter((x) => n(x.id) !== null && s(x.name)) .sort((a, b) => (n(a.order) ?? 999) - (n(b.order) ?? 999)) .slice(0, 10) .map((x) => ({ id: n(x.id)!, name: s(x.name)!, originalName: s(x.original_name), role: s(x.character), profilePath: s(x.profile_path), })); const crew: TmdbCredit[] = []; const seen = new Set(); const push = (x: Json, role: string) => { const id = n(x.id); if (id === null || seen.has(id) || !s(x.name)) return; seen.add(id); crew.push({ id, name: s(x.name)!, originalName: s(x.original_name), role, profilePath: s(x.profile_path) }); }; if (type === "tv") for (const x of (Array.isArray(createdBy) ? createdBy : []) as Json[]) push(x, "Creator"); for (const x of (Array.isArray(c.crew) ? c.crew : []) as Json[]) { if (x.job === "Director") push(x, "Director"); } return { cast, crew: crew.slice(0, 6) }; } /** * 富详情:一次请求 `/{type}/{id}?language=链首&append_to_response=alternative_titles,credits,external_ids,translations`, * 标题 / 简介 / 标语按链从 translations(+ 同地区别名)取。不存在抛 TmdbError(notfound)。 */ export async function fetchRichDetail( client: Pick, type: MediaEntityType, id: string | number, languages?: readonly string[], opts: RichTextOptions = {}, ): Promise { const chain = chainOf(languages); const textChain = overviewLanguages(chain, opts.overviewNonChineseFallback); const j = await client.getJson(`/${type}/${id}`, { language: chain[0], append_to_response: "alternative_titles,credits,external_ids,translations", }); const titleKey = type === "movie" ? "title" : "name"; const translations = parseTranslations(j.translations, titleKey); const altTitles = parseAltTitles(j.alternative_titles); const originalTitle = s(type === "movie" ? j.original_title : j.original_name); const originalLanguage = s(j.original_language); const primary = s(j[titleKey]); const titleSrc = { translations, altTitles, originalTitle, originalLanguage, primary }; const title = pickTitle(chain, titleSrc); const zh = pickChineseTitle(chain, titleSrc); // 请求语言 = 链首,链首总在 textChain 里(见 overviewLanguages),所以 primary 可以兜底 const overview = pickText(textChain, translations, "overview", { value: s(j.overview), language: chain[0] ?? null }); const tagline = pickText(textChain, translations, "tagline", { value: s(j.tagline), language: chain[0] ?? null }); const date = s(type === "movie" ? j.release_date : j.first_air_date); const ext = (j.external_ids ?? {}) as Json; const runtimes = Array.isArray(j.episode_run_time) ? (j.episode_run_time as unknown[]) : []; const { cast, crew } = credits(j.credits, type, j.created_by); return { entityType: type, externalId: String(id), title: title?.value ?? primary ?? originalTitle ?? "", titleLanguage: title?.language ?? null, titleZh: zh?.value ?? null, titleZhLanguage: zh?.language ?? null, originalTitle, originalLanguage, overview: overview?.value ?? null, overviewLanguage: overview?.language ?? null, tagline: tagline?.value ?? null, year: yearOf(date), releaseDate: date, lastAirDate: s(j.last_air_date), genres: ((Array.isArray(j.genres) ? j.genres : []) as Json[]).map((g) => s(g.name)).filter((x): x is string => !!x), voteAverage: n(j.vote_average) !== null ? Math.round(n(j.vote_average)! * 10) / 10 : null, voteCount: int(j.vote_count), status: s(j.status), runtime: type === "movie" ? int(j.runtime) : int(runtimes[0]), seasonCount: type === "tv" ? int(j.number_of_seasons) : null, episodeCount: type === "tv" ? int(j.number_of_episodes) : null, networks: ((Array.isArray(j.networks) ? j.networks : []) as Json[]) .map((x) => s(x.name)) .filter((x): x is string => !!x), originCountry: Array.isArray(j.origin_country) ? (j.origin_country as unknown[]).filter((x): x is string => typeof x === "string") : [], posterPath: s(j.poster_path), backdropPath: s(j.backdrop_path), cast, crew, externalIds: { imdb: s(ext.imdb_id), tvdb: ext.tvdb_id != null && ext.tvdb_id !== "" ? String(ext.tvdb_id) : null, wikidata: s(ext.wikidata_id), }, translations, alternativeTitles: altTitles, seasons: ((Array.isArray(j.seasons) ? j.seasons : []) as Json[]).map((x) => ({ seasonNumber: int(x.season_number) ?? 0, episodeCount: int(x.episode_count) ?? 0, name: s(x.name), airDate: s(x.air_date), overview: s(x.overview), posterPath: s(x.poster_path), })), languages: chain, }; } function episodeOf(e: Json, seasonNumber: number, lang: string): TmdbRichEpisode { const num = int(e.episode_number) ?? 0; const name = s(e.name); const overview = s(e.overview); const named = name && !isPlaceholderEpisodeName(name, num); return { seasonNumber: int(e.season_number) ?? seasonNumber, episodeNumber: num, name: named ? name : null, nameLanguage: named ? lang : null, overview, overviewLanguage: overview ? lang : null, airDate: s(e.air_date), runtime: int(e.runtime), stillPath: s(e.still_path), voteAverage: n(e.vote_average) !== null && (int(e.vote_count) ?? 0) > 0 ? Math.round(n(e.vote_average)! * 10) / 10 : null, voteCount: int(e.vote_count), }; } /** * 季详情:先按链首请求 `/tv/{id}/season/{n}`;有集缺标题(TMDB 占位名"第 N 集")或缺简介时, * 按链逐个语言补拉同一端点(各自走请求缓存),补齐即停。补不到标题的集保留链首的占位名。 */ export async function fetchRichSeason( client: Pick, tvId: string | number, seasonNumber: number, languages?: readonly string[], opts: { now?: Date } & RichTextOptions = {}, ): Promise { const chain = chainOf(languages); const first = chain[0]!; // MD-2:简介只从这些语言取(缺省只到中文变体);标题仍按完整链 const textChain = overviewLanguages(chain, opts.overviewNonChineseFallback); const j = await client.getJson(`/tv/${tvId}/season/${seasonNumber}`, { language: first }); const rawEps = (Array.isArray(j.episodes) ? j.episodes : []) as Json[]; const eps = rawEps.map((e) => episodeOf(e, seasonNumber, first)); const placeholders = new Map(rawEps.map((e) => [int(e.episode_number) ?? 0, s(e.name)])); let seasonName = s(j.name); let seasonNameLang: string | null = seasonName ? first : null; let seasonOverview = s(j.overview); let seasonOverviewLang: string | null = seasonOverview ? first : null; const fetched = [first]; // 只为已播出的集补拉(未播出的集各语言通常都空,补拉只会白白耗请求);季名"第 N 季"各语言都是占位,不触发补拉 const today = (opts.now ?? new Date()).toISOString().slice(0, 10); const aired = (e: TmdbRichEpisode) => !!e.airDate && e.airDate <= today; // 缺简介只在剩下的语言里还有可取简介的语言时才值得补拉 const missing = (rest: readonly string[]) => { const ov = rest.some((l) => textChain.includes(l)); return eps.some((e) => aired(e) && (!e.name || (ov && !e.overview))) || (ov && !seasonOverview && eps.some(aired)); }; for (const [i, lang] of chain.entries()) { if (i === 0) continue; if (!eps.length || !missing(chain.slice(i))) break; const textOk = textChain.includes(lang); let k: Json; try { k = await client.getJson(`/tv/${tvId}/season/${seasonNumber}`, { language: lang }); } catch { // 补拉失败不影响首选语言结果 break; } fetched.push(lang); const byNum = new Map( ((Array.isArray(k.episodes) ? k.episodes : []) as Json[]).map((e) => [int(e.episode_number) ?? 0, e]), ); for (const e of eps) { const alt = byNum.get(e.episodeNumber); if (!alt) continue; const other = episodeOf(alt, seasonNumber, lang); if (!e.name && other.name) { e.name = other.name; e.nameLanguage = lang; } if (textOk && !e.overview && other.overview) { e.overview = other.overview; e.overviewLanguage = lang; } } if (textOk && !seasonOverview && s(k.overview)) { seasonOverview = s(k.overview); seasonOverviewLang = lang; } if ( isPlaceholderSeasonName(seasonName, seasonNumber) && s(k.name) && !isPlaceholderSeasonName(s(k.name), seasonNumber) ) { seasonName = s(k.name); seasonNameLang = lang; } } // 链走完仍没有标题:保留首选语言的占位名(界面显示"第 N 集"比空白好) for (const e of eps) { if (!e.name) { const p = placeholders.get(e.episodeNumber); if (p) { e.name = p; e.nameLanguage = null; } } } return { tvId: String(tvId), seasonNumber: int(j.season_number) ?? seasonNumber, name: seasonName, nameLanguage: seasonNameLang, overview: seasonOverview, overviewLanguage: seasonOverviewLang, airDate: s(j.air_date), posterPath: s(j.poster_path), episodes: eps, fetchedLanguages: fetched, }; }