Files
UHD_Media_Tool/apps/server/test/sources-identity/metadata-md1.test.ts
T
rootandClaude Opus 5.5 044e718ec6 feat(identity): content unit data hygiene — specials, provisional promotion, duplicate merge (DH-1)
- metadata.includeSpecials (default true): full refresh and the language
  backfill fetch TMDB season 0 (specials) when present, like MP; season 0
  stays out of the episode-total ledger
- refreshMetadata promotes provisional season/episode units that match the
  TMDB season/episode layout to confirmed (source=tmdb), keeping ids and all
  references; non-matching units stay provisional
- content_unit.source (migration 0053): release / file / tmdb / user
- duplicate units (same entity/kind/season/episode/content type/segment) are
  merged as a refresh step and via `scripts/identity-cleanup.ts
  --merge-duplicates` (dry-run by default); keeper = most referenced, then
  confirmed, then earliest; references auto-discovered via FKs and repointed
- ensureUnits takes a per-unit advisory xact lock to stop concurrent lazy
  creation from producing duplicates
- runbooks updated; user DB read-only dry-run: 征途 S01E09/E10 4 units each
  -> 1 each (6 removed), not applied

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-29 20:49:56 +08:00

213 lines
9.8 KiB
TypeScript

import { createMockTmdb, type MockTmdb } from "@umt/testkit";
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 { seasonsToRefresh } from "../../src/modules/sources-identity/service/metadata-rich.ts";
import { createHarness, type Harness, json, pgAvailable } from "./harness.ts";
const up = await pgAvailable();
type Final = { value: unknown; source: string; language: string | null; locked: boolean };
describe("seasonsToRefresh", () => {
it("full 拉全部正片季;默认 = 追踪 ∪ 库里已有,最多 8 季;都没有时不拉", () => {
expect(seasonsToRefresh([0, 1, 2, 3], { full: true, tracked: [], inLibrary: [] })).toEqual([1, 2, 3]);
expect(seasonsToRefresh([0, 1, 2, 3], { full: false, tracked: [], inLibrary: [] })).toEqual([]);
expect(seasonsToRefresh([1, 2, 3, 4], { full: false, tracked: [2], inLibrary: [1, 9] })).toEqual([1, 2]);
const many = Array.from({ length: 20 }, (_, i) => i + 1);
expect(seasonsToRefresh(many, { full: false, tracked: [], inLibrary: many })).toEqual(many.slice(-8));
});
});
describe.skipIf(!up)("MD-1 语言回退链 + 每集元数据", () => {
let h: Harness;
let tmdb: MockTmdb;
let id: string;
const actor = { type: "user" as const, id: "tester" };
beforeAll(async () => {
tmdb = await createMockTmdb();
h = await createHarness("md1", { tmdbUrl: tmdb.url, now: () => new Date("2026-01-01T00:00:00Z") });
});
afterAll(async () => {
await h?.close();
await tmdb?.close();
});
const entity = () => h.db.selectFrom("media_entity").selectAll().where("id", "=", id).executeTakeFirstOrThrow();
const episodes = () =>
h.db
.selectFrom("content_unit")
.selectAll()
.where("media_entity_id", "=", id)
.where("kind", "=", "episode")
.orderBy("season_number")
.orderBy("episode_number")
.execute();
it("建档后 full 刷新:标题取 CN 别名「人生切割术」,中文名 / 简介带语言,别名标语言", async () => {
const r = await createEntityFromTmdbCandidate(h.db, () => h.rt.tmdb(), { externalId: "95396", kind: "tv" }, actor);
id = r.mediaEntityId;
// details() 的 zh-CN name 是原名(TMDB 缺 zh-CN 译名时回退)
expect((await entity()).title).toBe("Severance");
const before = await episodes();
expect(before.map((u) => [u.season_number, u.episode_number, u.status])).toEqual([
[1, 1, "provisional"],
[1, 2, "provisional"],
[1, 3, "provisional"],
[2, 1, "provisional"],
]);
const acc = await json(await h.req(`/media-entities/${id}/refresh?full=1`, { method: "POST" }));
expect(acc.status).toBe(200);
expect(h.engine.enqueued.at(-1)).toMatchObject({
task: "refreshMetadata",
input: { mediaEntityId: id, full: true },
});
const res = await refreshMetadata(h.rt, id, { full: true });
expect(res.status).toBe("refreshed");
expect(res.titles).toEqual({ title: "人生切割术", language: "zh-CN", titleZh: "人生切割术" });
const e = await entity();
expect(e.title).toBe("人生切割术");
const meta = e.metadata as Record<string, Final>;
expect(meta.title).toMatchObject({ value: "人生切割术", language: "zh-CN", source: "tmdb" });
expect(meta.titleZh).toMatchObject({ value: "人生切割术", language: "zh-CN" });
expect(meta.overview).toMatchObject({ value: "故事发生在一家名为卢蒙的超级大公司内。", language: "zh-CN" });
expect(meta.genres?.value).toEqual(["剧情", "悬疑", "Sci-Fi & Fantasy"]);
expect(meta.voteAverage?.value).toBe(8.4);
expect(meta.airStatus?.value).toBe("Returning Series");
expect(meta.platform?.value).toBe("Apple TV");
expect(meta.backdropPath?.value).toBe("/severance-bg.jpg");
expect((meta.cast?.value as { name: string }[] | undefined)?.[0]?.name).toBe("亚当·斯科特");
expect(meta.externalIds?.value).toEqual({ imdb: "tt11280740", tvdb: "371980", wikidata: null });
// 同字段只留一行 tmdb 来源(语言变了删旧行)
const titleRows = await h.db
.selectFrom("metadata_field")
.select(["language"])
.where("media_entity_id", "=", id)
.where("field", "=", "title")
.execute();
expect(titleRows).toEqual([{ language: "zh-CN" }]);
const aliases = await h.db
.selectFrom("title_alias")
.select(["alias", "language", "source", "kind"])
.where("media_entity_id", "=", id)
.execute();
const lang = (a: string) => aliases.find((x) => x.alias === a)?.language;
expect(lang("人生切割术")).toBe("zh-CN");
// 繁简归一化后与「人生切割术」同键,不重复登记
expect(aliases.filter((a) => a.alias === "人生切割術")).toHaveLength(0);
expect(lang("生活割離術")).toBe("zh-HK");
expect(aliases.every((a) => a.source === "tmdb")).toBe(true);
});
it("每集元数据:字段级写入 content_unit,编号与已有单元不变,名称按链回退", async () => {
const units = await episodes();
expect(units).toHaveLength(4);
const [e1, e2, e3, s2e1] = units;
const m = (u: (typeof units)[number]) => u.metadata as Record<string, Final>;
expect(e1!.title).toBe("有关地狱的好消息");
expect(e1!.air_date).toBeTruthy();
expect(m(e1!).name).toMatchObject({ value: "有关地狱的好消息", language: "zh-CN", source: "tmdb" });
expect(m(e1!).overview?.value).toBe("马克升职成为部门主管。");
expect(m(e1!).runtime?.value).toBe(59);
expect(m(e1!).stillPath?.value).toBe("/sev-101.jpg");
expect(m(e1!).voteAverage?.value).toBe(8.2);
expect(m(e2!).name).toMatchObject({ value: "半循環", language: "zh-TW" });
expect(m(e3!).name).toMatchObject({ value: "In Perpetuity", language: "en-US" });
expect(m(e3!).overview?.value).toBeNull();
// 未播出:占位名,语言未知
expect(m(s2e1!).name).toMatchObject({ value: "第 1 集", language: null });
// 编号不被元数据刷新改动;与 TMDB 季集结构对上的临时单元转正式(DH-1,id 不变)
expect(units.map((u) => [u.season_number, u.episode_number, u.status, u.numbering_locked])).toEqual([
[1, 1, "confirmed", false],
[1, 2, "confirmed", false],
[1, 3, "confirmed", false],
[2, 1, "confirmed", false],
]);
const s1 = await h.db
.selectFrom("content_unit")
.selectAll()
.where("media_entity_id", "=", id)
.where("kind", "=", "season")
.where("season_number", "=", 1)
.executeTakeFirstOrThrow();
expect((s1.metadata as Record<string, Final>).posterPath?.value).toBe("/sev-s1.jpg");
expect((s1.metadata as Record<string, Final>).overview?.value).toContain("卢蒙");
});
it("增量:再刷新没有变化的单元不改写;锁定集名后刷新不覆盖,解锁回到来源值", async () => {
const at = async () =>
(
await h.db
.selectFrom("metadata_field")
.select(["fetched_at"])
.where("content_unit_id", "is not", null)
.execute()
)
.map((x) => x.fetched_at.getTime())
.sort();
const t0 = await at();
const again = await refreshMetadata(h.rt, id, { full: true });
expect(again.episodes?.every((s) => s.changedUnits === 0 && s.created === 0)).toBe(true);
expect(await at()).toEqual(t0);
const e1 = (await episodes())[0]!;
const put = await json(
await h.req(`/content-units/${e1.id}/fields/name`, { method: "PUT", body: { value: "自定义标题" } }),
);
expect(put.status).toBe(200);
expect(put.body.data).toMatchObject({ contentUnitId: e1.id, lockedFields: ["name"] });
const bad = await json(
await h.req(`/content-units/${e1.id}/fields/airDate`, { method: "PUT", body: { value: "x" } }),
);
expect(bad.status).toBe(400);
await refreshMetadata(h.rt, id, { full: true });
let u = (await episodes())[0]!;
expect(u.title).toBe("自定义标题");
expect(u.field_locks).toEqual(["name"]);
expect((u.metadata as Record<string, Final>).name).toMatchObject({
value: "自定义标题",
source: "user",
locked: true,
});
const del = await json(await h.req(`/content-units/${e1.id}/fields/name`, { method: "DELETE" }));
expect(del.status).toBe(200);
u = (await episodes())[0]!;
expect(u.title).toBe("有关地狱的好消息");
expect(u.field_locks).toEqual([]);
});
it("设置 metadata.languages 换链:en-US 优先时标题取英文,中文名仍是 zh-CN 值", async () => {
await h.db
.insertInto("settings")
.values({ key: "metadata", value: JSON.stringify({ languages: ["en-US", "zh-CN"] }) } as never)
.execute();
const r = await refreshMetadata(h.rt, id);
expect(r.titles).toEqual({ title: "Severance", language: "en-US", titleZh: "人生切割术" });
const e = await entity();
expect(e.title).toBe("Severance");
expect((e.metadata as Record<string, Final>).overview).toMatchObject({
value: "Mark leads a team of office workers.",
language: "en-US",
});
await h.db.deleteFrom("settings").where("key", "=", "metadata").execute();
await refreshMetadata(h.rt, id);
expect((await entity()).title).toBe("人生切割术");
});
it("作品详情 API 带内容单元元数据;作品级字段来源不含集字段", async () => {
const d = await json(await h.req(`/media-entities/${id}/detail`));
expect(d.status).toBe(200);
const ep = d.body.data.contentUnits.find(
(u: { kind: string; episodeNumber: number | null; seasonNumber: number | null }) =>
u.kind === "episode" && u.seasonNumber === 1 && u.episodeNumber === 2,
);
expect(ep.metadata.name.value).toBe("半循環");
expect(d.body.data.metadataFields.some((f: { field: string }) => f.field === "stillPath")).toBe(false);
});
});