merge: fix/x-tmdb-lang
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
commit
cfe9ad1a71
16 files changed
+914
-49
No files matched your search
@@ -1,4 +1,4 @@
|
||||
import type { MetadataCandidate } from "@umt/adapters";
|
||||
import { allowedLanguage, type MetadataCandidate, textMatchesLanguage } from "@umt/adapters";
|
||||
import type { AliasKind, AliasSource, EvidenceLevel, MediaKind } from "@umt/contracts";
|
||||
import { sql } from "@umt/db";
|
||||
import { normalizeTitle, type ParsedRelease } from "@umt/domain";
|
||||
@@ -171,6 +171,11 @@ export async function createEntityFromTmdb(
|
||||
return { entityId: entity.id, created: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* TMDB 详情登记别名(来源 tmdb)。多语言保留规则(adapters language-policy.ts):details() 已只留简中 / 繁中 / 英文 /
|
||||
* 原语言的本地化名、译名与别名(别名带按地区 + 文字推断的语言),这里按语言写入,不再写空语言的 other。
|
||||
* 本地化名的文字与语言不符时(TMDB 缺该语言译名时回退成原名)不按该语言登记。
|
||||
*/
|
||||
export async function seedAliases(exec: Exec, entityId: string, m: MetadataCandidate): Promise<void> {
|
||||
// 同一文本只登记一次(按首个出现的类别):TMDB 的译名/别名列表常重复主标题与原名
|
||||
const seen = new Set<string>();
|
||||
@@ -180,12 +185,21 @@ export async function seedAliases(exec: Exec, entityId: string, m: MetadataCandi
|
||||
seen.add(n);
|
||||
await addAlias(exec, entityId, text, kind, "tmdb", lang);
|
||||
};
|
||||
await add(m.title, "translated", null);
|
||||
const work = { originalLanguage: m.originalLanguage, originCountry: m.originCountry };
|
||||
if (m.originalTitle) await add(m.originalTitle, "original", m.originalLanguage);
|
||||
let n = 0;
|
||||
for (const t of m.titles) {
|
||||
if (t.kind === "localized") await add(t.text, "translated", t.lang);
|
||||
if (t.kind === "original" || !t.lang || n >= 50) continue;
|
||||
const lang = allowedLanguage(t.lang, work);
|
||||
if (!lang || !textMatchesLanguage(lang, t.text)) continue;
|
||||
const kind: AliasKind = t.kind === "alias" ? (lang.endsWith("-Latn") ? "romanized" : "other") : "translated";
|
||||
await add(t.text, kind, lang);
|
||||
n++;
|
||||
}
|
||||
for (const a of m.aliases.slice(0, 50)) await add(a, "other", null);
|
||||
// 主标题(建档时可能来自语言链):上面没登记到时补一条,语言按同文本的本地化名(文字相符才标)
|
||||
const loc = m.titles.find((t) => t.text === m.title && t.kind === "localized" && t.lang);
|
||||
const titleLang = loc?.lang && textMatchesLanguage(loc.lang, m.title) ? allowedLanguage(loc.lang, work) : null;
|
||||
await add(m.title, "translated", titleLang);
|
||||
}
|
||||
|
||||
export class IdentityConflictError extends Error {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import {
|
||||
DEFAULT_METADATA_LANGUAGES,
|
||||
scriptMatches,
|
||||
type TmdbRichDetail,
|
||||
type TmdbRichEpisode,
|
||||
type TmdbRichSeasonDetail,
|
||||
textMatchesLanguage,
|
||||
} from "@umt/adapters";
|
||||
import type {
|
||||
EntityMetadataField,
|
||||
@@ -25,6 +25,7 @@ import { ensureSeason, promoteUnits } from "./metadata-totals.ts";
|
||||
// - 作品:TMDB 富详情(fetchRichDetail)按设置 metadata.languages 取标题 / 简介 / 标语(MD-2:简介 / 标语缺省只回退到中文变体),另存中文名 titleZh、类型、评分、
|
||||
// 状态、时长、季数、主创、外部 ID;来源值写 metadata_field(language = 实际取到的语言),同字段换语言时删旧行;
|
||||
// - 别名:translations / alternative_titles 写 title_alias(来源 tmdb,标语言);已有同文本别名只补空的语言;
|
||||
// 只写简中 / 繁中 / 英文 / 原语言(多语言保留规则,adapters language-policy.ts);
|
||||
// - 季 / 集:fetchRichSeason 的每集 name / overview / airDate / runtime / stillPath / vote 写 metadata_field(content_unit_id),
|
||||
// 投影到 content_unit.metadata(字段级,可锁),name → title 列、airDate → air_date 列;只写变化的字段,
|
||||
// 不改编号、不碰路径绑定与资产覆盖。集单元不存在时按 TMDB 建(confirmed,挂季单元)。
|
||||
@@ -152,26 +153,22 @@ export async function writeRichEntityFields(
|
||||
);
|
||||
}
|
||||
|
||||
/** 别名的地区 → 语言:只在文字相符时标(CN 地区的英文别名不标 zh-CN) */
|
||||
function altLanguage(region: string | null, text: string): string | null {
|
||||
if (!region) return null;
|
||||
if (["CN", "SG", "TW", "HK", "MO"].includes(region)) return scriptMatches("zh", text) ? `zh-${region}` : null;
|
||||
if (region === "JP") return scriptMatches("ja", text) ? "ja-JP" : null;
|
||||
if (region === "KR") return scriptMatches("ko", text) ? "ko-KR" : null;
|
||||
if (["US", "GB", "CA", "AU", "NZ", "IE"].includes(region)) return scriptMatches("en", text) ? `en-${region}` : null;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* translations(译名,kind translated)与 alternative_titles(别名,kind other)写 title_alias,来源 tmdb、标语言。
|
||||
* translations(译名,kind translated)与 alternative_titles(别名,kind other;原产地区罗马字 romanized)写 title_alias,
|
||||
* 来源 tmdb、标语言。多语言保留规则:fetchRichDetail 已只留简中 / 繁中 / 英文 / 原语言的译名,别名带按地区 + 文字推断的语言
|
||||
* (推断不出的已丢弃);这里再要求译名文字与语言相符。
|
||||
* 同作品已有同文本别名时不再新增,只给来源 tmdb 且语言为空的那行补语言。最多 80 条。
|
||||
*/
|
||||
export async function tagTitleAliases(exec: Exec, entityId: string, rich: TmdbRichDetail): Promise<number> {
|
||||
const want: { text: string; kind: "translated" | "other"; language: string | null }[] = [];
|
||||
type Kind = "translated" | "other" | "romanized";
|
||||
const want: { text: string; kind: Kind; language: string | null }[] = [];
|
||||
if (rich.titleZh) want.push({ text: rich.titleZh, kind: "translated", language: rich.titleZhLanguage });
|
||||
for (const t of rich.translations) if (t.title) want.push({ text: t.title, kind: "translated", language: t.lang });
|
||||
for (const t of rich.translations)
|
||||
if (t.title && textMatchesLanguage(t.lang, t.title))
|
||||
want.push({ text: t.title, kind: "translated", language: t.lang });
|
||||
for (const a of rich.alternativeTitles)
|
||||
want.push({ text: a.title, kind: "other", language: altLanguage(a.region, a.title) });
|
||||
if (a.language)
|
||||
want.push({ text: a.title, kind: a.language.endsWith("-Latn") ? "romanized" : "other", language: a.language });
|
||||
const rows = await exec
|
||||
.selectFrom("title_alias")
|
||||
.select(["id", "normalized", "language", "source"])
|
||||
|
||||
@@ -361,7 +361,14 @@ export async function refreshMetadata(
|
||||
creatable = new Set(opts.full ? want : tracked);
|
||||
for (const n of want) {
|
||||
try {
|
||||
richSeasons.push(await fetchRichSeason(tmdb, ident.external_id, n, langs, { now: rt.now(), ...textOpts }));
|
||||
richSeasons.push(
|
||||
await fetchRichSeason(tmdb, ident.external_id, n, langs, {
|
||||
now: rt.now(),
|
||||
...textOpts,
|
||||
// 语言链按作品原语言收敛(多语言保留规则)
|
||||
originalLanguage: richDetail?.originalLanguage ?? detail.originalLanguage,
|
||||
}),
|
||||
);
|
||||
} catch (e) {
|
||||
ctx.logger.debug({ entityId: id, season: n, err: { message: (e as Error).message } }, "季详情获取失败");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createMockTmdb, type MockTmdb, type TmdbFixture } from "@umt/testkit";
|
||||
import { sql } from "kysely";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { createEntityFromTmdbCandidate } from "../../src/modules/sources-identity/service/entity-from-candidate.ts";
|
||||
import { refreshMetadata } from "../../src/modules/sources-identity/service/metadata.ts";
|
||||
import { createHarness, type Harness, PG_URL, pgAvailable } from "./harness.ts";
|
||||
|
||||
// TMDB 多语言保留规则(用户规则 2026-10-06):建档 / 刷新只写简中 / 繁中 / 英文 / 原语言的别名与字段;
|
||||
// scripts/tmdb-alias-language-cleanup.ts 只删 TMDB 来源、语言不在保留范围的别名。
|
||||
|
||||
const up = await pgAvailable();
|
||||
const ROOT = fileURLToPath(new URL("../../../../", import.meta.url));
|
||||
const SCRIPT = fileURLToPath(new URL("../../../../scripts/tmdb-alias-language-cleanup.ts", import.meta.url));
|
||||
|
||||
/** 日语原作的剧:各种语言的译名与别名 */
|
||||
const AOT: TmdbFixture = {
|
||||
type: "tv",
|
||||
id: 1429,
|
||||
original_title: "進撃の巨人",
|
||||
original_language: "ja",
|
||||
origin_country: ["JP"],
|
||||
date: "2013-04-07",
|
||||
titles: { "zh-CN": "进击的巨人", "en-US": "Attack on Titan", "ja-JP": "進撃の巨人", "zh-TW": "進擊的巨人" },
|
||||
overviews: { "zh-CN": "巨人吃人的世界。", "ja-JP": "巨人が人を喰う世界。", "fr-FR": "Un monde de titans." },
|
||||
popularity: 200,
|
||||
alternative_titles: [
|
||||
{ iso_3166_1: "JP", title: "Shingeki no Kyojin", type: "Romaji" },
|
||||
{ iso_3166_1: "US", title: "AoT", type: "" },
|
||||
{ iso_3166_1: "BR", title: "Ataque dos Titãs", type: "" },
|
||||
{ iso_3166_1: "RU", title: "Атака на титанов", type: "" },
|
||||
{ iso_3166_1: "CN", title: "巨人", type: "简称" },
|
||||
{ iso_3166_1: "CN", title: "Attack Titan CN", type: "" },
|
||||
{ iso_3166_1: "KR", title: "진격의 거인", type: "" },
|
||||
],
|
||||
extra_translations: [
|
||||
{ iso_639_1: "ru", iso_3166_1: "RU", title: "Атака титанов" },
|
||||
{ iso_639_1: "fr", iso_3166_1: "FR", title: "L'Attaque des Titans" },
|
||||
{ iso_639_1: "ko", iso_3166_1: "KR", title: "진격의 거인" },
|
||||
{ iso_639_1: "th", iso_3166_1: "TH", title: "ผ่าพิภพไททัน" },
|
||||
],
|
||||
external_ids: { imdb_id: "tt2560140", tvdb_id: 267440 },
|
||||
poster_path: "/aot.jpg",
|
||||
rich: { genres: ["动画"], status: "Ended" },
|
||||
seasons: [
|
||||
{
|
||||
season_number: 1,
|
||||
air_date: "2013-04-07",
|
||||
names: { "zh-CN": "第 1 季", "en-US": "Season 1" },
|
||||
episodes: [
|
||||
{
|
||||
episode_number: 1,
|
||||
air_date: "2013-04-07",
|
||||
names: { "fr-FR": "À toi, dans 2000 ans", "ja-JP": "二千年後の君へ" },
|
||||
overviews: { "fr-FR": "Le mur tombe.", "ja-JP": "壁が壊される。" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const ALLOWED = /^(zh(-(CN|SG|TW|HK|MO|Hans|Hant))?|en(-[A-Z]{2})?|ja(-JP|-Latn)?)$/;
|
||||
|
||||
describe.skipIf(!up)("TMDB 多语言保留规则", () => {
|
||||
let h: Harness;
|
||||
let tmdb: MockTmdb;
|
||||
let id: string;
|
||||
let url: string;
|
||||
const actor = { type: "user" as const, id: "tester" };
|
||||
|
||||
const run = (...args: string[]) =>
|
||||
execFileSync(Deno.execPath(), ["run", "-A", SCRIPT, ...args], {
|
||||
cwd: ROOT,
|
||||
env: { ...process.env, DATABASE_URL: url, NO_COLOR: "1" },
|
||||
encoding: "utf8",
|
||||
});
|
||||
const aliases = () =>
|
||||
h.db
|
||||
.selectFrom("title_alias")
|
||||
.select(["alias", "language", "kind", "source", "locked"])
|
||||
.where("media_entity_id", "=", id)
|
||||
.orderBy("alias")
|
||||
.execute();
|
||||
|
||||
beforeAll(async () => {
|
||||
tmdb = await createMockTmdb({ fixtures: [AOT] });
|
||||
h = await createHarness("tmdblang", { tmdbUrl: tmdb.url, now: () => new Date("2026-10-06T00:00:00Z") });
|
||||
const schema = (await sql<{ s: string }>`select current_schema() as s`.execute(h.db)).rows[0]!.s;
|
||||
const u = new URL(PG_URL);
|
||||
u.searchParams.set("options", `-c search_path=${schema},public`);
|
||||
url = u.toString();
|
||||
// 用户在语言链里放了 fr-FR(不是原语言,应被跳过)与 ja-JP(是原语言,保留)
|
||||
await h.db
|
||||
.insertInto("settings")
|
||||
.values({ key: "metadata", value: JSON.stringify({ languages: ["fr-FR", "zh-CN", "ja-JP", "en-US"] }) } as never)
|
||||
.execute();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await h?.close();
|
||||
await tmdb?.close();
|
||||
});
|
||||
|
||||
it("建档:别名只含保留语言,不写空语言的 other;罗马字记 ja-Latn", async () => {
|
||||
const r = await createEntityFromTmdbCandidate(h.db, () => h.rt.tmdb(), { externalId: "1429", kind: "tv" }, actor);
|
||||
id = r.mediaEntityId;
|
||||
const list = await aliases();
|
||||
const texts = list.map((a) => a.alias);
|
||||
for (const bad of [
|
||||
"Атака титанов",
|
||||
"Атака на титанов",
|
||||
"L'Attaque des Titans",
|
||||
"진격의 거인",
|
||||
"ผ่าพิภพไททัน",
|
||||
"Ataque dos Titãs",
|
||||
"Attack Titan CN",
|
||||
])
|
||||
expect(texts).not.toContain(bad);
|
||||
expect(list.find((a) => a.alias === "進撃の巨人")).toMatchObject({ kind: "original", language: "ja" });
|
||||
expect(list.find((a) => a.alias === "Shingeki no Kyojin")).toMatchObject({
|
||||
kind: "romanized",
|
||||
language: "ja-Latn",
|
||||
});
|
||||
expect(list.find((a) => a.alias === "巨人")).toMatchObject({ kind: "other", language: "zh-CN" });
|
||||
expect(list.find((a) => a.alias === "AoT")).toMatchObject({ language: "en-US" });
|
||||
expect(list.filter((a) => a.kind === "other" && a.language === null)).toEqual([]);
|
||||
for (const a of list) if (a.language !== null) expect(a.language).toMatch(ALLOWED);
|
||||
});
|
||||
|
||||
it("刷新:语言链去掉 fr-FR,标题 / 简介 / 集名不取法语;别名仍只含保留语言", async () => {
|
||||
const res = await refreshMetadata(h.rt, id, { full: true });
|
||||
expect(res.status).toBe("refreshed");
|
||||
expect(res.titles).toMatchObject({ title: "进击的巨人", language: "zh-CN" });
|
||||
const fields = await h.db
|
||||
.selectFrom("metadata_field")
|
||||
.leftJoin("content_unit", "content_unit.id", "metadata_field.content_unit_id")
|
||||
.select(["metadata_field.field", "metadata_field.language", "metadata_field.value"])
|
||||
.where((eb) =>
|
||||
eb.or([eb("metadata_field.media_entity_id", "=", id), eb("content_unit.media_entity_id", "=", id)]),
|
||||
)
|
||||
.where("metadata_field.language", "is not", null)
|
||||
.execute();
|
||||
expect(fields.length).toBeGreaterThan(0);
|
||||
for (const f of fields) expect(f.language).toMatch(ALLOWED);
|
||||
const ep = await h.db
|
||||
.selectFrom("content_unit")
|
||||
.select(["title", "metadata"])
|
||||
.where("media_entity_id", "=", id)
|
||||
.where("kind", "=", "episode")
|
||||
.executeTakeFirstOrThrow();
|
||||
// 集名:fr-FR 被跳过,ja-JP(原语言)可用
|
||||
expect(ep.title).toBe("二千年後の君へ");
|
||||
expect((ep.metadata as Record<string, { language: string | null }>).name?.language).toBe("ja-JP");
|
||||
const list = await aliases();
|
||||
expect(list.filter((a) => a.kind === "other" && a.language === null)).toEqual([]);
|
||||
for (const a of list) if (a.language !== null) expect(a.language).toMatch(ALLOWED);
|
||||
expect(list.map((a) => a.alias)).not.toContain("L'Attaque des Titans");
|
||||
});
|
||||
|
||||
it("清理脚本:dry-run 统计、apply 只删 TMDB 来源的不合规别名,人工 / 站点 / 锁定的保留", async () => {
|
||||
// 旧版本写下的数据:各语言译名(translated)、空语言 other;以及人工 / 站点 / 锁定的同类文本
|
||||
const add = (alias: string, language: string | null, kind: string, source = "tmdb", locked = false) =>
|
||||
sql`insert into title_alias (media_entity_id, content_unit_id, alias, normalized, language, kind, source, locked)
|
||||
values (${id}, null, ${alias}, ${alias.toLowerCase()}, ${language}, ${kind}, ${source}, ${locked})`.execute(
|
||||
h.db,
|
||||
);
|
||||
await add("Атака титанов", "ru-RU", "translated");
|
||||
await add("Ataque dos Titãs", "pt-BR", "translated");
|
||||
await add("L'Attaque des Titans", "fr-FR", "translated");
|
||||
await add("Атака на титанов", null, "other");
|
||||
await add("진격의 거인", null, "other");
|
||||
await add("Ataque Titan", null, "other"); // 拉丁:按英文保留
|
||||
await add("ผ่าพิภพไททัน", "th-TH", "translated", "user"); // 人工:不动
|
||||
await add("Атака гигантов", "ru-RU", "site_title", "site"); // 站点:不动
|
||||
await add("Attaque verrouillée", "fr-FR", "translated", "tmdb", true); // 锁定:不动
|
||||
// 字段来源里的法语值(旧语言链写下的):只报告
|
||||
await sql`insert into metadata_field (media_entity_id, content_unit_id, field, value, language, source, source_revision, fetched_at)
|
||||
values (${id}, null, 'tagline', '"Le mur"'::jsonb, 'fr-FR', 'tmdb', 'x', now())`.execute(h.db);
|
||||
|
||||
const before = (await aliases()).length;
|
||||
const out = run("--dry-run");
|
||||
expect(out).toContain("DRY-RUN");
|
||||
expect(out).toMatch(/删除 5 条/);
|
||||
expect(out).toContain("ru-RU / translated:1 条");
|
||||
expect(out).toContain("(空) / other:2 条");
|
||||
expect(out).toContain("不动(非 tmdb 来源或已锁定):");
|
||||
expect(out).toContain("## 字段来源(tmdb)语言不在保留范围:1 条");
|
||||
expect(out).toContain("fr-FR / 作品·tagline:1 条");
|
||||
expect((await aliases()).length).toBe(before);
|
||||
|
||||
const applied = run("--apply");
|
||||
expect(applied).toContain("已删除 5 条别名");
|
||||
const after = await aliases();
|
||||
const texts = after.map((a) => a.alias);
|
||||
for (const gone of ["Атака титанов", "Ataque dos Titãs", "L'Attaque des Titans", "Атака на титанов", "진격의 거인"])
|
||||
expect(texts).not.toContain(gone);
|
||||
for (const kept of [
|
||||
"Ataque Titan",
|
||||
"ผ่าพิภพไททัน",
|
||||
"Атака гигантов",
|
||||
"Attaque verrouillée",
|
||||
"進撃の巨人",
|
||||
"Shingeki no Kyojin",
|
||||
])
|
||||
expect(texts).toContain(kept);
|
||||
expect(after.length).toBe(before - 5);
|
||||
const audit = await h.db
|
||||
.selectFrom("audit_event")
|
||||
.select(["payload"])
|
||||
.where("action", "=", "metadata.tmdb_alias_language_cleanup")
|
||||
.executeTakeFirstOrThrow();
|
||||
expect((audit.payload as { deleted: number }).deleted).toBe(5);
|
||||
// 再跑一遍没有要删的
|
||||
expect(run()).toContain("删除 0 条");
|
||||
});
|
||||
});
|
||||
@@ -69,7 +69,7 @@ export const LANGUAGE_CARD: CardSpec = {
|
||||
label: "语言回退链",
|
||||
kind: "text",
|
||||
placeholder: DEFAULT_LANGUAGES.join(", "),
|
||||
hint: "标题和每集信息按顺序取第一个有值的语言,简介默认只取链里的中文;中文名取链中第一个中文值。逗号分隔,如 zh-CN, zh-SG",
|
||||
hint: "标题和每集信息按顺序取第一个有值的语言,简介默认只取链里的中文;中文名取链中第一个中文值。只认简繁中文、英文和作品原语言(如日语作品的 ja-JP),其它语言跳过。逗号分隔,如 zh-CN, zh-SG",
|
||||
wide: true,
|
||||
},
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user