feat(metadata): overview falls back to Chinese variants only (MD-2)
- adapters: overviewLanguages() limits overview/tagline chain to Chinese variants (zh-CN→zh-SG→zh-TW→zh-HK); titles keep full chain; season supplement fetch skips languages useless for overview - setting metadata.overviewNonChineseFallback (default off) + UI switch - media detail shows "暂无简介" for empty entity/episode overview - scripts/overview-recompute.ts: recompute stored non-Chinese overviews from kv_cache only (no TMDB requests), dry-run by default, --apply - tests: chain unit tests, Heart Signal (103147) fixture, server recompute test, MP differential for episode overview - runbook + CHANGELOG Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
081db4651c
commit
f9901b514b
22 files changed
+887
-38
No files matched your search
@@ -5,13 +5,14 @@
|
||||
* 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),头像只存路径(前端经图片代理)。
|
||||
* 链走完或全部补齐即停。MD-2:简介 / 标语只按 overviewLanguages(缺省只到中文变体,不回退英文)取,取不到留空。主创只留前 10 位演员 + 导演 / 主创(created_by),头像只存路径(前端经图片代理)。
|
||||
*/
|
||||
import type { MediaEntityType, TmdbClient } from "./client.ts";
|
||||
import {
|
||||
DEFAULT_METADATA_LANGUAGES,
|
||||
isPlaceholderEpisodeName,
|
||||
isPlaceholderSeasonName,
|
||||
overviewLanguages,
|
||||
parseAltTitles,
|
||||
parseTranslations,
|
||||
pickChineseTitle,
|
||||
@@ -116,6 +117,12 @@ export interface TmdbRichSeasonDetail {
|
||||
fetchedLanguages: string[];
|
||||
}
|
||||
|
||||
/** MD-2 取值选项 */
|
||||
export interface RichTextOptions {
|
||||
/** 简介 / 标语是否允许回退到非中文(设置 metadata.overviewNonChineseFallback,缺省否) */
|
||||
overviewNonChineseFallback?: boolean;
|
||||
}
|
||||
|
||||
const chainOf = (languages?: readonly string[]) =>
|
||||
languages?.length ? [...languages] : [...DEFAULT_METADATA_LANGUAGES];
|
||||
|
||||
@@ -156,8 +163,10 @@ export async function fetchRichDetail(
|
||||
type: MediaEntityType,
|
||||
id: string | number,
|
||||
languages?: readonly string[],
|
||||
opts: RichTextOptions = {},
|
||||
): Promise<TmdbRichDetail> {
|
||||
const chain = chainOf(languages);
|
||||
const textChain = overviewLanguages(chain, opts.overviewNonChineseFallback);
|
||||
const j = await client.getJson<Json>(`/${type}/${id}`, {
|
||||
language: chain[0],
|
||||
append_to_response: "alternative_titles,credits,external_ids,translations",
|
||||
@@ -171,8 +180,9 @@ export async function fetchRichDetail(
|
||||
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 });
|
||||
// 请求语言 = 链首,链首总在 textChain 里(见 overviewLanguages),所以 primary 可以兜底
|
||||
const overview = pickText(textChain, translations, "overview", { value: s(j.overview), language: chain[0] ?? null });
|
||||
const tagline = pickText(textChain, translations, "tagline", { value: s(j.tagline), language: chain[0] ?? null });
|
||||
const 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[]) : [];
|
||||
@@ -258,10 +268,12 @@ export async function fetchRichSeason(
|
||||
tvId: string | number,
|
||||
seasonNumber: number,
|
||||
languages?: readonly string[],
|
||||
opts: { now?: Date } = {},
|
||||
opts: { now?: Date } & RichTextOptions = {},
|
||||
): Promise<TmdbRichSeasonDetail> {
|
||||
const chain = chainOf(languages);
|
||||
const first = chain[0]!;
|
||||
// MD-2:简介只从这些语言取(缺省只到中文变体);标题仍按完整链
|
||||
const textChain = overviewLanguages(chain, opts.overviewNonChineseFallback);
|
||||
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));
|
||||
@@ -274,9 +286,15 @@ export async function fetchRichSeason(
|
||||
// 只为已播出的集补拉(未播出的集各语言通常都空,补拉只会白白耗请求);季名"第 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;
|
||||
// 缺简介只在剩下的语言里还有可取简介的语言时才值得补拉
|
||||
const missing = (rest: readonly string[]) => {
|
||||
const ov = rest.some((l) => textChain.includes(l));
|
||||
return eps.some((e) => aired(e) && (!e.name || (ov && !e.overview))) || (ov && !seasonOverview && eps.some(aired));
|
||||
};
|
||||
for (const [i, lang] of chain.entries()) {
|
||||
if (i === 0) continue;
|
||||
if (!eps.length || !missing(chain.slice(i))) break;
|
||||
const textOk = textChain.includes(lang);
|
||||
let k: Json;
|
||||
try {
|
||||
k = await client.getJson<Json>(`/tv/${tvId}/season/${seasonNumber}`, { language: lang });
|
||||
@@ -296,12 +314,12 @@ export async function fetchRichSeason(
|
||||
e.name = other.name;
|
||||
e.nameLanguage = lang;
|
||||
}
|
||||
if (!e.overview && other.overview) {
|
||||
if (textOk && !e.overview && other.overview) {
|
||||
e.overview = other.overview;
|
||||
e.overviewLanguage = lang;
|
||||
}
|
||||
}
|
||||
if (!seasonOverview && s(k.overview)) {
|
||||
if (textOk && !seasonOverview && s(k.overview)) {
|
||||
seasonOverview = s(k.overview);
|
||||
seasonOverviewLang = lang;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user