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 条"); }); });