fix(media): 详情页标语只显示简体中文,简中没有时不显示
- adapters 新增 simplifiedChineseTagline:取值语言为繁中 / 非中文、或文字是繁体特有字时不显示;原语言为中文(zh / cn)的作品按简中处理 - TMDB 富详情带回 taglineLanguage;发现页详情的 tagline 按上述口径给出 - 媒体库作品详情新增 displayTagline(存储的标语语言为 null,只能按文字判断);metadata.tagline 存储不变 - 补 adapters 单测与作品详情接口测试 Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
1 parent
895c039a40
commit
6549ab92c3
9 files changed
+124
-2
No files matched your search
@@ -1,4 +1,5 @@
|
|||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
import { simplifiedChineseTagline } from "@umt/adapters";
|
||||||
import {
|
import {
|
||||||
type AcquisitionPlan,
|
type AcquisitionPlan,
|
||||||
type AcquisitionSlot,
|
type AcquisitionSlot,
|
||||||
@@ -405,6 +406,7 @@ export function registerLibraryRoutes(app: ModuleApp, d: AoDeps) {
|
|||||||
finishedAt: r.finished_at ? new Date(r.finished_at).toISOString() : null,
|
finishedAt: r.finished_at ? new Date(r.finished_at).toISOString() : null,
|
||||||
})),
|
})),
|
||||||
scrape: await latestEntityScrape(d, id),
|
scrape: await latestEntityScrape(d, id),
|
||||||
|
displayTagline: heroTagline(e!.metadata),
|
||||||
};
|
};
|
||||||
return detail;
|
return detail;
|
||||||
},
|
},
|
||||||
@@ -642,3 +644,11 @@ export function registerLibraryRoutes(app: ModuleApp, d: AoDeps) {
|
|||||||
{ tag: TAG },
|
{ tag: TAG },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 详情页头部的标语:只显示简体中文(见 simplifiedChineseTagline);存储的标语与语言不变 */
|
||||||
|
function heroTagline(metadata: unknown): string | null {
|
||||||
|
const m = (metadata ?? {}) as Record<string, { value?: unknown; language?: string | null } | undefined>;
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
fetchRichDetail,
|
fetchRichDetail,
|
||||||
fetchRichSeason,
|
fetchRichSeason,
|
||||||
type MetadataCandidate,
|
type MetadataCandidate,
|
||||||
|
simplifiedChineseTagline,
|
||||||
TmdbError,
|
TmdbError,
|
||||||
type TmdbRichDetail,
|
type TmdbRichDetail,
|
||||||
} from "@umt/adapters";
|
} from "@umt/adapters";
|
||||||
@@ -453,7 +454,7 @@ export function registerSearchRoutes(app: ModuleApp, ctx: ServerContext, d: Sear
|
|||||||
out.title = rich.title || out.title;
|
out.title = rich.title || out.title;
|
||||||
out.overview = rich.overview ?? out.overview;
|
out.overview = rich.overview ?? out.overview;
|
||||||
out.titleZh = rich.titleZh;
|
out.titleZh = rich.titleZh;
|
||||||
out.tagline = rich.tagline;
|
out.tagline = simplifiedChineseTagline(rich.tagline, rich.taglineLanguage, rich.originalLanguage);
|
||||||
out.genres = rich.genres;
|
out.genres = rich.genres;
|
||||||
out.voteAverage = rich.voteAverage;
|
out.voteAverage = rich.voteAverage;
|
||||||
out.voteCount = rich.voteCount;
|
out.voteCount = rich.voteCount;
|
||||||
|
|||||||
@@ -336,4 +336,32 @@ describe.skipIf(!hasPg)("LIBV-1 入库版本", () => {
|
|||||||
expect(response.body.data.downloads).toHaveLength(105);
|
expect(response.body.data.downloads).toHaveLength(105);
|
||||||
expect(response.body.data.droppedPlans).toHaveLength(51);
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1091,7 +1091,8 @@ function factsOf(d: LibraryDetail, entity: MediaEntityDetail | undefined): Media
|
|||||||
platform: strOf(m("platform")),
|
platform: strOf(m("platform")),
|
||||||
originCountry: Array.isArray(country) ? country.filter((x): x is string => typeof x === "string") : [],
|
originCountry: Array.isArray(country) ? country.filter((x): x is string => typeof x === "string") : [],
|
||||||
overview: strOf(m("overview")),
|
overview: strOf(m("overview")),
|
||||||
tagline: strOf(m("tagline")),
|
// 头部标语只显示简体中文,由服务端按取值语言 / 文字判断(存储的 metadata.tagline 不变)
|
||||||
|
tagline: d.displayTagline ?? null,
|
||||||
posterPath: posterPathOf(e.metadata),
|
posterPath: posterPathOf(e.metadata),
|
||||||
backdropPath: strOf(m("backdropPath")),
|
backdropPath: strOf(m("backdropPath")),
|
||||||
externalLinks: externalLinks({
|
externalLinks: externalLinks({
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ export interface TmdbRichDetail {
|
|||||||
overview: string | null;
|
overview: string | null;
|
||||||
overviewLanguage: string | null;
|
overviewLanguage: string | null;
|
||||||
tagline: string | null;
|
tagline: string | null;
|
||||||
|
/** 标语实际取到的语言(链内语言标签;落空为 null) */
|
||||||
|
taglineLanguage: string | null;
|
||||||
year: number | null;
|
year: number | null;
|
||||||
releaseDate: string | null;
|
releaseDate: string | null;
|
||||||
lastAirDate: string | null;
|
lastAirDate: string | null;
|
||||||
@@ -275,6 +277,7 @@ export async function fetchRichDetail(
|
|||||||
overview: overview?.value ?? null,
|
overview: overview?.value ?? null,
|
||||||
overviewLanguage: overview?.language ?? null,
|
overviewLanguage: overview?.language ?? null,
|
||||||
tagline: tagline?.value ?? null,
|
tagline: tagline?.value ?? null,
|
||||||
|
taglineLanguage: tagline?.language ?? null,
|
||||||
year: yearOf(date),
|
year: yearOf(date),
|
||||||
releaseDate: date,
|
releaseDate: date,
|
||||||
lastAirDate: s(j.last_air_date),
|
lastAirDate: s(j.last_air_date),
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
chineseScriptOf,
|
chineseScriptOf,
|
||||||
createTmdbClient,
|
createTmdbClient,
|
||||||
fetchRichDetail,
|
fetchRichDetail,
|
||||||
|
simplifiedChineseTagline,
|
||||||
textMatchesLanguage,
|
textMatchesLanguage,
|
||||||
untaggedTextAllowed,
|
untaggedTextAllowed,
|
||||||
} from "./index.ts";
|
} from "./index.ts";
|
||||||
@@ -144,3 +145,52 @@ describe("多语言保留规则:TMDB 详情", () => {
|
|||||||
expect(rich.titleLanguage).toBe("zh-CN");
|
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 <T>() =>
|
||||||
|
({
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -147,6 +147,32 @@ export function chineseScriptOf(text: string): "zh-Hans" | "zh-Hant" | null {
|
|||||||
return 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 时再看简繁特有字,明显是另一种的不符);
|
* 标题文字与语言标签是否相符:zh 要求汉字且不含假名 / 谚文(带地区或 Hans / Hant 时再看简繁特有字,明显是另一种的不符);
|
||||||
* ja 要求假名或汉字;ko 要求谚文;泰 / 俄 / 阿 / 希伯来 / 印地等按各自文字;`-Latn` 与其余语言按拉丁字母且不含非拉丁强文字。
|
* ja 要求假名或汉字;ko 要求谚文;泰 / 俄 / 阿 / 希伯来 / 印地等按各自文字;`-Latn` 与其余语言按拉丁字母且不含非拉丁强文字。
|
||||||
|
|||||||
@@ -553,6 +553,8 @@ export const LibraryDetail = z.object({
|
|||||||
.optional(),
|
.optional(),
|
||||||
/** SCR-1:最近一次刮削(写 NFO 与图片);没刮削过为 null */
|
/** SCR-1:最近一次刮削(写 NFO 与图片);没刮削过为 null */
|
||||||
scrape: LibraryScrapeState.nullable().optional(),
|
scrape: LibraryScrapeState.nullable().optional(),
|
||||||
|
/** 头部展示的标语:只取简体中文(原语言为中文的按简中),没有为 null;存储的 metadata.tagline 不变 */
|
||||||
|
displayTagline: z.string().nullable().optional(),
|
||||||
});
|
});
|
||||||
export type LibraryDetail = z.infer<typeof LibraryDetail>;
|
export type LibraryDetail = z.infer<typeof LibraryDetail>;
|
||||||
|
|
||||||
|
|||||||
@@ -426,6 +426,7 @@ export const TmdbMediaDetail = z.object({
|
|||||||
// MD-1:按语言回退链取的详情字段(媒体详情页与媒体库详情共用展示)
|
// MD-1:按语言回退链取的详情字段(媒体详情页与媒体库详情共用展示)
|
||||||
/** 中文名:回退链中第一个中文值(zh-CN 优先);没有为 null */
|
/** 中文名:回退链中第一个中文值(zh-CN 优先);没有为 null */
|
||||||
titleZh: z.string().nullable().optional(),
|
titleZh: z.string().nullable().optional(),
|
||||||
|
/** 标语(展示用):只取简体中文(原语言为中文的按简中),没有为 null,不退到繁中 */
|
||||||
tagline: z.string().nullable().optional(),
|
tagline: z.string().nullable().optional(),
|
||||||
genres: z.array(z.string()).optional(),
|
genres: z.array(z.string()).optional(),
|
||||||
voteAverage: z.number().nullable().optional(),
|
voteAverage: z.number().nullable().optional(),
|
||||||
|
|||||||
Reference in new issue
Block a user