/** * TMDB 多语言取值(MD-1):按语言回退链从 translations / alternative_titles 取标题、简介。 * 迁移来源:MoviePilot app/modules/themoviedb/tmdbapi.py(e6820a8) * __update_tmdbinfo_cn_title:本地化名不含中文 → alternative_titles 的 CN 地区中文名 → 新加坡译名(sg_title); * __update_tmdbinfo_extra_title:translations 按地区取 en / hk / tw / sg 译名。 * 调整:MP 的"中文名 → CN 别名 → SG"写死在代码里,这里泛化成可配置的语言链(缺省 zh-CN → zh-SG → zh-TW → zh-HK → en-US), * 链中每一项先看 translations(语言 + 地区精确匹配),再看同地区、文字相符的 alternative_titles; * TMDB 在某语言没有译名时会把 name 回退成原名,所以不信任请求语言下的 name,只信 translations。 * MP 用 zhconv 判断 CN 别名是否简体,这里不引入繁简转换,只要求含汉字。 */ export const DEFAULT_METADATA_LANGUAGES: readonly string[] = ["zh-CN", "zh-SG", "zh-TW", "zh-HK", "en-US"]; export interface TmdbTranslation { /** BCP47,如 zh-CN;没有地区时只有语言 */ lang: string; iso639: string; region: string | null; title: string | null; overview: string | null; tagline: string | null; } export interface TmdbAltTitle { title: string; region: string | null; type: string | null; } /** 取到的值与其来源语言;via 说明来自哪里(给元数据来源页和测试看) */ export interface PickedText { value: string; language: string | null; via: "translation" | "alternative_title" | "original" | "primary"; } type Json = Record; const str = (v: unknown): string | null => (typeof v === "string" && v.trim() !== "" ? v.trim() : null); const HAN = /\p{Script=Han}/u; const KANA = /[\p{Script=Hiragana}\p{Script=Katakana}]/u; const HANGUL = /\p{Script=Hangul}/u; const LATIN = /\p{Script=Latin}/u; const containsChinese = (s: string | null | undefined): boolean => !!s && HAN.test(s); /** 标题的文字与语言是否相符(用于 alternative_titles 只有地区、没有语言的情况) */ export function scriptMatches(lang: string, text: string): boolean { const l = lang.split("-")[0]!.toLowerCase(); if (l === "zh") return HAN.test(text) && !KANA.test(text); if (l === "ja") return KANA.test(text) || HAN.test(text); if (l === "ko") return HANGUL.test(text); if (["en", "fr", "de", "es", "it", "pt", "nl", "sv", "da", "no", "fi", "pl", "cs", "tr"].includes(l)) return LATIN.test(text) && !HAN.test(text) && !KANA.test(text) && !HANGUL.test(text); return true; } function splitTag(tag: string): { lang: string; region: string | null } { const [l, r] = tag.split("-"); return { lang: (l ?? "").toLowerCase(), region: r ? r.toUpperCase() : null }; } /** translations.translations[] → 归一化;type 决定标题键(movie: title,tv / season / episode: name) */ export function parseTranslations(raw: unknown, titleKey: "title" | "name"): TmdbTranslation[] { const list = Array.isArray((raw as Json | null)?.translations) ? ((raw as Json).translations as Json[]) : Array.isArray(raw) ? (raw as Json[]) : []; const out: TmdbTranslation[] = []; for (const t of list) { const iso639 = str(t?.iso_639_1)?.toLowerCase(); if (!iso639) continue; const region = str(t.iso_3166_1)?.toUpperCase() ?? null; const data = (t.data ?? {}) as Json; out.push({ lang: region ? `${iso639}-${region}` : iso639, iso639, region, title: str(data[titleKey]) ?? str(data.title) ?? str(data.name), overview: str(data.overview), tagline: str(data.tagline), }); } return out; } /** alternative_titles(movie: titles,tv: results)→ 归一化 */ export function parseAltTitles(raw: unknown): TmdbAltTitle[] { const r = raw as Json | null; const list = (Array.isArray(r?.titles) ? r.titles : Array.isArray(r?.results) ? r.results : []) as Json[]; const out: TmdbAltTitle[] = []; for (const a of list) { const title = str(a?.title); if (!title) continue; out.push({ title, region: str(a.iso_3166_1)?.toUpperCase() ?? null, type: str(a.type) }); } return out; } /** 语言标签是否匹配某条译名:zh-CN 只匹配 zh/CN;只写语言(zh)时匹配该语言任意地区 */ function tagMatches(tag: string, t: Pick): boolean { const { lang, region } = splitTag(tag); if (t.iso639 !== lang) return false; return region === null || t.region === region; } export interface TitleSources { translations: readonly TmdbTranslation[]; altTitles?: readonly TmdbAltTitle[]; originalTitle?: string | null; originalLanguage?: string | null; /** 请求语言下的 name/title(TMDB 缺译名时是原名);链全部落空时的兜底 */ primary?: string | null; } /** * 按语言链取标题:每个语言先查 translations,再查同地区、文字相符的别名(类型为空的优先), * 原始语言与链中语言相同时原名也算该语言的值。全部落空:primary → 原名。 */ export function pickTitle(chain: readonly string[], src: TitleSources): PickedText | null { const origLang = src.originalLanguage?.toLowerCase() ?? null; for (const tag of chain) { const tr = src.translations.find((t) => tagMatches(tag, t) && t.title); if (tr?.title) return { value: tr.title, language: tag, via: "translation" }; const { lang, region } = splitTag(tag); if (region && src.altTitles?.length) { const alts = src.altTitles .filter((a) => a.region === region && scriptMatches(lang, a.title)) .sort((a, b) => (a.type ? 1 : 0) - (b.type ? 1 : 0)); if (alts[0]) return { value: alts[0].title, language: tag, via: "alternative_title" }; } if (origLang === lang && src.originalTitle) return { value: src.originalTitle, language: tag, via: "original" }; } if (src.primary) return { value: src.primary, language: null, via: "primary" }; if (src.originalTitle) return { value: src.originalTitle, language: origLang, via: "original" }; return null; } /** 中文名:链里的中文语言按链顺序(没有中文语言时用缺省中文链),取到的值必须含汉字 */ export function pickChineseTitle(chain: readonly string[], src: TitleSources): PickedText | null { const zh = chain.filter((t) => splitTag(t).lang === "zh"); const zhChain = zh.length ? zh : DEFAULT_METADATA_LANGUAGES.filter((t) => t.startsWith("zh")); const p = pickTitle(zhChain, { ...src, primary: null, originalTitle: src.originalTitle }); if (p && containsChinese(p.value)) return p; if (containsChinese(src.primary)) return { value: src.primary!, language: null, via: "primary" }; return null; } /** 简介 / 标语:按链取 translations 的非空值;全部落空时用 primary(请求语言下的原值) */ export function pickText( chain: readonly string[], translations: readonly TmdbTranslation[], key: "overview" | "tagline", primary?: { value: string | null; language: string | null }, ): PickedText | null { for (const tag of chain) { const tr = translations.find((t) => tagMatches(tag, t) && t[key]); if (tr?.[key]) return { value: tr[key]!, language: tag, via: "translation" }; } if (primary?.value) return { value: primary.value, language: primary.language, via: "primary" }; return null; } /** * TMDB 缺译名时给集生成的占位名("第 3 集"、"Episode 3"、"第 3 話"…),当作没有标题继续回退。 */ export function isPlaceholderEpisodeName(name: string | null | undefined, episodeNumber?: number): boolean { if (!name) return true; const s = name.trim(); if (!s) return true; const n = episodeNumber !== undefined ? String(episodeNumber) : "\\d+"; return new RegExp( `^(第\\s*${n}\\s*[集話话回]|Episode\\s*${n}|Épisode\\s*${n}|Folge\\s*${n}|Episodio\\s*${n}|エピソード\\s*${n}|${n}\\s*화|에피소드\\s*${n})$`, "i", ).test(s); } /** 季的占位名("第 1 季"、"Season 1"、"特别篇"不算占位) */ export function isPlaceholderSeasonName(name: string | null | undefined, seasonNumber?: number): boolean { if (!name) return true; const n = seasonNumber !== undefined ? String(seasonNumber) : "\\d+"; return new RegExp(`^(第\\s*${n}\\s*季|Season\\s*${n}|シーズン\\s*${n}|시즌\\s*${n})$`, "i").test(name.trim()); }