diff --git a/apps/server/test/sources-identity/tmdb-language-policy.test.ts b/apps/server/test/sources-identity/tmdb-language-policy.test.ts new file mode 100644 index 00000000..753c90cc --- /dev/null +++ b/apps/server/test/sources-identity/tmdb-language-policy.test.ts @@ -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).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 条"); + }); +}); diff --git a/docs/porting/moviepilot-metadata-recognition.md b/docs/porting/moviepilot-metadata-recognition.md index 374f6696..05389426 100644 --- a/docs/porting/moviepilot-metadata-recognition.md +++ b/docs/porting/moviepilot-metadata-recognition.md @@ -336,6 +336,7 @@ export interface TmdbPort { searchMovie(q: string, year?: number): Promise-Latn`。识别比较(`details().aliases`、`match.ts` 别名回退、`tmdbToCandidate`、按作品搜资源的 `work-match.ts`)同样只用这些 | 用户规则:俄 / 葡 / 越 / 西 / 泰等与作品无关的语言别名只会制造误匹配和噪声;MP 的 en / hk / tw / sg / CN 取值都在保留范围内 | --- diff --git a/docs/runbook/identity.md b/docs/runbook/identity.md index 8de5ba66..83ff343f 100644 --- a/docs/runbook/identity.md +++ b/docs/runbook/identity.md @@ -292,6 +292,53 @@ DATABASE_URL=postgres://umt:<密码>@$PGIP:5432/umt deno run -A scripts/identity `POST /media-entities/{id}/refresh?full=1`,季 / 集单元会按 TMDB 重建为 confirmed。季 0(特别篇)自 DH-1 起也在 full 刷新 范围内(`metadata.includeSpecials` 缺省开),回刷即可重建。 +## TMDB 多语言保留规则(2026-10-06) + +用户规则:「除了简繁、英文,还有源语言之外,不需要获取其他更多的别名」,TMDB 的多语言数据整体按这个保留。 +规则是一个纯函数模块 `packages/adapters/src/tmdb/language-policy.ts`(单测 `language-policy.test.ts`)。 + +- **保留的语言**:简中(zh-CN / zh-SG / zh-Hans,及不带地区的 zh)、繁中(zh-TW / zh-HK / zh-MO / zh-Hant)、英文(en-*)、 + 作品原语言(TMDB `original_language`,按主语言比较,如 ja → ja-JP / ja 都算;原语言是中文 / 英文时不额外增加, + 原语言是中文时 zh 的其它地区也算原语言)。原名(`original_title / original_name`)总是保留。 +- **alternative_titles**(只有地区没有语言):CN / SG → 简中,TW / HK / MO → 繁中,US / GB / AU / CA / NZ / IE → 英文, + 作品 `origin_country` 里的地区 → 原语言;再校验文字:中文要汉字且不含假名 / 谚文,带地区时看简繁特有字(明显是另一种的不要), + 日文要假名或汉字,韩文要谚文,泰 / 俄 / 阿 / 希伯来 / 印地等按各自文字,英文和其它拉丁语言要拉丁字母且不含非拉丁文字。 + 推断不出(没有地区、地区不在上面)或文字都不符的丢弃。例外:原产地区、type 含 roma(TMDB 的 "Romaji")的罗马字别名记 + `<原语言>-Latn`(kind `romanized`),资源标题常写罗马字。 +- **应用位置**:`TmdbClient.details()`(识别比较用的 `aliases` / `titles`,`match.ts` 别名回退、`tmdbToCandidate`、 + `work-match.ts` 按作品搜资源都只看到保留语言)、`fetchRichDetail`(`translations` / `alternativeTitles` 只留保留语言, + 别名带推断语言)、建档 `seedAliases` 与刷新 `tagTitleAliases` 写 `title_alias`(语言写规范化后的值,不再写空语言的 `other`; + 本地化名文字与语言不符时——TMDB 缺该语言译名时回退成原名——不按该语言登记)。 +- **语言链**(设置 `metadata.languages`):缺省 `zh-CN, zh-SG, zh-TW, zh-HK, en-US` 全在保留范围。设置仍允许填任意 `xx-YY`, + 但按作品收敛:不在保留范围的语言(对这部作品既不是中英文也不是原语言)从链里去掉,标题 / 简介 / 标语 / 季集名都不取它; + 请求语言(链首)被去掉时请求语言下的值不拿来兜底;链全被去掉时用缺省链。季集详情不知道原语言时(链里有中英文以外的语言) + 先读一次作品详情(走请求缓存)。 +- 作品与单元的 `metadata` 只存按语言链取到的单个值(带语言),不存 translations 列表,没有别的多语言数据。 + +### 存量清理(`scripts/tmdb-alias-language-cleanup.ts`) + +缺省只读(`--dry-run` 显式只读,与 `--apply` 互斥);`--apply` 在一个事务里删除并写审计 `metadata.tmdb_alias_language_cleanup` +(payload 列出每条删除的作品 / 别名 / 语言 / kind / 原因);`--entity ` 只处理一部作品;`--samples N` 每类样例条数(缺省 5)。 +连接方式同上(用户栈的 PG 取容器 IP): + +```sh +DATABASE_URL=postgres://umt:<密码>@$PGIP:5432/umt deno run -A scripts/tmdb-alias-language-cleanup.ts # 看统计与样例 +DATABASE_URL=postgres://umt:<密码>@$PGIP:5432/umt deno run -A scripts/tmdb-alias-language-cleanup.ts --apply # 逐类目过目后执行 +``` + +- **判定口径**(`title_alias.source` 能区分来源):只动 `source = tmdb` 且未锁定的行;`user` / `site` / `agent` / `nfo` / `import` + 来源与锁定的行一律不动(清单里只报数量)。tmdb 行中:`kind = original` 保留;与作品主标题 / 原标题归一化相同的保留; + `language` 非空 → 规范化后在保留范围(原语言取 `media_entity.metadata.originalLanguage`,没有时取 tmdb 字段来源)保留,否则删 + (原因 `language`);`language` 为空(旧版建档写的 `other` 等)→ 汉字(不含假名 / 谚文)、拉丁字母、没有字母的保留,其它文字 + 只在与原语言相符时保留,否则删(原因 `script`)。拉丁字母分不出英 / 法 / 西 / 葡,空语言的拉丁别名一律按英文保留—— + 要彻底收敛这部分,对作品跑一次元数据刷新不会删它们(刷新只增不删),需要时人工处理。 +- **字段来源**:`metadata_field`(来源 tmdb)里语言不在保留范围的值只报告不删(删了会让最终值暂时没有来源)。对清单里的作品 + 跑一次元数据刷新即收敛(同字段换语言时删旧行),批量用计划页「元数据语言回刷」(`metadata.backfill_languages`)。 +- 输出:别名总数 / 删除 / 保留,保留按原因计数;删除按「语言 / kind」分组计数,每组列样例(作品、原语言、别名); + 字段来源按「语言 / 作品或单元·字段」计数并列涉及的作品。 +- **对部署库**(lessons 第 22 条):先 dry-run,协调者逐类目看清单(重点看删除组里有没有人工补录却被记成 tmdb 来源的、 + 原语言为空导致误删的日 / 韩 / 泰文别名)再 `--apply`。原语言为空的作品只保留中英文,先对这些作品刷新元数据补上原语言再清理更稳。 + ## 排查 ```sql diff --git a/docs/runbook/metadata.md b/docs/runbook/metadata.md index 3962cec1..b4fc27d0 100644 --- a/docs/runbook/metadata.md +++ b/docs/runbook/metadata.md @@ -9,6 +9,8 @@ - 季 / 集:先按链首请求 `/tv/{id}/season/{n}`;已播出的集缺标题(TMDB 占位名"第 N 集""Episode N")或缺简介时,按链逐个语言补拉同一端点,补齐即停(简介只从简介链里的语言取;只缺简介且剩下的语言都不在简介链里时不再补拉);链走完仍没有标题的集保留占位名(语言记为空)。未播出的集不触发补拉。 - 每个字段的来源值写 `metadata_field`,`language` 记实际取到的语言;同字段语言变了,旧语言的 tmdb 行删掉。 - `translations` 与 `alternative_titles` 写 `title_alias`(来源 tmdb,标语言;别名只在地区与文字相符时标,如 CN + 汉字 → zh-CN)。已有同文本别名(繁简归一化后同键)不重复登记,只给语言为空的 tmdb 行补语言。 +- 多语言保留规则(2026-10-06):TMDB 的译名 / 别名 / 简介 / 标语只保留简中、繁中、英文和作品原语言,语言链按作品收敛 + (链里的 ja-JP 只对日语原作生效,fr-FR 这类对原语言不是它的作品跳过)。规则、清理脚本见 `identity.md`「TMDB 多语言保留规则」。 ## 刷新 diff --git a/scripts/tmdb-alias-language-cleanup.ts b/scripts/tmdb-alias-language-cleanup.ts new file mode 100644 index 00000000..4ab23f48 --- /dev/null +++ b/scripts/tmdb-alias-language-cleanup.ts @@ -0,0 +1,187 @@ +// TMDB 多语言别名清理(用户规则 2026-10-06):TMDB 的多语言数据只保留简中 / 繁中 / 英文 / 作品原语言。 +// 规则见 packages/adapters/src/tmdb/language-policy.ts、docs/runbook/identity.md「TMDB 多语言保留规则」。 +// 默认只读(dry-run),加 --apply 才删(单个事务,写一条审计 metadata.tmdb_alias_language_cleanup)。 +// +// 用法(DATABASE_URL 必须显式给出,脚本不带默认库): +// DATABASE_URL=postgres://umt:***@:5432/umt deno run -A scripts/tmdb-alias-language-cleanup.ts # 看统计与样例 +// DATABASE_URL=… deno run -A scripts/tmdb-alias-language-cleanup.ts --apply # 删除 +// DATABASE_URL=… deno run -A scripts/tmdb-alias-language-cleanup.ts --entity <作品 id> # 只看 / 只处理一部作品 +// DATABASE_URL=… deno run -A scripts/tmdb-alias-language-cleanup.ts --samples 20 # 每类样例条数(缺省 5) +// +// 别名(title_alias)判定——只动 source = 'tmdb' 且未锁定的行;人工(user)/ 站点(site)/ 智能体(agent)/ NFO / 导入来源与 +// 锁定的行一律不动: +// - kind = original(原名)保留;与作品主标题 / 原标题归一化相同的保留(作品自己的名字); +// - language 非空:规范化后是简中(zh-CN / zh-SG / zh-Hans / zh)、繁中(zh-TW / zh-HK / zh-MO / zh-Hant)、英文(en-*)、 +// 作品原语言(media_entity.metadata.originalLanguage,没有时取 TMDB 字段来源)→ 保留,否则删除(原因 language); +// - language 为空(旧版建档写的 other 等):按文字判断——汉字(不含假名 / 谚文)、拉丁字母、没有字母的保留; +// 其它文字(西里尔 / 阿拉伯 / 希伯来 / 泰文 / 谚文 / 假名 …)只在与作品原语言相符时保留,否则删除(原因 script)。 +// 拉丁字母无法区分英 / 法 / 西 / 葡,一律按英文保留。 +// 字段来源(metadata_field,来源 tmdb)里语言不在保留范围的标题 / 简介 / 集名只报告不删:删了会让最终值暂时没有来源, +// 对这些作品跑一次元数据刷新(语言链已按作品收敛)即被同字段的保留语言值替换(同字段换语言时删旧行)。 +// 作品与单元的 metadata 只存按语言链取到的单个值,不存 translations 列表,没有别的多语言数据要收敛。 + +import { allowedLanguage, untaggedTextAllowed } from "@umt/adapters"; +import { normalizeTitle } from "@umt/domain"; +import pg from "pg"; + +const argv = [...Deno.args]; +const APPLY = argv.includes("--apply"); +if (APPLY && argv.includes("--dry-run")) { + console.error("--apply 与 --dry-run 不能同时给出"); + Deno.exit(2); +} +const argOf = (k: string) => { + const i = argv.indexOf(k); + return i >= 0 ? (argv[i + 1] ?? null) : null; +}; +const ONLY_ENTITY = argOf("--entity"); +const SAMPLES = Number(argOf("--samples") ?? 5) || 5; +const url = Deno.env.get("DATABASE_URL"); +if (!url) { + console.error("需要 DATABASE_URL(脚本不带默认库)"); + Deno.exit(2); +} +const db = new pg.Client({ connectionString: url }); +await db.connect(); +const q = async (text: string, values: unknown[] = []) => (await db.query(text, values)).rows as T[]; +console.log( + `# tmdb-alias-language-cleanup (${APPLY ? "APPLY" : "DRY-RUN"})${ONLY_ENTITY ? ` --entity ${ONLY_ENTITY}` : ""}\n`, +); + +interface Row { + id: string; + media_entity_id: string; + alias: string; + normalized: string; + language: string | null; + kind: string; + title: string; + original_title: string | null; + orig_lang: string | null; +} + +// 作品原语言:投影值优先,没有时取 tmdb 字段来源 +const ORIG_LANG = `coalesce(e.metadata->'originalLanguage'->>'value', + (select f.value#>>'{}' from metadata_field f where f.media_entity_id = e.id and f.content_unit_id is null + and f.field = 'originalLanguage' and f.source = 'tmdb' order by f.fetched_at desc limit 1))`; + +const rows = await q( + `select a.id, a.media_entity_id, a.alias, a.normalized, a.language, a.kind::text as kind, + e.title, e.original_title, ${ORIG_LANG} as orig_lang + from title_alias a join media_entity e on e.id = a.media_entity_id + where a.source = 'tmdb' and not a.locked and ($1::uuid is null or a.media_entity_id = $1::uuid) + order by e.title, a.alias`, + [ONLY_ENTITY], +); +const other = await q<{ source: string; locked: boolean; n: number }>( + `select source::text as source, locked, count(*)::int as n from title_alias + where (source <> 'tmdb' or locked) and ($1::uuid is null or media_entity_id = $1::uuid) + group by 1, 2 order by 1, 2`, + [ONLY_ENTITY], +); + +type Verdict = { del: false; why: string } | { del: true; why: "language" | "script" }; +function judge(r: Row): Verdict { + if (r.kind === "original") return { del: false, why: "原名" }; + const own = [r.title, r.original_title].filter((x): x is string => !!x).map(normalizeTitle); + if (own.includes(r.normalized)) return { del: false, why: "作品自己的名字" }; + const work = { originalLanguage: r.orig_lang }; + if (r.language !== null) + return allowedLanguage(r.language, work) ? { del: false, why: "保留语言" } : { del: true, why: "language" }; + return untaggedTextAllowed(r.alias, work) ? { del: false, why: "无语言·文字相符" } : { del: true, why: "script" }; +} + +const deletes: (Row & { why: string })[] = []; +const keepBy = new Map(); +for (const r of rows) { + const v = judge(r); + if (v.del) deletes.push({ ...r, why: v.why }); + else keepBy.set(v.why, (keepBy.get(v.why) ?? 0) + 1); +} + +console.log( + `## 别名(来源 tmdb、未锁定):${rows.length} 条,删除 ${deletes.length} 条,保留 ${rows.length - deletes.length} 条`, +); +for (const [why, n] of [...keepBy].sort((a, b) => b[1] - a[1])) console.log(` 保留·${why}:${n} 条`); +console.log( + ` 不动(非 tmdb 来源或已锁定):${other.map((o) => `${o.source}${o.locked ? "·锁定" : ""} ${o.n}`).join(",") || "0"}`, +); +const groups = new Map(); +for (const d of deletes) { + const k = `${d.language ?? "(空)"} / ${d.kind}`; + groups.set(k, [...(groups.get(k) ?? []), d]); +} +if (deletes.length) console.log(`\n### 删除:按语言 / kind`); +for (const [k, list] of [...groups].sort((a, b) => b[1].length - a[1].length)) { + console.log(` ${k}:${list.length} 条`); + for (const d of list.slice(0, SAMPLES)) + console.log(` - 《${d.title}》(原语言 ${d.orig_lang ?? "未知"}):${d.alias}`); +} + +// 字段来源里语言不在保留范围的(只报告) +const fields = await q<{ + entity_id: string; + title: string; + orig_lang: string | null; + field: string; + language: string; + unit: boolean; +}>( + `select e.id as entity_id, e.title, ${ORIG_LANG} as orig_lang, f.field, f.language, f.content_unit_id is not null as unit + from metadata_field f + left join content_unit u on u.id = f.content_unit_id + join media_entity e on e.id = coalesce(f.media_entity_id, u.media_entity_id) + where f.source = 'tmdb' and f.language is not null and ($1::uuid is null or e.id = $1::uuid)`, + [ONLY_ENTITY], +); +const badFields = fields.filter((f) => !allowedLanguage(f.language, { originalLanguage: f.orig_lang })); +console.log(`\n## 字段来源(tmdb)语言不在保留范围:${badFields.length} 条(只报告,对涉及的作品跑元数据刷新即收敛)`); +const byLang = new Map(); +for (const f of badFields) + byLang.set( + `${f.language} / ${f.unit ? "单元" : "作品"}·${f.field}`, + (byLang.get(`${f.language} / ${f.unit ? "单元" : "作品"}·${f.field}`) ?? 0) + 1, + ); +for (const [k, n] of [...byLang].sort((a, b) => b[1] - a[1])) console.log(` ${k}:${n} 条`); +const badEntities = [...new Map(badFields.map((f) => [f.entity_id, f.title])).entries()]; +for (const [id, title] of badEntities.slice(0, SAMPLES * 4)) console.log(` - 《${title}》 ${id}`); + +if (!APPLY) { + console.log(`\n(dry-run,未改库;确认后加 --apply)`); +} else if (deletes.length) { + try { + await db.query("begin"); + const ids = deletes.map((d) => d.id); + const res = await db.query( + `delete from title_alias where id = any($1::uuid[]) and source = 'tmdb' and not locked`, + [ids], + ); + await db.query( + `insert into audit_event (actor_type, actor_id, action, target_type, target_id, payload) + values ('system', 'tmdb-alias-language-cleanup', 'metadata.tmdb_alias_language_cleanup', 'media_entity', $1, $2::jsonb)`, + [ + ONLY_ENTITY, + JSON.stringify({ + deleted: res.rowCount, + aliases: deletes.map((d) => ({ + entityId: d.media_entity_id, + alias: d.alias, + language: d.language, + kind: d.kind, + reason: d.why, + })), + }), + ], + ); + await db.query("commit"); + console.log(`\n已删除 ${res.rowCount} 条别名`); + } catch (e) { + await db.query("rollback"); + console.error("执行失败,已回滚:", (e as Error).message); + await db.end(); + Deno.exit(1); + } +} else { + console.log(`\n没有要删除的别名`); +} +await db.end();