diff --git a/apps/web/app/components/settings/metadata.tsx b/apps/web/app/components/settings/metadata.tsx index 42562ce7..6bb86d94 100644 --- a/apps/web/app/components/settings/metadata.tsx +++ b/apps/web/app/components/settings/metadata.tsx @@ -54,7 +54,7 @@ const TMDB_CARD: CardSpec = { label: "语言回退链", kind: "text", placeholder: DEFAULT_LANGUAGES.join(", "), - hint: "标题和每集信息按顺序取第一个有值的语言,简介默认只取链里的中文;中文名取链中第一个中文值。逗号分隔,如 zh-CN, zh-SG", + hint: "标题和每集信息按顺序取第一个有值的语言,简介默认只取链里的中文;中文名取链中第一个中文值。只认简繁中文、英文和作品原语言(如日语作品的 ja-JP),其它语言跳过。逗号分隔,如 zh-CN, zh-SG", wide: true, }, { diff --git a/packages/adapters/src/tmdb/client.ts b/packages/adapters/src/tmdb/client.ts index 1bfdae00..692e8e84 100644 --- a/packages/adapters/src/tmdb/client.ts +++ b/packages/adapters/src/tmdb/client.ts @@ -9,6 +9,7 @@ */ 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"; @@ -27,7 +28,7 @@ export type MediaEntityType = "movie" | "tv"; export interface CandidateTitle { text: string; - /** BCP47,如 zh-CN;原名为原始语言;别名为 null */ + /** BCP47,如 zh-CN;原名为原始语言;别名为按地区 + 文字推断的语言(language-policy altTitleLanguage) */ lang: string | null; kind: "localized" | "original" | "alias" | "translation"; /** 别名/翻译的地区(iso_3166_1) */ @@ -395,9 +396,13 @@ export class TmdbClient { ), ); 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) TmdbClient.mergeTitle(c, { text: t, lang, kind: "localized" }); + 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[]; @@ -405,25 +410,24 @@ export class TmdbClient { 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 } : {}) }); + 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 同样把各语言译名计入可匹配名称 + // 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 } : {}), - }); + 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; diff --git a/packages/adapters/src/tmdb/details.ts b/packages/adapters/src/tmdb/details.ts index 393cd016..332f1995 100644 --- a/packages/adapters/src/tmdb/details.ts +++ b/packages/adapters/src/tmdb/details.ts @@ -6,8 +6,11 @@ * 请求缓存语义同 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, @@ -85,7 +88,7 @@ export interface TmdbRichDetail { translations: TmdbTranslation[]; alternativeTitles: TmdbAltTitle[]; seasons: TmdbRichSeason[]; - /** 本次使用的语言链 */ + /** 本次使用的语言链(按作品收敛后) */ languages: string[]; } @@ -126,6 +129,12 @@ export interface RichTextOptions { 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[]) @@ -165,24 +174,40 @@ export async function fetchRichDetail( languages?: readonly string[], opts: RichTextOptions = {}, ): Promise { - const chain = chainOf(languages); - const textChain = overviewLanguages(chain, opts.overviewNonChineseFallback); + const requested = chainOf(languages); const j = await client.getJson(`/${type}/${id}`, { - language: chain[0], + language: requested[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 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: s(j.overview), language: chain[0] ?? null }); - const tagline = pickText(textChain, translations, "tagline", { value: s(j.tagline), language: chain[0] ?? null }); + 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[]) : []; @@ -190,7 +215,7 @@ export async function fetchRichDetail( return { entityType: type, externalId: String(id), - title: title?.value ?? primary ?? originalTitle ?? "", + title: title?.value ?? primary ?? originalTitle ?? s(j[titleKey]) ?? "", titleLanguage: title?.language ?? null, titleZh: zh?.value ?? null, titleZhLanguage: zh?.language ?? null, @@ -212,9 +237,7 @@ export async function fetchRichDetail( 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") - : [], + originCountry, posterPath: s(j.poster_path), backdropPath: s(j.backdrop_path), cast, @@ -268,9 +291,18 @@ export async function fetchRichSeason( tvId: string | number, seasonNumber: number, languages?: readonly string[], - opts: { now?: Date } & RichTextOptions = {}, + opts: { now?: Date; originalLanguage?: string | null } & RichTextOptions = {}, ): Promise { - const chain = chainOf(languages); + 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); diff --git a/packages/adapters/src/tmdb/index.ts b/packages/adapters/src/tmdb/index.ts index 4676f6bd..a3de0f61 100644 --- a/packages/adapters/src/tmdb/index.ts +++ b/packages/adapters/src/tmdb/index.ts @@ -2,6 +2,7 @@ export { RequestLimiter, TtlLruCache } from "./cache.ts"; export * from "./client.ts"; export * from "./details.ts"; export * from "./episode-groups.ts"; +export * from "./language-policy.ts"; export * from "./match.ts"; export * from "./recall.ts"; export * from "./translations.ts"; diff --git a/packages/adapters/src/tmdb/language-policy.test.ts b/packages/adapters/src/tmdb/language-policy.test.ts new file mode 100644 index 00000000..f877119d --- /dev/null +++ b/packages/adapters/src/tmdb/language-policy.test.ts @@ -0,0 +1,146 @@ +import { createMockTmdb, type MockTmdb, TMDB_FIXTURES } from "@umt/testkit"; +import { afterEach, describe, expect, it } from "vitest"; +import { + allowedLanguage, + allowedLanguageChain, + altTitleLanguage, + chineseScriptOf, + createTmdbClient, + fetchRichDetail, + textMatchesLanguage, + untaggedTextAllowed, +} from "./index.ts"; + +const JA = { originalLanguage: "ja", originCountry: ["JP"] }; +const KO = { originalLanguage: "ko", originCountry: ["KR"] }; +const TH = { originalLanguage: "th", originCountry: ["TH"] }; +const ZH = { originalLanguage: "zh", originCountry: ["CN"] }; +const EN = { originalLanguage: "en", originCountry: ["US"] }; + +describe("多语言保留规则:语言标签", () => { + it("简中 / 繁中 / 英文总是保留,规范化大小写", () => { + for (const tag of ["zh-CN", "zh-SG", "zh-Hans", "zh-TW", "zh-HK", "zh-MO", "zh-Hant", "zh", "en-US", "en-GB", "en"]) + expect(allowedLanguage(tag, EN)).toBe(tag); + expect(allowedLanguage("zh-cn", EN)).toBe("zh-CN"); + expect(allowedLanguage("zh_hant", EN)).toBe("zh-Hant"); + expect(allowedLanguage("EN-au", EN)).toBe("en-AU"); + }); + + it("其它语言只在是作品原语言时保留", () => { + for (const tag of ["ru-RU", "pt-BR", "vi-VN", "es-ES", "th-TH", "bg-BG", "he-IL", "fr-FR", "ja-JP", "ko-KR"]) + expect(allowedLanguage(tag, EN)).toBeNull(); + expect(allowedLanguage("ja-JP", JA)).toBe("ja-JP"); + expect(allowedLanguage("ja", JA)).toBe("ja"); + expect(allowedLanguage("ko-KR", JA)).toBeNull(); + expect(allowedLanguage("ko-KR", KO)).toBe("ko-KR"); + expect(allowedLanguage("th-TH", TH)).toBe("th-TH"); + // 原语言是中文:中文的其它地区也算原语言;不额外增加别的语言 + expect(allowedLanguage("zh-MY", ZH)).toBe("zh-MY"); + expect(allowedLanguage("zh-MY", EN)).toBeNull(); + expect(allowedLanguage("ja-JP", ZH)).toBeNull(); + // 没有原语言信息:只有中英文 + expect(allowedLanguage("ja-JP", {})).toBeNull(); + expect(allowedLanguage(null, JA)).toBeNull(); + expect(allowedLanguage("", JA)).toBeNull(); + }); + + it("语言链按作品收敛:去掉不保留的语言,保持顺序", () => { + const chain = ["ja-JP", "zh-CN", "fr-FR", "zh-TW", "en-US"]; + expect(allowedLanguageChain(chain, JA)).toEqual(chain.filter((x) => x !== "fr-FR")); + expect(allowedLanguageChain(chain, EN)).toEqual(["zh-CN", "zh-TW", "en-US"]); + expect(allowedLanguageChain(["fr-FR"], EN)).toEqual([]); + }); +}); + +describe("多语言保留规则:文字校验", () => { + it("简繁特有字", () => { + expect(chineseScriptOf("人生切割术")).toBe("zh-Hans"); + expect(chineseScriptOf("人生切割術")).toBe("zh-Hant"); + expect(chineseScriptOf("沙丘")).toBeNull(); + }); + + it("各语言的文字", () => { + expect(textMatchesLanguage("zh-CN", "奥本海默传")).toBe(true); + expect(textMatchesLanguage("zh-CN", "奧本海默傳")).toBe(false); // 明显繁体 + expect(textMatchesLanguage("zh-TW", "最後生還者")).toBe(true); + expect(textMatchesLanguage("zh-TW", "最后的生还者")).toBe(false); + expect(textMatchesLanguage("zh", "最後生還者")).toBe(true); // 不带地区不看简繁 + expect(textMatchesLanguage("zh-CN", "Severance CN")).toBe(false); + expect(textMatchesLanguage("zh-CN", "進撃の巨人")).toBe(false); + expect(textMatchesLanguage("ja-JP", "進撃の巨人")).toBe(true); + expect(textMatchesLanguage("ja-JP", "Shingeki no Kyojin")).toBe(false); + expect(textMatchesLanguage("ko-KR", "오징어 게임")).toBe(true); + expect(textMatchesLanguage("ko-KR", "Squid Game")).toBe(false); + expect(textMatchesLanguage("th-TH", "ฉลาดเกมส์โกง")).toBe(true); + expect(textMatchesLanguage("th-TH", "Bad Genius")).toBe(false); + expect(textMatchesLanguage("en-US", "Bad Genius")).toBe(true); + expect(textMatchesLanguage("en-US", "Очень странные дела")).toBe(false); + expect(textMatchesLanguage("ru-RU", "Очень странные дела")).toBe(true); + expect(textMatchesLanguage("ja-Latn", "Shingeki no Kyojin")).toBe(true); + }); +}); + +describe("多语言保留规则:alternative_titles 按地区推断语言", () => { + it("CN / SG → 简中,TW / HK / MO → 繁中;文字不符丢弃", () => { + expect(altTitleLanguage({ title: "人生切割术", region: "CN" }, EN)).toBe("zh-CN"); + expect(altTitleLanguage({ title: "生活割離術", region: "HK" }, EN)).toBe("zh-HK"); + expect(altTitleLanguage({ title: "人生切割術", region: "CN" }, EN)).toBeNull(); + expect(altTitleLanguage({ title: "Severance CN", region: "CN" }, EN)).toBeNull(); + expect(altTitleLanguage({ title: "奧本海默", region: "TW" }, EN)).toBe("zh-TW"); + }); + + it("英语区 → 英文;其它地区只在是原产地区时 → 原语言", () => { + expect(altTitleLanguage({ title: "Dune 2", region: "US" }, EN)).toBe("en-US"); + expect(altTitleLanguage({ title: "Attack on Titan", region: "GB" }, JA)).toBe("en-GB"); + expect(altTitleLanguage({ title: "Дюна 2", region: "RU" }, EN)).toBeNull(); + expect(altTitleLanguage({ title: "Duna: Parte Dois", region: "BR" }, EN)).toBeNull(); + expect(altTitleLanguage({ title: "進撃の巨人", region: "JP" }, JA)).toBe("ja-JP"); + expect(altTitleLanguage({ title: "進撃の巨人", region: "JP" }, EN)).toBeNull(); + expect(altTitleLanguage({ title: "오징어 게임", region: "KR" }, KO)).toBe("ko-KR"); + expect(altTitleLanguage({ title: "ฉลาดเกมส์โกง", region: "TH" }, TH)).toBe("th-TH"); + expect(altTitleLanguage({ title: "Bad Genius", region: "TH" }, TH)).toBeNull(); + expect(altTitleLanguage({ title: "流浪地球 2", region: "CN" }, ZH)).toBe("zh-CN"); + expect(altTitleLanguage({ title: "The Wandering Earth 2", region: "US" }, ZH)).toBe("en-US"); + expect(altTitleLanguage({ title: "无地区", region: null }, ZH)).toBeNull(); + }); + + it("原产地区的罗马字别名记原语言拉丁转写", () => { + expect(altTitleLanguage({ title: "Shingeki no Kyojin", region: "JP", type: "Romaji" }, JA)).toBe("ja-Latn"); + expect(altTitleLanguage({ title: "Shingeki no Kyojin", region: "JP", type: "" }, JA)).toBeNull(); + expect(altTitleLanguage({ title: "Shingeki no Kyojin", region: "FR", type: "Romaji" }, JA)).toBeNull(); + }); + + it("没有语言标记的旧别名按文字判断", () => { + expect(untaggedTextAllowed("人生切割术", EN)).toBe(true); + expect(untaggedTextAllowed("Severance", EN)).toBe(true); + expect(untaggedTextAllowed("Разделение", EN)).toBe(false); + expect(untaggedTextAllowed("세브란스", EN)).toBe(false); + expect(untaggedTextAllowed("세브란스", KO)).toBe(true); + expect(untaggedTextAllowed("ฉลาดเกมส์โกง", TH)).toBe(true); + expect(untaggedTextAllowed("2049", EN)).toBe(true); + }); +}); + +describe("多语言保留规则:TMDB 详情", () => { + let mock: MockTmdb | null = null; + afterEach(async () => { + await mock?.close(); + mock = null; + }); + + it("details() 与富详情只留保留语言的译名与别名(奥本海默的 ja-JP 译名不要)", async () => { + mock = await createMockTmdb({ fixtures: TMDB_FIXTURES }); + const tmdb = createTmdbClient({ baseUrl: `${mock.url}/3`, apiKey: "test-tmdb-key", retries: 0, timeoutMs: 1000 }); + const d = await tmdb.details("movie", 872585); + expect(d.aliases).not.toContain("オッペンハイマー"); + expect(d.aliases).toEqual(expect.arrayContaining(["奥本海默传", "奧本海默"])); + expect(d.titles.find((t) => t.text === "奥本海默传")).toMatchObject({ kind: "alias", lang: "zh-CN" }); + const rich = await fetchRichDetail(tmdb, "movie", 872585, ["ja-JP", "zh-CN", "en-US"]); + expect(rich.translations.map((t) => t.lang).sort()).toEqual(["en-US", "zh-CN"]); + expect(rich.alternativeTitles.map((a) => a.language).sort()).toEqual(["zh-CN", "zh-TW"]); + // 请求语言 ja-JP 不在保留范围:语言链收敛,不用 ja-JP 的值兜底 + expect(rich.languages).toEqual(["zh-CN", "en-US"]); + expect(rich.title).toBe("奥本海默"); + expect(rich.titleLanguage).toBe("zh-CN"); + }); +}); diff --git a/packages/adapters/src/tmdb/language-policy.ts b/packages/adapters/src/tmdb/language-policy.ts new file mode 100644 index 00000000..01e3a99e --- /dev/null +++ b/packages/adapters/src/tmdb/language-policy.ts @@ -0,0 +1,207 @@ +/** + * TMDB 多语言保留规则(用户规则 2026-10-06):TMDB 的译名 / 别名 / 简介 / 标语只保留 + * 简体中文(zh-CN / zh-SG / zh-Hans,及不带地区的 zh)、繁体中文(zh-TW / zh-HK / zh-MO / zh-Hant)、 + * 英文(en-*)、作品原语言(original_language,如 ja / ko / th;原语言是中文 / 英文时不额外增加)。 + * 原名(original_title / original_name)总是保留,不经过这里。 + * + * alternative_titles 只有地区(iso_3166_1)没有语言:按地区推断——CN / SG → 简中,TW / HK / MO → 繁中, + * US / GB / AU / CA / NZ / IE → 英文,作品 origin_country 里的地区 → 原语言;再按文字校验(汉字简繁 / 假名 / 谚文 / + * 泰文 / 西里尔 / 拉丁 …),对不上的丢弃。例外:原产地区的罗马字别名(type 含 roma,如 TMDB 的 "Romaji")记为 + * 原语言的拉丁转写 `<原语言>-Latn`,资源标题常用罗马字。 + * MP 对照:tmdbapi.__get_names 把全部 alternative_titles 与全部 translations 都当可匹配名称; + * __update_tmdbinfo_extra_title 只取 en / hk / tw / sg 译名,__update_tmdbinfo_cn_title 取 CN 地区别名。 + */ + +export interface WorkLanguages { + /** TMDB original_language(ISO 639-1,如 ja) */ + originalLanguage?: string | null; + /** TMDB origin_country(ISO 3166-1,如 ["JP"]) */ + originCountry?: readonly string[] | null; +} + +const HANS_REGIONS = new Set(["CN", "SG"]); +const HANT_REGIONS = new Set(["TW", "HK", "MO"]); +/** 英语区:别名按地区推断为英文 */ +export const ENGLISH_REGIONS: ReadonlySet = new Set(["US", "GB", "AU", "CA", "NZ", "IE"]); + +interface Tag { + lang: string; + script: string | null; + region: string | null; +} + +function parseTag(tag: string | null | undefined): Tag | null { + if (!tag) return null; + const parts = tag.trim().split(/[-_]/).filter(Boolean); + const lang = parts[0]?.toLowerCase(); + if (!lang || !/^[a-z]{2,3}$/.test(lang)) return null; + let script: string | null = null; + let region: string | null = null; + for (const p of parts.slice(1)) { + if (/^[A-Za-z]{4}$/.test(p)) script = p[0]!.toUpperCase() + p.slice(1).toLowerCase(); + else if (/^([A-Za-z]{2}|\d{3})$/.test(p)) region = p.toUpperCase(); + } + return { lang, script, region }; +} + +const formatTag = (t: Tag) => [t.lang, t.script, t.region].filter(Boolean).join("-"); +const primaryOf = (lang: string | null | undefined) => parseTag(lang)?.lang ?? null; + +/** 中文标签的简繁:zh-Hans / zh-CN / zh-SG / zh → 简;zh-Hant / zh-TW / zh-HK / zh-MO → 繁;其它地区 null */ +export function chineseVariant(tag: string | null | undefined): "zh-Hans" | "zh-Hant" | null { + const t = parseTag(tag); + if (t?.lang !== "zh") return null; + if (t.script === "Hans") return "zh-Hans"; + if (t.script === "Hant") return "zh-Hant"; + if (!t.region || HANS_REGIONS.has(t.region)) return "zh-Hans"; + if (HANT_REGIONS.has(t.region)) return "zh-Hant"; + return null; +} + +/** + * 语言标签是否在保留范围内;在范围内返回规范化后的标签(zh-cn → zh-CN,zh_hant → zh-Hant),否则 null。 + * 原语言按主语言比较(作品原语言 ja 时 ja-JP / ja 都保留)。 + */ +export function allowedLanguage(tag: string | null | undefined, work: WorkLanguages = {}): string | null { + const t = parseTag(tag); + if (!t) return null; + const canonical = formatTag(t); + if (t.lang === "en") return canonical; + const orig = primaryOf(work.originalLanguage); + if (orig && t.lang === orig) return canonical; + if (t.lang === "zh") return chineseVariant(canonical) ? canonical : null; + return null; +} + +export const isAllowedLanguage = (tag: string | null | undefined, work: WorkLanguages = {}): boolean => + allowedLanguage(tag, work) !== null; + +/** 语言链按作品收敛:去掉不在保留范围内的语言(保持顺序、去重);全被去掉时返回空数组 */ +export function allowedLanguageChain(chain: readonly string[], work: WorkLanguages = {}): string[] { + const out: string[] = []; + for (const tag of chain) { + const a = allowedLanguage(tag, work); + if (a && !out.includes(a)) out.push(a); + } + return out; +} + +// ── 文字校验 ── + +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 NON_LATIN = + /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}\p{Script=Thai}\p{Script=Cyrillic}\p{Script=Arabic}\p{Script=Hebrew}\p{Script=Devanagari}\p{Script=Greek}]/u; + +const SCRIPT_OF: Record = { + th: /\p{Script=Thai}/u, + ru: /\p{Script=Cyrillic}/u, + uk: /\p{Script=Cyrillic}/u, + bg: /\p{Script=Cyrillic}/u, + sr: /\p{Script=Cyrillic}/u, + be: /\p{Script=Cyrillic}/u, + kk: /\p{Script=Cyrillic}/u, + mk: /\p{Script=Cyrillic}/u, + mn: /\p{Script=Cyrillic}/u, + ar: /\p{Script=Arabic}/u, + fa: /\p{Script=Arabic}/u, + ur: /\p{Script=Arabic}/u, + he: /\p{Script=Hebrew}/u, + hi: /\p{Script=Devanagari}/u, + mr: /\p{Script=Devanagari}/u, + ne: /\p{Script=Devanagari}/u, + bn: /\p{Script=Bengali}/u, + ta: /\p{Script=Tamil}/u, + te: /\p{Script=Telugu}/u, + ml: /\p{Script=Malayalam}/u, + kn: /\p{Script=Kannada}/u, + el: /\p{Script=Greek}/u, + ka: /\p{Script=Georgian}/u, + hy: /\p{Script=Armenian}/u, + km: /\p{Script=Khmer}/u, + lo: /\p{Script=Lao}/u, + my: /\p{Script=Myanmar}/u, +}; + +// 简繁不同形的常用字(影视标题常见),各一份;只用于"明显是另一种"的判断,两边都没有时不否决 +const TRAD_CHARS = + "們這個說時會來對後與麼還沒讓從無過開關問題點學實現發見頭聽話體愛應經義東車長門國電腦網買賣歡寫讀樣種邊戰爭傳龍鳳驚險夢戀劍俠島鬥獸獵殺將軍漢華歲親媽飛機進錄離雙號達偵線紅綠藍黃銀鐵鋼風雲龜馬魚鳥雞貓獅書畫樂聲劇場戲錢貴慶萬歷憶夠壞隊員陽陰靈屍謎諜襲獄惡復紀傑紳蘭麗嬌婦兒孫師醫術獨勝敗強鎮縣區莊園間塵煙燈熱氣溫聖誕節單狀態舊頁顏預選樓廳寶滅絕斷續終結細織純給約級總絲鄉歸處偉劉張陳楊趙吳鄭謝許蘇盧蔣韓鄧馮譚齊顧錦繡濤潔漁灣臺審計記憶護衛權勢語誤談識詩誰變奪奮緣獻歐擊鏡響頂順"; +const SIMP_CHARS = + "们这个说时会来对与么还没让从无过开关问题点学实现发见头听话体爱应经义东车长门国电脑网买卖欢写读样种边战争传龙凤惊险梦恋剑侠岛兽猎杀将军汉华岁亲妈飞机进录离双号达侦线红绿蓝黄银铁钢风龟马鱼鸟鸡猫狮书画乐声剧场戏钱贵庆万历忆够坏队员阳阴灵尸谜谍袭狱恶复纪杰绅兰丽娇妇儿孙师医术独胜败强镇县区庄园间尘烟灯热气温圣诞节单状态旧页颜预选楼厅宝灭绝断续终结细织纯给约级总丝乡归处伟刘张陈杨赵吴郑谢许苏卢蒋韩邓冯谭齐顾锦绣涛洁渔湾审计记忆护卫权势语误谈识诗谁变夺奋缘献欧击镜响顶顺"; +const TRAD = new Set([...TRAD_CHARS].filter((c) => !SIMP_CHARS.includes(c))); +const SIMP = new Set([...SIMP_CHARS].filter((c) => !TRAD_CHARS.includes(c))); + +/** 汉字文本的简繁倾向:只有繁体特有字 → hant;只有简体特有字 → hans;都有或都没有 → null */ +export function chineseScriptOf(text: string): "zh-Hans" | "zh-Hant" | null { + let t = 0; + let s = 0; + for (const c of text) { + if (TRAD.has(c)) t++; + else if (SIMP.has(c)) s++; + } + if (t && !s) return "zh-Hant"; + if (s && !t) return "zh-Hans"; + return null; +} + +/** + * 标题文字与语言标签是否相符:zh 要求汉字且不含假名 / 谚文(带地区或 Hans / Hant 时再看简繁特有字,明显是另一种的不符); + * ja 要求假名或汉字;ko 要求谚文;泰 / 俄 / 阿 / 希伯来 / 印地等按各自文字;`-Latn` 与其余语言按拉丁字母且不含非拉丁强文字。 + */ +export function textMatchesLanguage(tag: string, text: string): boolean { + const t = parseTag(tag); + if (!t) return false; + if (t.script === "Latn") return LATIN.test(text) && !NON_LATIN.test(text); + if (t.lang === "zh") { + if (!HAN.test(text) || KANA.test(text) || HANGUL.test(text)) return false; + const want = chineseVariant(tag); + const got = chineseScriptOf(text); + return !want || !got || want === got || !(t.region || t.script); + } + if (t.lang === "ja") return (KANA.test(text) || HAN.test(text)) && !HANGUL.test(text); + if (t.lang === "ko") return HANGUL.test(text); + const re = SCRIPT_OF[t.lang]; + if (re) return re.test(text); + return LATIN.test(text) && !NON_LATIN.test(text); +} + +/** + * alternative_titles 的一条(只有地区)推断语言:地区候选依次为 简中(CN / SG)/ 繁中(TW / HK / MO)/ 英文(英语区)/ + * 原语言(作品原产地区);取第一个文字相符的。原产地区的罗马字别名(type 含 roma)记 `<原语言>-Latn`。 + * 推断不出或文字都不符返回 null(该别名不保留)。 + */ +export function altTitleLanguage( + alt: { title: string; region: string | null; type?: string | null }, + work: WorkLanguages = {}, +): string | null { + const region = alt.region?.toUpperCase() ?? null; + if (!region) return null; + const orig = primaryOf(work.originalLanguage); + const home = (work.originCountry ?? []).some((c) => c.toUpperCase() === region); + const tries: string[] = []; + if (HANS_REGIONS.has(region) || HANT_REGIONS.has(region)) tries.push(`zh-${region}`); + if (ENGLISH_REGIONS.has(region)) tries.push(`en-${region}`); + if (home && orig) tries.push(`${orig}-${region}`); + for (const tag of tries) { + const a = allowedLanguage(tag, work); + if (a && textMatchesLanguage(a, alt.title)) return a; + } + if (home && orig && orig !== "en" && /roma/i.test(alt.type ?? "") && textMatchesLanguage("und-Latn", alt.title)) + return `${orig}-Latn`; + return null; +} + +/** + * 没有语言标记的已存别名(旧数据)按文字判断是否可能属于保留语言:汉字(不含假名 / 谚文)→ 中文;拉丁 → 英文(无法区分 + * 法 / 西 / 葡等拉丁语言,按英文保留);其余文字只在与原语言相符时保留。没有字母的(纯数字 / 符号)保留。 + */ +export function untaggedTextAllowed(text: string, work: WorkLanguages = {}): boolean { + if (!/\p{L}/u.test(text)) return true; + if (textMatchesLanguage("zh", text)) return true; + if (LATIN.test(text) && !NON_LATIN.test(text)) return true; + const orig = primaryOf(work.originalLanguage); + return !!orig && orig !== "zh" && orig !== "en" && textMatchesLanguage(orig, text); +} diff --git a/packages/adapters/src/tmdb/translations.ts b/packages/adapters/src/tmdb/translations.ts index 80a6bd7a..a68604bb 100644 --- a/packages/adapters/src/tmdb/translations.ts +++ b/packages/adapters/src/tmdb/translations.ts @@ -25,6 +25,8 @@ export interface TmdbAltTitle { title: string; region: string | null; type: string | null; + /** 按地区 + 文字推断的语言(language-policy altTitleLanguage);fetchRichDetail 只留推断得出的 */ + language?: string | null; } /** 取到的值与其来源语言;via 说明来自哪里(给元数据来源页和测试看) */ diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index a604c1f8..3df252fb 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -256,7 +256,9 @@ export const MetadataSettings = z.object({ tmdbBaseUrl: z.url().nullable(), /** * 元数据语言回退链(MD-1):作品 / 季 / 集的标题与简介按顺序取第一个非空值;中文名(titleZh)取链中第一个中文值。 - * BCP47 语言-地区,如 zh-CN;缺省 zh-CN → zh-SG → zh-TW → zh-HK → en-US + * BCP47 语言-地区,如 zh-CN;缺省 zh-CN → zh-SG → zh-TW → zh-HK → en-US。 + * 多语言保留规则(adapters tmdb/language-policy.ts):只有简中(zh-CN / zh-SG)、繁中(zh-TW / zh-HK / zh-MO)、 + * 英文(en-*)和作品原语言生效;其它语言(如 ja-JP)只在作品原语言是它时生效,否则按作品跳过 */ languages: z .array(z.string().regex(/^[a-z]{2,3}(-[A-Z]{2})?$/))