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
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user