feat(tmdb): language fallback chain, rich details and per-episode season fetch (MD-1)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-29 17:28:16 +08:00
1 parent c3aaf901ad
commit fc1856c2e3
8 files changed
+834 -5

No files matched your search

+11
View File
@@ -252,6 +252,17 @@ export class TmdbClient {
}
}
/**
* 原始 JSON 请求(走同一套缓存 / 限流 / 重试 / 脱敏):details.ts 的多语言详情、季集详情用。
* path 以 `/` 开头(不含 /3 前缀),params 不含凭证。
*/
async getJson<T = Record<string, unknown>>(
path: string,
params: Record<string, string | undefined> = {},
): Promise<T> {
return this.get<T>(path, params);
}
/** 单语言原始搜索(匹配链使用,对照 MP search.movies/tv_shows,语言取首选)。 */
async searchRaw(type: MediaEntityType, query: string, year?: string | number): Promise<MetadataCandidate[]> {
const lang = this.langs[0]!;
+147
View File
@@ -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);
});
});
+339
View File
@@ -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<string, unknown>;
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<number>();
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<TmdbClient, "getJson">,
type: MediaEntityType,
id: string | number,
languages?: readonly string[],
): Promise<TmdbRichDetail> {
const chain = chainOf(languages);
const j = await client.getJson<Json>(`/${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<TmdbClient, "getJson">,
tvId: string | number,
seasonNumber: number,
languages?: readonly string[],
opts: { now?: Date } = {},
): Promise<TmdbRichSeasonDetail> {
const chain = chainOf(languages);
const first = chain[0]!;
const j = await client.getJson<Json>(`/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<Json>(`/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,
};
}
+2
View File
@@ -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";
+184
View File
@@ -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<string, unknown>;
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<TmdbTranslation, "iso639" | "region">): 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());
}
+1
View File
@@ -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];
+110 -2
View File
@@ -5,14 +5,39 @@
export interface TmdbFixtureEpisode {
episode_number: number;
air_date: string | null;
/** 按语言的集名;请求语言没有时 mock 返回 TMDB 式占位名("第 N 集" / "Episode N") */
names: Record<string, string>;
/** MD-1:按语言的简介;没有时返回空串 */
overviews?: Record<string, string>;
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<string, string>;
overviews?: Record<string, string>;
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<string, string>;
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: {} }],
},
],
},
];
+40 -3
View File
@@ -107,17 +107,27 @@ export async function createMockTmdb(opts: MockTmdbOptions = {}): Promise<MockTm
const f = find("tv", m[1]!);
const s = f?.seasons?.find((x) => 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<MockTm
if (!f) return notFound;
const append = new Set((q.append_to_response ?? "").split(",").filter(Boolean));
const out: Record<string, unknown> = { ...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<MockTm
episode_count: s.episodes.length,
name: pick(s.names, q.language, ""),
air_date: s.air_date,
overview: s.overviews?.[q.language ?? "en-US"] ?? "",
poster_path: s.poster_path ?? null,
}));
}
if (append.has("alternative_titles")) {
@@ -150,12 +183,16 @@ export async function createMockTmdb(opts: MockTmdbOptions = {}): Promise<MockTm
translations: [
...Object.entries(f.titles).map(([lang, t]) => {
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 ?? "" },
})),
],
};