From fc1856c2e34a457de496cd7424b344ea6fbf0c61 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 29 Sep 2026 17:28:16 +0800 Subject: [PATCH] feat(tmdb): language fallback chain, rich details and per-episode season fetch (MD-1) Co-Authored-By: Claude Opus 5.5 --- packages/adapters/src/tmdb/client.ts | 11 + packages/adapters/src/tmdb/details.test.ts | 147 +++++++++ packages/adapters/src/tmdb/details.ts | 339 +++++++++++++++++++++ packages/adapters/src/tmdb/index.ts | 2 + packages/adapters/src/tmdb/translations.ts | 184 +++++++++++ packages/contracts/test/schemas.test.ts | 1 + packages/testkit/src/tmdb/fixtures.ts | 112 ++++++- packages/testkit/src/tmdb/mock-tmdb.ts | 43 ++- 8 files changed, 834 insertions(+), 5 deletions(-) create mode 100644 packages/adapters/src/tmdb/details.test.ts create mode 100644 packages/adapters/src/tmdb/details.ts create mode 100644 packages/adapters/src/tmdb/translations.ts diff --git a/packages/adapters/src/tmdb/client.ts b/packages/adapters/src/tmdb/client.ts index 78abe02a..15475f70 100644 --- a/packages/adapters/src/tmdb/client.ts +++ b/packages/adapters/src/tmdb/client.ts @@ -252,6 +252,17 @@ export class TmdbClient { } } + /** + * 原始 JSON 请求(走同一套缓存 / 限流 / 重试 / 脱敏):details.ts 的多语言详情、季集详情用。 + * path 以 `/` 开头(不含 /3 前缀),params 不含凭证。 + */ + async getJson>( + path: string, + params: Record = {}, + ): Promise { + return this.get(path, params); + } + /** 单语言原始搜索(匹配链使用,对照 MP search.movies/tv_shows,语言取首选)。 */ async searchRaw(type: MediaEntityType, query: string, year?: string | number): Promise { const lang = this.langs[0]!; diff --git a/packages/adapters/src/tmdb/details.test.ts b/packages/adapters/src/tmdb/details.test.ts new file mode 100644 index 00000000..9e5d1b0a --- /dev/null +++ b/packages/adapters/src/tmdb/details.test.ts @@ -0,0 +1,147 @@ +import { createMockTmdb, type MockTmdb } from "@umt/testkit"; +import { afterEach, describe, expect, it } from "vitest"; +import { + createTmdbClient, + fetchRichDetail, + fetchRichSeason, + isPlaceholderEpisodeName, + parseAltTitles, + parseTranslations, + pickChineseTitle, + pickText, + pickTitle, +} from "./index.ts"; + +let mock: MockTmdb | null = null; +afterEach(async () => { + await mock?.close(); + mock = null; +}); + +async function setup() { + mock = await createMockTmdb(); + const tmdb = createTmdbClient({ baseUrl: `${mock.url}/3`, apiKey: "test-tmdb-key", retries: 0, timeoutMs: 1000 }); + return { mock, tmdb }; +} + +const CHAIN = ["zh-CN", "zh-SG", "zh-TW", "zh-HK", "en-US"]; + +describe("语言回退链(纯函数)", () => { + const translations = parseTranslations( + { + translations: [ + { iso_639_1: "zh", iso_3166_1: "CN", data: { name: "", overview: "简中简介" } }, + { iso_639_1: "zh", iso_3166_1: "TW", data: { name: "人生切割術", overview: "繁中簡介" } }, + { iso_639_1: "en", iso_3166_1: "US", data: { name: "", overview: "English" } }, + ], + }, + "name", + ); + const altTitles = parseAltTitles({ + results: [ + { iso_3166_1: "CN", title: "Severance CN", type: "" }, + { iso_3166_1: "CN", title: "人生切割术", type: "" }, + { iso_3166_1: "HK", title: "生活割離術", type: "" }, + ], + }); + const src = { translations, altTitles, originalTitle: "Severance", originalLanguage: "en", primary: "Severance" }; + + it("zh-CN 译名为空时取 CN 地区的中文别名(对照 MP __update_tmdbinfo_cn_title)", () => { + expect(pickTitle(CHAIN, src)).toEqual({ value: "人生切割术", language: "zh-CN", via: "alternative_title" }); + expect(pickChineseTitle(CHAIN, src)?.value).toBe("人生切割术"); + }); + + it("没有 CN 别名时按链落到 zh-TW 译名;链只有英文时英文原名", () => { + const noCn = { ...src, altTitles: altTitles.filter((a) => a.region !== "CN") }; + expect(pickTitle(CHAIN, noCn)).toMatchObject({ value: "人生切割術", language: "zh-TW", via: "translation" }); + expect(pickTitle(["en-US"], noCn)).toMatchObject({ value: "Severance", language: "en-US", via: "original" }); + expect(pickTitle(["ja-JP"], { ...noCn, originalLanguage: "ko" })).toMatchObject({ via: "primary" }); + }); + + it("简介按链取第一个非空值", () => { + expect(pickText(CHAIN, translations, "overview")).toMatchObject({ value: "简中简介", language: "zh-CN" }); + expect(pickText(["zh-HK", "en-US"], translations, "overview")).toMatchObject({ value: "English" }); + expect(pickText(["ja-JP"], translations, "overview", { value: null, language: null })).toBeNull(); + }); + + it("占位集名识别", () => { + expect(isPlaceholderEpisodeName("第 3 集", 3)).toBe(true); + expect(isPlaceholderEpisodeName("Episode 3", 3)).toBe(true); + expect(isPlaceholderEpisodeName("第3話")).toBe(true); + expect(isPlaceholderEpisodeName("第 3 集", 4)).toBe(false); + expect(isPlaceholderEpisodeName("有关地狱的好消息", 1)).toBe(false); + expect(isPlaceholderEpisodeName("")).toBe(true); + }); +}); + +describe("fetchRichDetail / fetchRichSeason(mock TMDB)", () => { + it("Severance:标题取 CN 别名,中文名 zh-CN,简介 zh-CN,主创与外部 ID", async () => { + const { tmdb, mock } = await setup(); + const d = await fetchRichDetail(tmdb, "tv", 95396, CHAIN); + expect(d.title).toBe("人生切割术"); + expect(d.titleLanguage).toBe("zh-CN"); + expect(d.titleZh).toBe("人生切割术"); + expect(d.overview).toBe("故事发生在一家名为卢蒙的超级大公司内。"); + expect(d.overviewLanguage).toBe("zh-CN"); + expect(d.genres).toEqual(["剧情", "悬疑", "Sci-Fi & Fantasy"]); + expect(d.voteAverage).toBe(8.4); + expect(d.status).toBe("Returning Series"); + expect(d.networks).toEqual(["Apple TV"]); + expect(d.cast[0]).toEqual({ + id: 4495, + name: "亚当·斯科特", + originalName: null, + role: "Mark Scout", + profilePath: "/adam.jpg", + }); + expect(d.crew[0]).toMatchObject({ name: "丹·埃里克森", role: "Creator" }); + expect(d.externalIds).toEqual({ imdb: "tt11280740", tvdb: "371980", wikidata: null }); + expect(d.seasons.map((s) => s.seasonNumber)).toEqual([1, 2]); + // 一次请求(credits 等随 append_to_response) + expect(mock.calls.filter((r) => r.path === "/3/tv/95396")).toHaveLength(1); + }); + + it("链首 en-US:英文原名、英文简介,中文名仍按中文链取", async () => { + const { tmdb } = await setup(); + const d = await fetchRichDetail(tmdb, "tv", 95396, ["en-US"]); + expect(d.title).toBe("Severance"); + expect(d.overview).toBe("Mark leads a team of office workers."); + expect(d.titleZh).toBe("人生切割术"); + }); + + it("季集:首选语言缺标题 / 简介的已播集按链补拉;未播出的集不触发补拉", async () => { + const { tmdb, mock } = await setup(); + const s = await fetchRichSeason(tmdb, 95396, 1, CHAIN, { now: new Date("2026-01-01") }); + expect(s.name).toBe("第 1 季"); + expect(s.overview).toContain("卢蒙"); + const [e1, e2, e3] = s.episodes; + expect(e1).toMatchObject({ + name: "有关地狱的好消息", + nameLanguage: "zh-CN", + overview: "马克升职成为部门主管。", + runtime: 59, + stillPath: "/sev-101.jpg", + voteAverage: 8.2, + voteCount: 171, + }); + expect(e2).toMatchObject({ name: "半循環", nameLanguage: "zh-TW", overview: "馬克帶領新員工參觀。" }); + // 链走完仍没有中文名:en-US 标题;简介各语言都空 + expect(e3).toMatchObject({ name: "In Perpetuity", nameLanguage: "en-US", overview: null }); + expect(s.fetchedLanguages).toEqual(CHAIN); + const perLang = mock.calls.filter((r) => r.path === "/3/tv/95396/season/1").map((r) => r.query.language); + expect(perLang).toEqual(CHAIN); + + mock.calls.length = 0; + const s2 = await fetchRichSeason(tmdb, 95396, 2, CHAIN, { now: new Date("2026-01-01") }); + expect(s2.episodes[0]).toMatchObject({ name: "第 1 集", nameLanguage: null }); + expect(s2.fetchedLanguages).toEqual(["zh-CN"]); + }); + + it("补拉走请求缓存:同一季第二次不再请求", async () => { + const { tmdb, mock } = await setup(); + await fetchRichSeason(tmdb, 95396, 1, CHAIN, { now: new Date("2026-01-01") }); + const n = mock.calls.length; + await fetchRichSeason(tmdb, 95396, 1, CHAIN, { now: new Date("2026-01-01") }); + expect(mock.calls.length).toBe(n); + }); +}); diff --git a/packages/adapters/src/tmdb/details.ts b/packages/adapters/src/tmdb/details.ts new file mode 100644 index 00000000..5349c6ba --- /dev/null +++ b/packages/adapters/src/tmdb/details.ts @@ -0,0 +1,339 @@ +/** + * 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);季集在首选语言缺标题(占位名)或缺简介时按链逐个语言补拉, + * 链走完或全部补齐即停。主创只留前 10 位演员 + 导演 / 主创(created_by),头像只存路径(前端经图片代理)。 + */ +import type { MediaEntityType, TmdbClient } from "./client.ts"; +import { + DEFAULT_METADATA_LANGUAGES, + isPlaceholderEpisodeName, + isPlaceholderSeasonName, + 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[]; +} + +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[], +): Promise { + const chain = chainOf(languages); + 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); + const overview = pickText(chain, translations, "overview", { value: s(j.overview), language: chain[0] ?? null }); + const tagline = pickText(chain, 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 } = {}, +): Promise { + const chain = chainOf(languages); + const first = chain[0]!; + 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 = () => eps.some((e) => aired(e) && (!e.name || !e.overview)) || (!seasonOverview && eps.some(aired)); + for (const lang of chain.slice(1)) { + if (!eps.length || !missing()) break; + 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 (!e.overview && other.overview) { + e.overview = other.overview; + e.overviewLanguage = lang; + } + } + if (!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, + }; +} diff --git a/packages/adapters/src/tmdb/index.ts b/packages/adapters/src/tmdb/index.ts index 2f38d109..1de8bc4e 100644 --- a/packages/adapters/src/tmdb/index.ts +++ b/packages/adapters/src/tmdb/index.ts @@ -1,3 +1,5 @@ export { RequestLimiter, TtlLruCache } from "./cache.ts"; export * from "./client.ts"; +export * from "./details.ts"; export * from "./match.ts"; +export * from "./translations.ts"; diff --git a/packages/adapters/src/tmdb/translations.ts b/packages/adapters/src/tmdb/translations.ts new file mode 100644 index 00000000..22da8183 --- /dev/null +++ b/packages/adapters/src/tmdb/translations.ts @@ -0,0 +1,184 @@ +/** + * 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()); +} diff --git a/packages/contracts/test/schemas.test.ts b/packages/contracts/test/schemas.test.ts index 556db29d..4cdc4a79 100644 --- a/packages/contracts/test/schemas.test.ts +++ b/packages/contracts/test/schemas.test.ts @@ -38,6 +38,7 @@ const STRING_CANDIDATES = [ "ALL", "zh-hans", "movie:157336", + "zh-CN", ]; const NUMBER_CANDIDATES = [1, 50, 2024, 0.5, 0, 4096, 128000]; diff --git a/packages/testkit/src/tmdb/fixtures.ts b/packages/testkit/src/tmdb/fixtures.ts index 17b7c7cc..da8fc95b 100644 --- a/packages/testkit/src/tmdb/fixtures.ts +++ b/packages/testkit/src/tmdb/fixtures.ts @@ -5,14 +5,39 @@ export interface TmdbFixtureEpisode { episode_number: number; air_date: string | null; + /** 按语言的集名;请求语言没有时 mock 返回 TMDB 式占位名("第 N 集" / "Episode N") */ names: Record; + /** MD-1:按语言的简介;没有时返回空串 */ + overviews?: Record; + runtime?: number | null; + still_path?: string | null; + vote_average?: number; + vote_count?: number; } export interface TmdbFixtureSeason { season_number: number; air_date: string | null; names: Record; + overviews?: Record; + poster_path?: string | null; episodes: TmdbFixtureEpisode[]; } +/** MD-1 富详情字段(credits / 类型 / 评分 / 状态…);缺省不返回 */ +export interface TmdbFixtureRich { + genres?: string[]; + vote_average?: number; + vote_count?: number; + status?: string; + runtime?: number; + episode_run_time?: number[]; + last_air_date?: string; + backdrop_path?: string; + networks?: string[]; + tagline?: Record; + cast?: { id: number; name: string; character: string; profile_path: string | null }[]; + crew?: { id: number; name: string; job: string; profile_path: string | null }[]; + created_by?: { id: number; name: string; profile_path: string | null }[]; +} export interface TmdbFixture { type: "movie" | "tv"; id: number; @@ -25,8 +50,9 @@ export interface TmdbFixture { popularity: number; alternative_titles: { iso_3166_1: string; title: string; type: string }[]; external_ids: { imdb_id?: string | null; tvdb_id?: number | null }; - /** 额外译名(titles 中的语言会自动生成 translations) */ - extra_translations?: { iso_639_1: string; iso_3166_1: string; title: string }[]; + /** 额外译名(titles 中的语言会自动生成 translations);title 为空串表示该语言只有简介没有译名(TMDB 常见) */ + extra_translations?: { iso_639_1: string; iso_3166_1: string; title: string; overview?: string }[]; + rich?: TmdbFixtureRich; seasons?: TmdbFixtureSeason[]; /** 海报路径(拼 image.tmdb.org);缺省 null */ poster_path?: string | null; @@ -219,4 +245,86 @@ export const TMDB_FIXTURES: TmdbFixture[] = [ }, ], }, + // MD-1 语言回退链:zh-CN 译名为空(只有简介)、CN 地区别名是简中名、zh-TW / zh-HK 有译名(对照 TMDB tv/95396 真实形态) + { + type: "tv", + id: 95396, + original_title: "Severance", + original_language: "en", + origin_country: ["US"], + date: "2022-02-17", + titles: { "en-US": "Severance", "zh-TW": "人生切割術", "zh-HK": "生活割離術" }, + overviews: { "en-US": "Mark leads a team of office workers.", "zh-TW": "馬克帶領的辦公團隊成員全都接受手術。" }, + popularity: 95.1, + alternative_titles: [ + { iso_3166_1: "CN", title: "人生切割术", type: "" }, + { iso_3166_1: "HK", title: "生活割離術", type: "" }, + ], + external_ids: { imdb_id: "tt11280740", tvdb_id: 371980 }, + extra_translations: [ + { iso_639_1: "zh", iso_3166_1: "CN", title: "", overview: "故事发生在一家名为卢蒙的超级大公司内。" }, + { iso_639_1: "zh", iso_3166_1: "SG", title: "", overview: "马克领导着一个办公室里的团队。" }, + ], + poster_path: "/severance.jpg", + rich: { + genres: ["剧情", "悬疑", "Sci-Fi & Fantasy"], + vote_average: 8.395, + vote_count: 2400, + status: "Returning Series", + episode_run_time: [], + last_air_date: "2025-03-21", + backdrop_path: "/severance-bg.jpg", + networks: ["Apple TV"], + cast: [ + { id: 4495, name: "亚当·斯科特", character: "Mark Scout", profile_path: "/adam.jpg" }, + { id: 1319469, name: "布丽特·洛薇尔", character: "Helly Riggs", profile_path: null }, + ], + created_by: [{ id: 3442409, name: "丹·埃里克森", profile_path: "/dan.jpg" }], + }, + seasons: [ + { + season_number: 1, + air_date: "2022-02-17", + names: { "zh-CN": "第 1 季", "en-US": "Season 1" }, + overviews: { "zh-CN": "在卢蒙工业公司,员工们接受了一种名为“分离”的手术。" }, + poster_path: "/sev-s1.jpg", + episodes: [ + { + episode_number: 1, + air_date: "2022-02-17", + names: { "zh-CN": "有关地狱的好消息", "en-US": "Good News About Hell" }, + overviews: { "zh-CN": "马克升职成为部门主管。", "en-US": "Mark is promoted." }, + runtime: 59, + still_path: "/sev-101.jpg", + vote_average: 8.2, + vote_count: 171, + }, + { + episode_number: 2, + air_date: "2022-02-17", + names: { "zh-TW": "半循環", "en-US": "Half Loop" }, + overviews: { "zh-TW": "馬克帶領新員工參觀。", "en-US": "Mark gives Helly a tour." }, + runtime: 53, + still_path: "/sev-102.jpg", + vote_average: 8.0, + vote_count: 150, + }, + { + episode_number: 3, + air_date: "2022-02-25", + names: { "en-US": "In Perpetuity" }, + overviews: {}, + runtime: 52, + still_path: null, + }, + ], + }, + { + season_number: 2, + air_date: "2099-01-17", + names: { "zh-CN": "第 2 季", "en-US": "Season 2" }, + episodes: [{ episode_number: 1, air_date: "2099-01-17", names: {} }], + }, + ], + }, ]; diff --git a/packages/testkit/src/tmdb/mock-tmdb.ts b/packages/testkit/src/tmdb/mock-tmdb.ts index ce1fddd3..83aebc59 100644 --- a/packages/testkit/src/tmdb/mock-tmdb.ts +++ b/packages/testkit/src/tmdb/mock-tmdb.ts @@ -107,17 +107,27 @@ export async function createMockTmdb(opts: MockTmdbOptions = {}): Promise String(x.season_number) === m[2]); if (!f || !s) return notFound; + const lang = q.language ?? "en-US"; + const zh = lang.startsWith("zh"); return { json: { id: f.id * 100 + s.season_number, season_number: s.season_number, name: pick(s.names, q.language, `Season ${s.season_number}`), + overview: s.overviews?.[lang] ?? "", + poster_path: s.poster_path ?? null, air_date: s.air_date, episodes: s.episodes.map((e) => ({ episode_number: e.episode_number, season_number: s.season_number, - name: pick(e.names, q.language, ""), + // TMDB 缺该语言译名时给占位名 + name: e.names[lang] ?? (zh ? `第 ${e.episode_number} 集` : `Episode ${e.episode_number}`), + overview: e.overviews?.[lang] ?? "", air_date: e.air_date, + runtime: e.runtime ?? null, + still_path: e.still_path ?? null, + vote_average: e.vote_average ?? 0, + vote_count: e.vote_count ?? 0, })), }, }; @@ -130,6 +140,27 @@ export async function createMockTmdb(opts: MockTmdbOptions = {}): Promise = { ...brief(f, q.language), origin_country: f.origin_country }; + const r = f.rich; + if (r) { + out.genres = (r.genres ?? []).map((name, i) => ({ id: i + 1, name })); + out.vote_average = r.vote_average ?? 0; + out.vote_count = r.vote_count ?? 0; + out.status = r.status ?? null; + out.backdrop_path = r.backdrop_path ?? null; + out.tagline = r.tagline?.[q.language ?? "en-US"] ?? ""; + if (f.type === "movie") out.runtime = r.runtime ?? null; + else { + out.episode_run_time = r.episode_run_time ?? []; + out.last_air_date = r.last_air_date ?? null; + out.networks = (r.networks ?? []).map((name, i) => ({ id: i + 1, name })); + out.created_by = r.created_by ?? []; + out.number_of_episodes = (f.seasons ?? []) + .filter((x) => x.season_number > 0) + .reduce((a, x) => a + x.episodes.length, 0); + } + if (append.has("credits")) + out.credits = { cast: (r.cast ?? []).map((c, order) => ({ ...c, order })), crew: r.crew ?? [] }; + } if (f.type === "tv") { out.number_of_seasons = f.seasons?.filter((s) => s.season_number > 0).length ?? 0; out.seasons = (f.seasons ?? []).map((s) => ({ @@ -137,6 +168,8 @@ export async function createMockTmdb(opts: MockTmdbOptions = {}): Promise { const [l, r] = lang.split("-"); - return { iso_639_1: l, iso_3166_1: r ?? "", data: { [key]: t } }; + return { + iso_639_1: l, + iso_3166_1: r ?? "", + data: { [key]: t, overview: f.overviews?.[lang] ?? "" }, + }; }), ...(f.extra_translations ?? []).map((x) => ({ iso_639_1: x.iso_639_1, iso_3166_1: x.iso_3166_1, - data: { [key]: x.title }, + data: { [key]: x.title, overview: x.overview ?? "" }, })), ], };