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:
1 parent
2847512501
commit
044e718ec6
21 files changed
+734
-111
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -283,6 +283,8 @@ const ENUMS: Record<string, z.ZodEnum> = {
|
||||
BrowserControlLeaseStatus: C.BrowserLeaseStatus,
|
||||
// W5-C(0025)
|
||||
ContentUnitEpisodeTotalSource: C.EpisodeTotalSource,
|
||||
// DH-1(0053)
|
||||
ContentUnitSource: C.ContentUnitSource,
|
||||
};
|
||||
|
||||
describe("contracts ⇔ db 表结构同步", () => {
|
||||
|
||||
@@ -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;`);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user