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; export const MediaEntityStatus = z.enum(["provisional", "active", "merged", "archived"]); export type MediaEntityStatus = z.infer; 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; 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; /** 合并通过专门动作完成,Update 不接受 status=merged */ export const MediaEntityUpdate = MediaEntityCreate.partial().extend({ status: MediaEntityStatus.exclude(["merged"]).optional(), }); export type MediaEntityUpdate = z.infer; export const ContentUnitKind = z.enum(["season", "episode", "special", "part"]); export type ContentUnitKind = z.infer; /** 综艺内容类型(REQ §5.1) */ export const ContentType = z.enum([ "main", "extra", "behind_the_scenes", "performance_only", "preview", "reaction", "unknown", ]); export type ContentType = z.infer; export const Segment = z.enum(["full", "upper", "middle", "lower", "unknown"]); export type Segment = z.infer; export const ContentUnitStatus = z.enum(["provisional", "confirmed", "retired"]); export type ContentUnitStatus = z.infer; /** 内容单元来源(DH-1):资源解析懒建 / 整理·文件懒建 / 按 TMDB 季集结构建或对上后转正式 / 人工;旧行为 null */ export const ContentUnitSource = z.enum(["release", "file", "tmdb", "user"]); export type ContentUnitSource = z.infer; /** 季总集数来源:tmdb 元数据 / 插件注入(只升)/ 人工锁定 / 回退被已覆盖最高集兜住 / 沿用 */ export const EpisodeTotalSource = z.enum(["tmdb", "injected", "manual", "covered_floor", "previous"]); export type EpisodeTotalSource = z.infer; 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; 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; export const ContentUnitUpdate = ContentUnitCreate.omit({ mediaEntityId: true }).partial(); export type ContentUnitUpdate = z.infer; export const AliasKind = z.enum(["original", "translated", "short", "site_title", "romanized", "other"]); export type AliasKind = z.infer; export const AliasSource = z.enum(["tmdb", "user", "site", "agent", "nfo", "import"]); export type AliasSource = z.infer; 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; /** 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; export const TitleAliasUpdate = TitleAliasCreate.omit({ mediaEntityId: true }).partial(); export type TitleAliasUpdate = z.infer; // ── 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; /** 某来源给出的一个候选字段值(刷新只写这里,不直接改最终展示值) */ 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; /** 字段覆盖:用户锁定优先于任何自动刷新 */ 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; /** 可覆盖/锁定的作品字段;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; /** * 内容单元(季 / 集)可覆盖 / 锁定的字段(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; /** 主创(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; /** externalIds 字段的值 */ export const MediaExternalIds = z.object({ imdb: z.string().nullable(), tvdb: z.string().nullable(), wikidata: z.string().nullable(), }); export type MediaExternalIds = z.infer; export const FieldOverrideInput = z.object({ value: z.unknown(), lock: z.boolean().default(true), reason: z.string().max(2000).optional(), }); export type FieldOverrideInput = z.infer; /** 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; /** 作品详情:外部身份、别名、内容单元、字段来源与覆盖 */ 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; /** 合并重复作品(保留旧 ID 跳转) */ export const MediaEntityMerge = z.object({ intoId: Id, reason: z.string().max(2000).optional() }); export type MediaEntityMerge = z.infer;