/** * 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),头像只存路径(前端经图片代理)。 * 多语言保留规则(language-policy.ts):translations / alternative_titles 只留简中 / 繁中 / 英文 / 原语言, * 语言链按作品收敛(去掉既不是中英文、也不是作品原语言的语言)。 */ import type { MediaEntityType, TmdbClient } from "./client.ts"; import { allowedLanguageChain, altTitleLanguage, isAllowedLanguage, type WorkLanguages } from "./language-policy.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; /** 标语实际取到的语言(链内语言标签;落空为 null) */ taglineLanguage: 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; /** 标志(透明 PNG,刮削写 clearlogo.png);语言优先 原语言 → 中文 → 英文 → 无文字;没有为 null(旧数据可缺省) */ logoPath?: 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]; /** 语言链按作品收敛(保留规则);全被去掉时用缺省链 */ export function workLanguageChain(languages: readonly string[] | undefined, work: WorkLanguages): string[] { const eff = allowedLanguageChain(chainOf(languages), work); return eff.length ? eff : allowedLanguageChain(DEFAULT_METADATA_LANGUAGES, work); } 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) }; } /** 详情请求带回的图片语言:中文、英文、无文字;作品原语言不在其中时另取一次 /images */ export const DETAIL_IMAGE_LANGUAGES = "zh,en,null"; interface LogoRow { path: string; lang: string | null; vote: number; count: number; } const logoRows = (images: unknown): LogoRow[] => { const logos = ((images ?? {}) as Json).logos; return ((Array.isArray(logos) ? logos : []) as Json[]).flatMap((x) => { const path = s(x.file_path); // 只要 PNG:Kodi / Emby / Jellyfin 的 clearlogo 都是 png,TMDB 的 svg 标志不用 if (!path || !/\.png$/i.test(path)) return []; return [{ path, lang: s(x.iso_639_1), vote: n(x.vote_average) ?? 0, count: n(x.vote_count) ?? 0 }]; }); }; /** * 选一张标志:语言优先 作品原语言 → 中文 → 英文 → 无文字;同语言按评分、票数从高到低。没有 PNG 标志返回 null。 * images 可以是多份(详情带回的 + 原语言单独取的)。 */ export function pickLogo(images: unknown[], originalLanguage: string | null): string | null { const rows = images.flatMap(logoRows); const orig = originalLanguage?.toLowerCase().split("-")[0] ?? null; const order = [orig, "zh", "en", null].filter((x, i, a) => a.indexOf(x) === i && (x !== null || i === a.length - 1)); for (const lang of order) { const hit = rows .filter((r) => (r.lang?.toLowerCase() ?? null) === lang) .sort((a, b) => b.vote - a.vote || b.count - a.count)[0]; if (hit) return hit.path; } return null; } /** * 富详情:一次请求 `/{type}/{id}?language=链首&append_to_response=alternative_titles,credits,external_ids,translations,images` * (`include_image_language=zh,en,null`),标题 / 简介 / 标语按链从 translations(+ 同地区别名)取。 * 作品原语言不是中文 / 英文时另取 `/{type}/{id}/images?include_image_language=<原语言>` 选标志(失败忽略)。 * 不存在抛 TmdbError(notfound)。 */ export async function fetchRichDetail( client: Pick, type: MediaEntityType, id: string | number, languages?: readonly string[], opts: RichTextOptions = {}, ): Promise { const requested = chainOf(languages); const j = await client.getJson(`/${type}/${id}`, { language: requested[0], append_to_response: "alternative_titles,credits,external_ids,translations,images", include_image_language: DETAIL_IMAGE_LANGUAGES, }); const titleKey = type === "movie" ? "title" : "name"; const originalTitle = s(type === "movie" ? j.original_title : j.original_name); const originalLanguage = s(j.original_language); const originCountry = Array.isArray(j.origin_country) ? (j.origin_country as unknown[]).filter((x): x is string => typeof x === "string") : []; const work: WorkLanguages = { originalLanguage, originCountry }; // 保留规则:译名只留允许语言;别名按地区 + 文字推断语言,推断不出的丢弃;语言链按作品收敛 const translations = parseTranslations(j.translations, titleKey).filter((t) => isAllowedLanguage(t.lang, work)); const altTitles = parseAltTitles(j.alternative_titles) .map((a) => ({ ...a, language: altTitleLanguage(a, work) })) .filter((a) => a.language); const chain = workLanguageChain(requested, work); const textChain = overviewLanguages(chain, opts.overviewNonChineseFallback); // 请求语言不在保留范围内时,请求语言下的值不可信(是被去掉的语言),不拿来兜底 const reqLang = isAllowedLanguage(requested[0], work) ? requested[0]! : null; const primary = reqLang ? s(j[titleKey]) : null; 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: reqLang ? s(j.overview) : null, language: reqLang, }); const tagline = pickText(textChain, translations, "tagline", { value: reqLang ? s(j.tagline) : null, language: reqLang, }); 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); const logoSources: unknown[] = [j.images]; const origLang = originalLanguage?.toLowerCase().split("-")[0] ?? null; if (origLang && origLang !== "zh" && origLang !== "en") { try { logoSources.unshift(await client.getJson(`/${type}/${id}/images`, { include_image_language: origLang })); } catch { // 原语言标志取不到就用中文 / 英文 / 无文字的 } } return { entityType: type, externalId: String(id), title: title?.value ?? primary ?? originalTitle ?? s(j[titleKey]) ?? "", 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, taglineLanguage: tagline?.language ?? 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, posterPath: s(j.poster_path), backdropPath: s(j.backdrop_path), logoPath: pickLogo(logoSources, originalLanguage), 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; originalLanguage?: string | null } & RichTextOptions = {}, ): Promise { const requested = chainOf(languages); // 保留规则:链里有中英文以外的语言时要知道作品原语言才能判断;调用方没给就读一次作品详情(请求缓存) let originalLanguage = opts.originalLanguage; if (originalLanguage === undefined && allowedLanguageChain(requested, {}).length !== requested.length) { originalLanguage = await client .getJson(`/tv/${tvId}`, { language: requested[0] }) .then((d) => s(d.original_language)) .catch(() => null); } const chain = workLanguageChain(requested, { originalLanguage: originalLanguage ?? null }); 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, }; }