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]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-29 20:49:56 +08:00
1 parent 2847512501
commit 044e718ec6
21 files changed
+734 -111

No files matched your search

@@ -119,12 +119,12 @@ describe.skipIf(!up)("MD-1 语言回退链 + 每集元数据", () => {
expect(m(e3!).overview?.value).toBeNull();
// 未播出:占位名,语言未知
expect(m(s2e1!).name).toMatchObject({ value: "第 1 集", language: null });
// 编号 / 状态不被元数据刷新改动(provisional 仍是 provisional)
// 编号不被元数据刷新改动;与 TMDB 季集结构对上的临时单元转正式(DH-1,id 不变)
expect(units.map((u) => [u.season_number, u.episode_number, u.status, u.numbering_locked])).toEqual([
[1, 1, "provisional", false],
[1, 2, "provisional", false],
[1, 3, "provisional", false],
[2, 1, "provisional", false],
[1, 1, "confirmed", false],
[1, 2, "confirmed", false],
[1, 3, "confirmed", false],
[2, 1, "confirmed", false],
]);
const s1 = await h.db
.selectFrom("content_unit")