fix(identity): never delete TMDB/metadata units in identity-cleanup orphan pass (FIX-6)

Orphan detection now only removes provisional units created by release
parsing that carry no metadata trace (field sources, overrides, title,
air date, total source) and whose entity has no accepted external
identity, or that fall outside the entity's external season/episode
layout. TMDB ledger units are never deleted. Dry-run output lists
deletions and kept units by category; --dry-run is explicit and
mutually exclusive with --apply; audit payload records each orphan.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-29 20:08:44 +08:00
1 parent 9244315768
commit bca46fd796
3 files changed
+295 -23

No files matched your search

@@ -0,0 +1,163 @@
import { execFileSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { sql } from "kysely";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { createHarness, type Harness, PG_URL, pgAvailable } from "./harness.ts";
// FIX-6 scripts/identity-cleanup.ts:孤立单元只删资源解析建出的临时单元,TMDB / 元数据来源的季集账本单元不删。
const up = await pgAvailable();
const ROOT = fileURLToPath(new URL("../../../../", import.meta.url));
const SCRIPT = fileURLToPath(new URL("../../../../scripts/identity-cleanup.ts", import.meta.url));
describe.skipIf(!up)("identity-cleanup 孤立单元判定", () => {
let h: Harness;
let url: string;
let tmdbShow: string;
let localShow: string;
let mappedNoLayout: string;
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 unit = async (
entityId: string,
kind: "season" | "episode",
season: number,
episode: number | null,
extra: Record<string, unknown> = {},
) =>
(
await h.db
.insertInto("content_unit")
.values({
media_entity_id: entityId,
kind,
season_number: season,
episode_number: episode,
status: "provisional",
...extra,
} as never)
.returning("id")
.executeTakeFirstOrThrow()
).id;
const entity = async (title: string, tmdbId: string | null, seasonCount: number | null) => {
const id = (
await h.db
.insertInto("media_entity")
.values({
kind: "series",
title,
status: "active",
metadata: JSON.stringify(
seasonCount === null ? {} : { seasonCount: { value: seasonCount, source: "tmdb", locked: false } },
),
} as never)
.returning("id")
.executeTakeFirstOrThrow()
).id;
if (tmdbId)
await h.db
.insertInto("external_identity")
.values({
media_entity_id: id,
provider: "tmdb",
entity_type: "tv",
external_id: tmdbId,
status: "accepted",
} as never)
.execute();
return id;
};
const labels = async (entityId: string) =>
(
await h.db
.selectFrom("content_unit")
.select(["kind", "season_number", "episode_number"])
.where("media_entity_id", "=", entityId)
.execute()
)
.map((u) => (u.kind === "season" ? `S${u.season_number}` : `S${u.season_number}E${u.episode_number}`))
.sort();
beforeAll(async () => {
h = await createHarness("idcleanup");
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();
// TMDB 映射的剧(季数 1):S01 带 TMDB 总集数 3,集单元都还是 provisional、没有字段来源(旧版本建的账本单元)
tmdbShow = await entity("人生切割术", "95396", 1);
const s1 = await unit(tmdbShow, "season", 1, null, { episode_total: 3, episode_total_source: "tmdb" });
for (const e of [1, 2, 3]) await unit(tmdbShow, "episode", 1, e, { parent_unit_id: s1 });
// 带 TMDB 字段来源的集
const e4 = await unit(tmdbShow, "episode", 1, 4, { parent_unit_id: s1, status: "confirmed" });
await h.db
.insertInto("metadata_field")
.values({ content_unit_id: e4, field: "name", value: JSON.stringify("第 4 集"), source: "tmdb" } as never)
.execute();
// 特别篇季(季号 0)在外部季集结构内
await unit(tmdbShow, "season", 0, null);
// 错挂资源建出的:超出 TMDB 季数 / 超出该季总集数
const s5 = await unit(tmdbShow, "season", 5, null);
await unit(tmdbShow, "episode", 5, 1, { parent_unit_id: s5 });
await unit(tmdbShow, "episode", 1, 9, { parent_unit_id: s1 });
// 没有外部映射的作品:临时孤立单元删除;带元数据标题的保留(连同季)
localShow = await entity("东北有个周东北", null, null);
const ls1 = await unit(localShow, "season", 1, null);
await unit(localShow, "episode", 1, 1, { parent_unit_id: ls1 });
await unit(localShow, "episode", 1, 2, { parent_unit_id: ls1, title: "手工命名" });
const ls2 = await unit(localShow, "season", 2, null);
await unit(localShow, "episode", 2, 1, { parent_unit_id: ls2 });
// 有外部映射但季数未知:整部作品都算在外部结构内,不删
mappedNoLayout = await entity("沙丘:预言", "90228", null);
const m0 = await unit(mappedNoLayout, "season", 0, null);
for (let e = 1; e <= 3; e++) await unit(mappedNoLayout, "episode", 0, e, { parent_unit_id: m0 });
});
afterAll(async () => {
await h?.close();
});
it("--dry-run 按类别列出删除 / 保留,不改库", async () => {
const before = await labels(tmdbShow);
const out = run("--dry-run");
expect(out).toContain("DRY-RUN");
expect(out).toContain("## 孤立的临时内容单元:删除 6 条");
expect(out).toContain("删除·作品没有外部映射:3 条\n - 《东北有个周东北》:S02 S01E01 S02E01");
expect(out).toContain("删除·超出外部季集结构:3 条\n - 《人生切割术》:S05 S01E09 S05E01");
expect(out).toContain("保留·在作品的外部季集结构内");
expect(out).toContain("保留·TMDB / 元数据 / 人工来源");
expect(await labels(tmdbShow)).toEqual(before);
expect(() => run("--dry-run", "--apply")).toThrow();
});
it("--apply:TMDB 季集账本单元不删,临时孤立单元删除", async () => {
run("--apply");
expect(await labels(tmdbShow)).toEqual(["S0", "S1", "S1E1", "S1E2", "S1E3", "S1E4"]);
expect(await labels(localShow)).toEqual(["S1", "S1E2"]);
expect(await labels(mappedNoLayout)).toEqual(["S0", "S0E1", "S0E2", "S0E3"]);
const audit = await h.db
.selectFrom("audit_event")
.select("payload")
.where("action", "=", "identity.cleanup")
.executeTakeFirstOrThrow();
const orphans = (audit.payload as { orphanUnits: { reason: string }[] }).orphanUnits;
expect(orphans.map((o) => o.reason).sort()).toEqual([
"no_external",
"no_external",
"no_external",
"outside_external",
"outside_external",
"outside_external",
]);
});
});