- 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]>
289 lines
11 KiB
TypeScript
289 lines
11 KiB
TypeScript
import { z } from "zod";
|
|
import { ActorType, DateOnly, EntityBase, Id, JsonObject, SERVER_MANAGED, Timestamp } from "./common.ts";
|
|
import { ExternalIdentity, FieldEvidence } from "./identity.ts";
|
|
|
|
// 内容域:本地作品、内容单元、别名(REQ §3.1、§5.1、§5.4、§7.5)
|
|
// 本地 UUID 是主身份;季/集编号、外部 ID 都可修订,不是唯一身份。
|
|
|
|
export const MediaKind = z.enum(["movie", "series", "variety", "collection", "other"]);
|
|
export type MediaKind = z.infer<typeof MediaKind>;
|
|
export const MediaEntityStatus = z.enum(["provisional", "active", "merged", "archived"]);
|
|
export type MediaEntityStatus = z.infer<typeof MediaEntityStatus>;
|
|
|
|
export const MediaEntity = EntityBase.extend({
|
|
kind: MediaKind,
|
|
title: z.string().min(1),
|
|
originalTitle: z.string().nullable(),
|
|
year: z.number().int().min(1870).max(2200).nullable(),
|
|
status: MediaEntityStatus,
|
|
/** status=merged 时指向合并后的实体(旧 ID 跳转) */
|
|
mergedIntoId: Id.nullable(),
|
|
/** 最终展示字段(每个字段带来源/语言/时间) */
|
|
metadata: JsonObject,
|
|
/** 人工锁定的字段名;刷新不得覆盖 */
|
|
fieldLocks: z.array(z.string()),
|
|
lastMetadataRefreshAt: Timestamp.nullable(),
|
|
/** 作品级命名模板覆盖 {movie?, tv?, variety?}(见 settings NamingOverride);null = 不覆盖。经 PUT /media-entities/{id}/naming-override 修改 */
|
|
namingOverride: JsonObject.nullable().optional(),
|
|
});
|
|
export type MediaEntity = z.infer<typeof MediaEntity>;
|
|
export const MediaEntityCreate = MediaEntity.pick({
|
|
kind: true,
|
|
title: true,
|
|
originalTitle: true,
|
|
year: true,
|
|
status: true,
|
|
metadata: true,
|
|
fieldLocks: true,
|
|
}).partial({ originalTitle: true, year: true, status: true, metadata: true, fieldLocks: true });
|
|
export type MediaEntityCreate = z.infer<typeof MediaEntityCreate>;
|
|
/** 合并通过专门动作完成,Update 不接受 status=merged */
|
|
export const MediaEntityUpdate = MediaEntityCreate.partial().extend({
|
|
status: MediaEntityStatus.exclude(["merged"]).optional(),
|
|
});
|
|
export type MediaEntityUpdate = z.infer<typeof MediaEntityUpdate>;
|
|
|
|
export const ContentUnitKind = z.enum(["season", "episode", "special", "part"]);
|
|
export type ContentUnitKind = z.infer<typeof ContentUnitKind>;
|
|
/** 综艺内容类型(REQ §5.1) */
|
|
export const ContentType = z.enum([
|
|
"main",
|
|
"extra",
|
|
"behind_the_scenes",
|
|
"performance_only",
|
|
"preview",
|
|
"reaction",
|
|
"unknown",
|
|
]);
|
|
export type ContentType = z.infer<typeof ContentType>;
|
|
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"]);
|
|
export type EpisodeTotalSource = z.infer<typeof EpisodeTotalSource>;
|
|
|
|
export const ContentUnit = EntityBase.extend({
|
|
mediaEntityId: Id,
|
|
parentUnitId: Id.nullable(),
|
|
kind: ContentUnitKind,
|
|
contentType: ContentType,
|
|
segment: Segment,
|
|
seasonNumber: z.number().int().nonnegative().nullable(),
|
|
episodeNumber: z.number().int().nonnegative().nullable(),
|
|
/** 来源期号原文,如 "第十期"、"20260927" */
|
|
sourceIssue: z.string().nullable(),
|
|
airDate: DateOnly.nullable(),
|
|
displayOrder: z.number().nullable(),
|
|
title: z.string().nullable(),
|
|
status: ContentUnitStatus,
|
|
/** 人工确认的编号锁定;刷新不得改编号 */
|
|
numberingLocked: z.boolean(),
|
|
metadata: JsonObject,
|
|
fieldLocks: z.array(z.string()),
|
|
/** 季单元的总集数账本(只升;回退不低于已覆盖最高集);null = 未知,追更不结束 */
|
|
episodeTotal: z.number().int().nonnegative().nullable(),
|
|
episodeTotalSource: EpisodeTotalSource.nullable(),
|
|
/** 人工锁定总集数:元数据刷新与注入不再改写 */
|
|
episodeTotalLocked: z.boolean(),
|
|
episodeTotalAt: Timestamp.nullable(),
|
|
source: ContentUnitSource.nullable(),
|
|
});
|
|
export type ContentUnit = z.infer<typeof ContentUnit>;
|
|
export const ContentUnitCreate = ContentUnit.omit(SERVER_MANAGED).partial({
|
|
parentUnitId: true,
|
|
contentType: true,
|
|
segment: true,
|
|
seasonNumber: true,
|
|
episodeNumber: true,
|
|
sourceIssue: true,
|
|
airDate: true,
|
|
displayOrder: true,
|
|
title: true,
|
|
status: true,
|
|
numberingLocked: true,
|
|
metadata: true,
|
|
fieldLocks: true,
|
|
episodeTotal: true,
|
|
episodeTotalSource: true,
|
|
episodeTotalLocked: true,
|
|
episodeTotalAt: true,
|
|
source: true,
|
|
});
|
|
export type ContentUnitCreate = z.infer<typeof ContentUnitCreate>;
|
|
export const ContentUnitUpdate = ContentUnitCreate.omit({ mediaEntityId: true }).partial();
|
|
export type ContentUnitUpdate = z.infer<typeof ContentUnitUpdate>;
|
|
|
|
export const AliasKind = z.enum(["original", "translated", "short", "site_title", "romanized", "other"]);
|
|
export type AliasKind = z.infer<typeof AliasKind>;
|
|
export const AliasSource = z.enum(["tmdb", "user", "site", "agent", "nfo", "import"]);
|
|
export type AliasSource = z.infer<typeof AliasSource>;
|
|
|
|
export const TitleAlias = EntityBase.extend({
|
|
mediaEntityId: Id,
|
|
contentUnitId: Id.nullable(),
|
|
alias: z.string().min(1),
|
|
/** 归一化后的检索键(小写、去标点、全半角统一),建 trgm 索引 */
|
|
normalized: z.string().min(1),
|
|
language: z.string().nullable(),
|
|
kind: AliasKind,
|
|
source: AliasSource,
|
|
locked: z.boolean(),
|
|
/** 派生字段(列表返回):作品标题 */
|
|
mediaEntityTitle: z.string().nullable().optional(),
|
|
});
|
|
export type TitleAlias = z.infer<typeof TitleAlias>;
|
|
/** normalized 由服务端计算,不接受客户端传入 */
|
|
export const TitleAliasCreate = TitleAlias.pick({
|
|
mediaEntityId: true,
|
|
contentUnitId: true,
|
|
alias: true,
|
|
language: true,
|
|
kind: true,
|
|
source: true,
|
|
locked: true,
|
|
}).partial({ contentUnitId: true, language: true, kind: true, locked: true });
|
|
export type TitleAliasCreate = z.infer<typeof TitleAliasCreate>;
|
|
export const TitleAliasUpdate = TitleAliasCreate.omit({ mediaEntityId: true }).partial();
|
|
export type TitleAliasUpdate = z.infer<typeof TitleAliasUpdate>;
|
|
|
|
// ── W2-B:字段级元数据(REQ §7.3)与作品详情 ──
|
|
|
|
/** web = 值出自网页原文(META-1 补全);agent = Agent 归纳 / 翻译的生成内容。两者都不冒充 TMDB */
|
|
export const MetadataSource = z.enum(["tmdb", "user", "agent", "nfo", "site", "import", "web"]);
|
|
export type MetadataSource = z.infer<typeof MetadataSource>;
|
|
|
|
/** 某来源给出的一个候选字段值(刷新只写这里,不直接改最终展示值) */
|
|
export const MetadataField = EntityBase.extend({
|
|
mediaEntityId: Id.nullable(),
|
|
contentUnitId: Id.nullable(),
|
|
field: z.string().min(1),
|
|
value: z.unknown(),
|
|
language: z.string().nullable(),
|
|
source: MetadataSource,
|
|
sourceRevision: z.string().nullable(),
|
|
fetchedAt: Timestamp,
|
|
/** 字段证据(META-1 补全写入:来源网页、证据等级、补全记录);TMDB 等 API 来源为 null */
|
|
evidence: FieldEvidence.nullable().optional(),
|
|
});
|
|
export type MetadataField = z.infer<typeof MetadataField>;
|
|
|
|
/** 字段覆盖:用户锁定优先于任何自动刷新 */
|
|
export const FieldOverride = EntityBase.extend({
|
|
mediaEntityId: Id.nullable(),
|
|
contentUnitId: Id.nullable(),
|
|
field: z.string().min(1),
|
|
value: z.unknown(),
|
|
locked: z.boolean(),
|
|
source: MetadataSource,
|
|
reason: z.string().nullable(),
|
|
actorType: ActorType,
|
|
actorId: z.string().nullable(),
|
|
});
|
|
export type FieldOverride = z.infer<typeof FieldOverride>;
|
|
|
|
/** 可覆盖/锁定的作品字段;title/originalTitle/year 同时投影到 media_entity 列 */
|
|
export const EntityMetadataField = z.enum([
|
|
"title",
|
|
"originalTitle",
|
|
"year",
|
|
"overview",
|
|
"releaseDate",
|
|
"originalLanguage",
|
|
"originCountry",
|
|
"posterPath",
|
|
"backdropPath",
|
|
// META-1 补全(TMDB 没有的作品):播出平台、集数、类型、网页海报地址(经图片代理显示)
|
|
"platform",
|
|
"episodeCount",
|
|
"genres",
|
|
"posterUrl",
|
|
// MD-1 TMDB 详情(对照 MP 媒体详情):中文名、标语、最近播出、状态、评分、时长、季数、主创、外部 ID
|
|
"titleZh",
|
|
"tagline",
|
|
"lastAirDate",
|
|
"airStatus",
|
|
"voteAverage",
|
|
"voteCount",
|
|
"runtime",
|
|
"seasonCount",
|
|
"cast",
|
|
"crew",
|
|
"externalIds",
|
|
]);
|
|
export type EntityMetadataField = z.infer<typeof EntityMetadataField>;
|
|
|
|
/**
|
|
* 内容单元(季 / 集)可覆盖 / 锁定的字段(MD-1):来源值写 metadata_field(content_unit_id),
|
|
* 最终值投影到 content_unit.metadata;name → title 列、airDate → air_date 列。编号不属于元数据。
|
|
*/
|
|
export const UnitMetadataField = z.enum([
|
|
"name",
|
|
"overview",
|
|
"airDate",
|
|
"runtime",
|
|
"stillPath",
|
|
"posterPath",
|
|
"voteAverage",
|
|
"voteCount",
|
|
]);
|
|
export type UnitMetadataField = z.infer<typeof UnitMetadataField>;
|
|
|
|
/** 主创(cast / crew 字段的元素):TMDB 人物 ID、姓名(按回退链语言)、角色或职务、头像路径(经图片代理显示) */
|
|
export const MediaCredit = z.object({
|
|
id: z.number().int(),
|
|
name: z.string(),
|
|
originalName: z.string().nullable(),
|
|
/** 演员:角色名;幕后:职务(Director / Creator …) */
|
|
role: z.string().nullable(),
|
|
profilePath: z.string().nullable(),
|
|
});
|
|
export type MediaCredit = z.infer<typeof MediaCredit>;
|
|
|
|
/** externalIds 字段的值 */
|
|
export const MediaExternalIds = z.object({
|
|
imdb: z.string().nullable(),
|
|
tvdb: z.string().nullable(),
|
|
wikidata: z.string().nullable(),
|
|
});
|
|
export type MediaExternalIds = z.infer<typeof MediaExternalIds>;
|
|
|
|
export const FieldOverrideInput = z.object({
|
|
value: z.unknown(),
|
|
lock: z.boolean().default(true),
|
|
reason: z.string().max(2000).optional(),
|
|
});
|
|
export type FieldOverrideInput = z.infer<typeof FieldOverrideInput>;
|
|
|
|
/** media_entity.metadata 中每个最终字段的形状 */
|
|
export const FinalFieldValue = z.object({
|
|
value: z.unknown(),
|
|
source: MetadataSource,
|
|
language: z.string().nullable(),
|
|
at: Timestamp,
|
|
locked: z.boolean(),
|
|
});
|
|
export type FinalFieldValue = z.infer<typeof FinalFieldValue>;
|
|
|
|
/** 作品详情:外部身份、别名、内容单元、字段来源与覆盖 */
|
|
export const MediaEntityDetail = z.object({
|
|
entity: MediaEntity,
|
|
/** 请求的是已合并的旧 ID 时,指向跳转后的实体 */
|
|
redirectedFrom: Id.nullable(),
|
|
identities: z.array(ExternalIdentity),
|
|
aliases: z.array(TitleAlias),
|
|
contentUnits: z.array(ContentUnit),
|
|
metadataFields: z.array(MetadataField),
|
|
overrides: z.array(FieldOverride),
|
|
});
|
|
export type MediaEntityDetail = z.infer<typeof MediaEntityDetail>;
|
|
|
|
/** 合并重复作品(保留旧 ID 跳转) */
|
|
export const MediaEntityMerge = z.object({ intoId: Id, reason: z.string().max(2000).optional() });
|
|
export type MediaEntityMerge = z.infer<typeof MediaEntityMerge>;
|