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

@@ -0,0 +1,24 @@
import type { Kysely } from "kysely";
import { execDdl, oneOf } from "../src/migration-helpers.ts";
// DH-1 内容单元来源:release(资源解析懒建)、file(整理 / 文件扫描懒建)、tmdb(TMDB 季集结构建或对上后转正式)、user(人工)。
// 旧行:已确认且带 TMDB 字段来源 / TMDB 总集数的单元回填 tmdb;其余留空(来源未知)。
// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型
export async function up(db: Kysely<any>): Promise<void> {
await execDdl(
db,
`
alter table content_unit add column source text ${oneOf("source", ["release", "file", "tmdb", "user"])};
update content_unit u set source = 'tmdb'
where u.status = 'confirmed'
and (u.episode_total_source = 'tmdb'
or exists (select 1 from metadata_field f where f.content_unit_id = u.id and f.source = 'tmdb'));
`,
);
}
// biome-ignore lint/suspicious/noExplicitAny: 同上
export async function down(db: Kysely<any>): Promise<void> {
await execDdl(db, `alter table content_unit drop column if exists source;`);
}
+2
View File
@@ -100,6 +100,7 @@ export type ContentUnitContentType =
export type ContentUnitSegment = "full" | "upper" | "middle" | "lower" | "unknown";
export type ContentUnitStatus = "provisional" | "confirmed" | "retired";
export type ContentUnitEpisodeTotalSource = "tmdb" | "injected" | "manual" | "covered_floor" | "previous";
export type ContentUnitSource = "release" | "file" | "tmdb" | "user";
export type DecisionKind =
| "identity_ambiguous"
| "download_stalled"
@@ -642,6 +643,7 @@ export interface ContentUnitTable {
episode_total_source: ContentUnitEpisodeTotalSource | null;
episode_total_locked: Generated<boolean>;
episode_total_at: Timestamptz | null;
source: ContentUnitSource | null;
}
export interface DecisionTable {