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:
1 parent
c3aaf901ad
commit
fc1856c2e3
8 files changed
+834
-5
No files matched your search
@@ -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]!;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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];
|
||||
|
||||
|
||||
@@ -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: {} }],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -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 ?? "" },
|
||||
})),
|
||||
],
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user