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

+5
View File
@@ -60,6 +60,9 @@ export const Segment = z.enum(["full", "upper", "middle", "lower", "unknown"]);
export type Segment = z.infer<typeof Segment>;
export const ContentUnitStatus = z.enum(["provisional", "confirmed", "retired"]);
export type ContentUnitStatus = z.infer<typeof ContentUnitStatus>;
/** 内容单元来源(DH-1):资源解析懒建 / 整理·文件懒建 / 按 TMDB 季集结构建或对上后转正式 / 人工;旧行为 null */
export const ContentUnitSource = z.enum(["release", "file", "tmdb", "user"]);
export type ContentUnitSource = z.infer<typeof ContentUnitSource>;
/** 季总集数来源:tmdb 元数据 / 插件注入(只升)/ 人工锁定 / 回退被已覆盖最高集兜住 / 沿用 */
export const EpisodeTotalSource = z.enum(["tmdb", "injected", "manual", "covered_floor", "previous"]);
@@ -89,6 +92,7 @@ export const ContentUnit = EntityBase.extend({
/** 人工锁定总集数:元数据刷新与注入不再改写 */
episodeTotalLocked: z.boolean(),
episodeTotalAt: Timestamp.nullable(),
source: ContentUnitSource.nullable(),
});
export type ContentUnit = z.infer<typeof ContentUnit>;
export const ContentUnitCreate = ContentUnit.omit(SERVER_MANAGED).partial({
@@ -109,6 +113,7 @@ export const ContentUnitCreate = ContentUnit.omit(SERVER_MANAGED).partial({
episodeTotalSource: true,
episodeTotalLocked: true,
episodeTotalAt: true,
source: true,
});
export type ContentUnitCreate = z.infer<typeof ContentUnitCreate>;
export const ContentUnitUpdate = ContentUnitCreate.omit({ mediaEntityId: true }).partial();
+12 -1
View File
@@ -226,6 +226,11 @@ export const MetadataSettings = z.object({
.min(1)
.max(8)
.default(["zh-CN", "zh-SG", "zh-TW", "zh-HK", "en-US"]),
/**
* DH-1 季 0(特别篇):true = full 刷新与元数据回刷在 TMDB 有季 0 时一并拉取每集信息并建集单元(对照 MP);
* 季 0 不计入正片季总集数与订阅完成判定
*/
includeSpecials: z.boolean().default(true),
});
export type MetadataSettings = z.infer<typeof MetadataSettings>;
@@ -611,7 +616,12 @@ export const DEFAULT_SETTINGS: GlobalSettings = {
sessionDir: null,
webSearch: DEFAULT_WEB_SEARCH,
},
metadata: { tmdbApiKeySecretRef: null, tmdbBaseUrl: null, languages: ["zh-CN", "zh-SG", "zh-TW", "zh-HK", "en-US"] },
metadata: {
tmdbApiKeySecretRef: null,
tmdbBaseUrl: null,
languages: ["zh-CN", "zh-SG", "zh-TW", "zh-HK", "en-US"],
includeSpecials: true,
},
sites: {
defaultRateLimit: { requestsPerMinute: 20, concurrency: 2 },
defaultSnapshotIntervalSec: 3600,
@@ -906,6 +916,7 @@ export const SETTINGS_SECTIONS: readonly SettingsSectionDef[] = [
"metadata.tmdbApiKeySecretRef",
"metadata.tmdbBaseUrl",
"metadata.languages",
"metadata.includeSpecials",
"cache.tmdbMaxSize",
"identity.eagerTmdbLookup",
"identity.autoEnrich",