From 6549ab92c33ca45e0419eee597dc069546fd2a21 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 7 Oct 2026 01:32:00 +0800 Subject: [PATCH] =?UTF-8?q?fix(media):=20=E8=AF=A6=E6=83=85=E9=A1=B5?= =?UTF-8?q?=E6=A0=87=E8=AF=AD=E5=8F=AA=E6=98=BE=E7=A4=BA=E7=AE=80=E4=BD=93?= =?UTF-8?q?=E4=B8=AD=E6=96=87,=E7=AE=80=E4=B8=AD=E6=B2=A1=E6=9C=89?= =?UTF-8?q?=E6=97=B6=E4=B8=8D=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - adapters 新增 simplifiedChineseTagline:取值语言为繁中 / 非中文、或文字是繁体特有字时不显示;原语言为中文(zh / cn)的作品按简中处理 - TMDB 富详情带回 taglineLanguage;发现页详情的 tagline 按上述口径给出 - 媒体库作品详情新增 displayTagline(存储的标语语言为 null,只能按文字判断);metadata.tagline 存储不变 - 补 adapters 单测与作品详情接口测试 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei --- .../acquisition-organize/routes/library.ts | 10 ++++ apps/server/src/modules/search/routes.ts | 3 +- .../library-versions.test.ts | 28 +++++++++++ apps/web/app/routes/console/library.$id.tsx | 3 +- packages/adapters/src/tmdb/details.ts | 3 ++ .../adapters/src/tmdb/language-policy.test.ts | 50 +++++++++++++++++++ packages/adapters/src/tmdb/language-policy.ts | 26 ++++++++++ packages/contracts/src/organization.ts | 2 + packages/contracts/src/search.ts | 1 + 9 files changed, 124 insertions(+), 2 deletions(-) diff --git a/apps/server/src/modules/acquisition-organize/routes/library.ts b/apps/server/src/modules/acquisition-organize/routes/library.ts index 3431682e..3720b5ad 100644 --- a/apps/server/src/modules/acquisition-organize/routes/library.ts +++ b/apps/server/src/modules/acquisition-organize/routes/library.ts @@ -1,4 +1,5 @@ import { join } from "node:path"; +import { simplifiedChineseTagline } from "@umt/adapters"; import { type AcquisitionPlan, type AcquisitionSlot, @@ -405,6 +406,7 @@ export function registerLibraryRoutes(app: ModuleApp, d: AoDeps) { finishedAt: r.finished_at ? new Date(r.finished_at).toISOString() : null, })), scrape: await latestEntityScrape(d, id), + displayTagline: heroTagline(e!.metadata), }; return detail; }, @@ -642,3 +644,11 @@ export function registerLibraryRoutes(app: ModuleApp, d: AoDeps) { { tag: TAG }, ); } + +/** 详情页头部的标语:只显示简体中文(见 simplifiedChineseTagline);存储的标语与语言不变 */ +function heroTagline(metadata: unknown): string | null { + const m = (metadata ?? {}) as Record; + const text = typeof m.tagline?.value === "string" ? m.tagline.value : null; + const orig = typeof m.originalLanguage?.value === "string" ? m.originalLanguage.value : null; + return simplifiedChineseTagline(text, m.tagline?.language ?? null, orig); +} diff --git a/apps/server/src/modules/search/routes.ts b/apps/server/src/modules/search/routes.ts index 5c6325ca..1118d446 100644 --- a/apps/server/src/modules/search/routes.ts +++ b/apps/server/src/modules/search/routes.ts @@ -3,6 +3,7 @@ import { fetchRichDetail, fetchRichSeason, type MetadataCandidate, + simplifiedChineseTagline, TmdbError, type TmdbRichDetail, } from "@umt/adapters"; @@ -453,7 +454,7 @@ export function registerSearchRoutes(app: ModuleApp, ctx: ServerContext, d: Sear out.title = rich.title || out.title; out.overview = rich.overview ?? out.overview; out.titleZh = rich.titleZh; - out.tagline = rich.tagline; + out.tagline = simplifiedChineseTagline(rich.tagline, rich.taglineLanguage, rich.originalLanguage); out.genres = rich.genres; out.voteAverage = rich.voteAverage; out.voteCount = rich.voteCount; diff --git a/apps/server/test/acquisition-organize/library-versions.test.ts b/apps/server/test/acquisition-organize/library-versions.test.ts index 2dee71d2..f4d157bf 100644 --- a/apps/server/test/acquisition-organize/library-versions.test.ts +++ b/apps/server/test/acquisition-organize/library-versions.test.ts @@ -336,4 +336,32 @@ describe.skipIf(!hasPg)("LIBV-1 入库版本", () => { expect(response.body.data.downloads).toHaveLength(105); expect(response.body.data.droppedPlans).toHaveLength(51); }); + + it("作品详情头部标语只给简体中文(displayTagline);存储的 metadata.tagline 不变", async () => { + const field = (value: unknown, language: string | null = null) => ({ + value, + source: "tmdb", + language, + at: "2026-10-01T00:00:00.000Z", + locked: false, + }); + const cases: [string, string, string | null][] = [ + ["我们注定要离开地球", "en", "我们注定要离开地球"], + ["我們註定要離開地球", "en", null], + ["Mankind was born on Earth.", "en", null], + ["我們註定要離開地球", "zh", "我們註定要離開地球"], + ]; + for (const [tagline, orig, want] of cases) { + const entityId = await seedEntity(h.db, { kind: "movie", title: `标语 ${tagline}`, year: 2026 }); + await h.db + .updateTable("media_entity") + .set({ metadata: JSON.stringify({ tagline: field(tagline), originalLanguage: field(orig) }) }) + .where("id", "=", entityId) + .execute(); + const r = await h.api("GET", `/library/${entityId}`); + expect(r.status).toBe(200); + expect(r.body.data.displayTagline).toBe(want); + expect(r.body.data.entity.metadata.tagline.value).toBe(tagline); + } + }); }); diff --git a/apps/web/app/routes/console/library.$id.tsx b/apps/web/app/routes/console/library.$id.tsx index e6ebe975..215f60af 100644 --- a/apps/web/app/routes/console/library.$id.tsx +++ b/apps/web/app/routes/console/library.$id.tsx @@ -1091,7 +1091,8 @@ function factsOf(d: LibraryDetail, entity: MediaEntityDetail | undefined): Media platform: strOf(m("platform")), originCountry: Array.isArray(country) ? country.filter((x): x is string => typeof x === "string") : [], overview: strOf(m("overview")), - tagline: strOf(m("tagline")), + // 头部标语只显示简体中文,由服务端按取值语言 / 文字判断(存储的 metadata.tagline 不变) + tagline: d.displayTagline ?? null, posterPath: posterPathOf(e.metadata), backdropPath: strOf(m("backdropPath")), externalLinks: externalLinks({ diff --git a/packages/adapters/src/tmdb/details.ts b/packages/adapters/src/tmdb/details.ts index 13f8303e..1aa46cc0 100644 --- a/packages/adapters/src/tmdb/details.ts +++ b/packages/adapters/src/tmdb/details.ts @@ -66,6 +66,8 @@ export interface TmdbRichDetail { overview: string | null; overviewLanguage: string | null; tagline: string | null; + /** 标语实际取到的语言(链内语言标签;落空为 null) */ + taglineLanguage: string | null; year: number | null; releaseDate: string | null; lastAirDate: string | null; @@ -275,6 +277,7 @@ export async function fetchRichDetail( overview: overview?.value ?? null, overviewLanguage: overview?.language ?? null, tagline: tagline?.value ?? null, + taglineLanguage: tagline?.language ?? null, year: yearOf(date), releaseDate: date, lastAirDate: s(j.last_air_date), diff --git a/packages/adapters/src/tmdb/language-policy.test.ts b/packages/adapters/src/tmdb/language-policy.test.ts index f877119d..7fa51cf9 100644 --- a/packages/adapters/src/tmdb/language-policy.test.ts +++ b/packages/adapters/src/tmdb/language-policy.test.ts @@ -7,6 +7,7 @@ import { chineseScriptOf, createTmdbClient, fetchRichDetail, + simplifiedChineseTagline, textMatchesLanguage, untaggedTextAllowed, } from "./index.ts"; @@ -144,3 +145,52 @@ describe("多语言保留规则:TMDB 详情", () => { expect(rich.titleLanguage).toBe("zh-CN"); }); }); + +describe("详情页标语只显示简体中文", () => { + it("取值语言是简中(zh-CN / zh-SG / zh)显示;繁中(zh-TW / zh-HK)和非中文不显示,不退到繁中", () => { + expect(simplifiedChineseTagline("人类的勇气就是人类的赞歌", "zh-CN", "en")).toBe("人类的勇气就是人类的赞歌"); + expect(simplifiedChineseTagline("一场冒险", "zh-SG", "en")).toBe("一场冒险"); + expect(simplifiedChineseTagline("一场冒险", "zh", "en")).toBe("一场冒险"); + expect(simplifiedChineseTagline("一場冒險", "zh-TW", "en")).toBeNull(); + expect(simplifiedChineseTagline("一场冒险", "zh-HK", "en")).toBeNull(); + expect(simplifiedChineseTagline("Mankind was born on Earth.", "en-US", "en")).toBeNull(); + }); + + it("语言未知(媒体库存的标语)按文字判断:繁体特有字、假名、谚文、没有汉字的都不显示", () => { + expect(simplifiedChineseTagline("我们注定要离开地球", null, "en")).toBe("我们注定要离开地球"); + expect(simplifiedChineseTagline("我們註定要離開地球", null, "en")).toBeNull(); + expect(simplifiedChineseTagline("沙丘", null, "en")).toBe("沙丘"); + expect(simplifiedChineseTagline("君の名は。", null, "ja")).toBeNull(); + expect(simplifiedChineseTagline("Mankind was born on Earth.", null, "en")).toBeNull(); + expect(simplifiedChineseTagline(" ", null, "en")).toBeNull(); + expect(simplifiedChineseTagline(null, "zh-CN", "en")).toBeNull(); + }); + + it("原语言为中文(zh / 粤语 cn)的作品按简中处理", () => { + expect(simplifiedChineseTagline("我們註定要離開地球", null, "zh")).toBe("我們註定要離開地球"); + expect(simplifiedChineseTagline("一場冒險", "zh-HK", "cn")).toBe("一場冒險"); + }); + + it("富详情带回标语的取值语言:简中没有、回退到繁中时取值不变(存储照旧),展示层丢掉", async () => { + const client = { + getJson: async () => + ({ + id: 1, + name: "冒险", + original_name: "Adventure", + original_language: "en", + origin_country: ["US"], + tagline: "", + translations: { + translations: [ + { iso_639_1: "zh", iso_3166_1: "CN", data: { name: "冒险", overview: "简介", tagline: "" } }, + { iso_639_1: "zh", iso_3166_1: "TW", data: { name: "冒險", overview: "簡介", tagline: "一場冒險" } }, + ], + }, + }) as T, + }; + const rich = await fetchRichDetail(client, "tv", 1, ["zh-CN", "zh-TW", "en-US"]); + expect(rich).toMatchObject({ tagline: "一場冒險", taglineLanguage: "zh-TW" }); + expect(simplifiedChineseTagline(rich.tagline, rich.taglineLanguage, rich.originalLanguage)).toBeNull(); + }); +}); diff --git a/packages/adapters/src/tmdb/language-policy.ts b/packages/adapters/src/tmdb/language-policy.ts index 01e3a99e..d3b8ea80 100644 --- a/packages/adapters/src/tmdb/language-policy.ts +++ b/packages/adapters/src/tmdb/language-policy.ts @@ -147,6 +147,32 @@ export function chineseScriptOf(text: string): "zh-Hans" | "zh-Hant" | null { return null; } +/** 原语言是否中文(TMDB 用 zh,粤语作品是 cn) */ +const isChineseOriginal = (lang: string | null | undefined) => { + const p = primaryOf(lang); + return p === "zh" || p === "cn"; +}; + +/** + * 详情页头部展示的标语:只要简体中文,没有就不显示(不退到繁中或其它语言)。只决定展示,不改存储的取值。 + * - language 是取值语言(TMDB 富详情的 taglineLanguage;媒体库存的标语语言多为 null,只能看文字); + * - 原语言为中文的作品按简中处理(TMDB 主记录就是中文原文); + * - 要求含汉字且不含假名 / 谚文;语言标为繁中、非中文,或文字明显是繁体特有字的,不显示。 + */ +export function simplifiedChineseTagline( + text: string | null | undefined, + language: string | null | undefined, + originalLanguage: string | null | undefined, +): string | null { + const t = text?.trim(); + if (!t || !HAN.test(t) || KANA.test(t) || HANGUL.test(t)) return null; + if (isChineseOriginal(originalLanguage)) return t; + if (language) { + if (primaryOf(language) !== "zh" || chineseVariant(language) === "zh-Hant") return null; + } + return chineseScriptOf(t) === "zh-Hant" ? null : t; +} + /** * 标题文字与语言标签是否相符:zh 要求汉字且不含假名 / 谚文(带地区或 Hans / Hant 时再看简繁特有字,明显是另一种的不符); * ja 要求假名或汉字;ko 要求谚文;泰 / 俄 / 阿 / 希伯来 / 印地等按各自文字;`-Latn` 与其余语言按拉丁字母且不含非拉丁强文字。 diff --git a/packages/contracts/src/organization.ts b/packages/contracts/src/organization.ts index 42f98ade..1326cb7b 100644 --- a/packages/contracts/src/organization.ts +++ b/packages/contracts/src/organization.ts @@ -553,6 +553,8 @@ export const LibraryDetail = z.object({ .optional(), /** SCR-1:最近一次刮削(写 NFO 与图片);没刮削过为 null */ scrape: LibraryScrapeState.nullable().optional(), + /** 头部展示的标语:只取简体中文(原语言为中文的按简中),没有为 null;存储的 metadata.tagline 不变 */ + displayTagline: z.string().nullable().optional(), }); export type LibraryDetail = z.infer; diff --git a/packages/contracts/src/search.ts b/packages/contracts/src/search.ts index 53028d0f..d5ccdffd 100644 --- a/packages/contracts/src/search.ts +++ b/packages/contracts/src/search.ts @@ -426,6 +426,7 @@ export const TmdbMediaDetail = z.object({ // MD-1:按语言回退链取的详情字段(媒体详情页与媒体库详情共用展示) /** 中文名:回退链中第一个中文值(zh-CN 优先);没有为 null */ titleZh: z.string().nullable().optional(), + /** 标语(展示用):只取简体中文(原语言为中文的按简中),没有为 null,不退到繁中 */ tagline: z.string().nullable().optional(), genres: z.array(z.string()).optional(), voteAverage: z.number().nullable().optional(),