- domain versionSpecOf: file name + ffprobe + ingest quality -> version spec;
summarizeVersions; matchVersions (satisfied / missing / upgradeable, single target)
- GET /library/{id}/versions: per-season/episode versions, summaries, matches[]
- organize writes full spec into media_asset.delivery (specVersion 1)
- scripts/asset-spec-backfill.ts: dry-run by default, --probe local only, --apply
- web: season summary line, one-line spec per episode, N versions expand,
tech info details (TODO: DEAI-2 TechInfo), movie versions panel
Co-Authored-By: Claude Opus 5.5 <[email protected]>
199 lines
7.4 KiB
TypeScript
199 lines
7.4 KiB
TypeScript
import { z } from "zod";
|
|
import { EntityBase, Id, JsonObject, NonNegInt, SERVER_MANAGED, Timestamp } from "./common.ts";
|
|
import { Segment } from "./content.ts";
|
|
|
|
// 文件域:资产、资产-内容单元覆盖、资产位置、路径绑定(REQ §7.1、§8.2、§12.2)
|
|
// 资产与位置分离;path_binding 一旦建立不因元数据刷新改变,只有显式整理可替换(旧绑定标 superseded)。
|
|
|
|
export const AssetKind = z.enum(["video", "bluray_dir", "subtitle", "audio", "nfo", "image", "other"]);
|
|
export type AssetKind = z.infer<typeof AssetKind>;
|
|
export const AssetStatus = z.enum(["active", "replaced", "retired", "missing"]);
|
|
export type AssetStatus = z.infer<typeof AssetStatus>;
|
|
|
|
/** 交付特征(分辨率/编码/音轨/来源类型/发布组),与内容版本 contentEdition 分开(REQ §6.1) */
|
|
export const DeliveryTraits = z.object({
|
|
resolution: z.string().optional(),
|
|
videoCodec: z.string().optional(),
|
|
audioCodecs: z.array(z.string()).optional(),
|
|
hdr: z.array(z.string()).optional(),
|
|
sourceType: z.string().optional(),
|
|
releaseGroup: z.string().optional(),
|
|
subtitles: z.array(z.string()).optional(),
|
|
// LIBV-1 版本规格(domain deliveryOfSpec 写入;specVersion 标记口径,缺省 = 旧数据待回填)
|
|
specVersion: z.number().int().optional(),
|
|
specFrom: z.array(z.enum(["name", "probe"])).optional(),
|
|
isEncode: z.boolean().optional(),
|
|
bitDepth: z.number().int().optional(),
|
|
audio: z
|
|
.array(z.object({ codec: z.string().optional(), channels: z.string().optional(), language: z.string().optional() }))
|
|
.optional(),
|
|
edition: z.string().optional(),
|
|
durationSec: z.number().int().optional(),
|
|
});
|
|
export type DeliveryTraits = z.infer<typeof DeliveryTraits>;
|
|
|
|
export const MediaAsset = EntityBase.extend({
|
|
mediaEntityId: Id.nullable(),
|
|
kind: AssetKind,
|
|
/** 附属文件(字幕/音轨)指向主视频资产 */
|
|
parentAssetId: Id.nullable(),
|
|
sizeBytes: NonNegInt.nullable(),
|
|
contentHash: z.string().nullable(),
|
|
hashAlgorithm: z.string().nullable(),
|
|
sampleDigest: z.string().nullable(),
|
|
/** ffprobe/MediaInfo 结果 */
|
|
mediaInfo: JsonObject,
|
|
delivery: DeliveryTraits,
|
|
/** 内容版本:院线版/导演剪辑版等 */
|
|
contentEdition: z.string().nullable(),
|
|
sourceReleaseId: Id.nullable(),
|
|
downloadTaskId: Id.nullable(),
|
|
status: AssetStatus,
|
|
verifiedAt: Timestamp.nullable(),
|
|
});
|
|
export type MediaAsset = z.infer<typeof MediaAsset>;
|
|
export const MediaAssetCreate = MediaAsset.omit(SERVER_MANAGED).partial({
|
|
mediaEntityId: true,
|
|
parentAssetId: true,
|
|
sizeBytes: true,
|
|
contentHash: true,
|
|
hashAlgorithm: true,
|
|
sampleDigest: true,
|
|
mediaInfo: true,
|
|
delivery: true,
|
|
contentEdition: true,
|
|
sourceReleaseId: true,
|
|
downloadTaskId: true,
|
|
status: true,
|
|
verifiedAt: true,
|
|
});
|
|
export type MediaAssetCreate = z.infer<typeof MediaAssetCreate>;
|
|
export const MediaAssetUpdate = MediaAssetCreate.partial();
|
|
export type MediaAssetUpdate = z.infer<typeof MediaAssetUpdate>;
|
|
|
|
export const CoverageKind = z.enum(["full", "partial", "segment", "unknown"]);
|
|
export type CoverageKind = z.infer<typeof CoverageKind>;
|
|
|
|
/** 一个资产覆盖哪些内容单元(E01-E03 合集、上/下分段) */
|
|
export const AssetUnitCoverage = EntityBase.extend({
|
|
mediaAssetId: Id,
|
|
contentUnitId: Id,
|
|
coverage: CoverageKind,
|
|
segment: Segment.nullable(),
|
|
evidence: JsonObject,
|
|
});
|
|
export type AssetUnitCoverage = z.infer<typeof AssetUnitCoverage>;
|
|
export const AssetUnitCoverageCreate = AssetUnitCoverage.omit(SERVER_MANAGED).partial({
|
|
coverage: true,
|
|
segment: true,
|
|
evidence: true,
|
|
});
|
|
export type AssetUnitCoverageCreate = z.infer<typeof AssetUnitCoverageCreate>;
|
|
export const AssetUnitCoverageUpdate = AssetUnitCoverageCreate.omit({
|
|
mediaAssetId: true,
|
|
contentUnitId: true,
|
|
}).partial();
|
|
export type AssetUnitCoverageUpdate = z.infer<typeof AssetUnitCoverageUpdate>;
|
|
|
|
export const LocationKind = z.enum(["library", "download_cache", "hardlink", "external"]);
|
|
export type LocationKind = z.infer<typeof LocationKind>;
|
|
/** 路径不存在时先区分根离线/权限/外部移动,再按 unavailable → suspected_missing → confirmed_missing 推进 */
|
|
export const LocationStatus = z.enum(["available", "unavailable", "suspected_missing", "confirmed_missing", "removed"]);
|
|
export type LocationStatus = z.infer<typeof LocationStatus>;
|
|
|
|
export const AssetLocation = EntityBase.extend({
|
|
mediaAssetId: Id,
|
|
locationKind: LocationKind,
|
|
libraryRootId: Id.nullable(),
|
|
downloaderInstanceId: Id.nullable(),
|
|
/** 相对库根(或下载器保存根)的路径 */
|
|
relativePath: z.string().min(1),
|
|
status: LocationStatus,
|
|
isPrimary: z.boolean(),
|
|
sizeBytes: NonNegInt.nullable(),
|
|
inode: z.number().int().nullable(),
|
|
device: z.number().int().nullable(),
|
|
mtime: Timestamp.nullable(),
|
|
sidecarOfLocationId: Id.nullable(),
|
|
lastSeenAt: Timestamp.nullable(),
|
|
lastCheckedAt: Timestamp.nullable(),
|
|
checkDetail: JsonObject,
|
|
});
|
|
export type AssetLocation = z.infer<typeof AssetLocation>;
|
|
export const AssetLocationCreate = AssetLocation.omit(SERVER_MANAGED)
|
|
.partial({
|
|
locationKind: true,
|
|
libraryRootId: true,
|
|
downloaderInstanceId: true,
|
|
status: true,
|
|
isPrimary: true,
|
|
sizeBytes: true,
|
|
inode: true,
|
|
device: true,
|
|
mtime: true,
|
|
sidecarOfLocationId: true,
|
|
lastSeenAt: true,
|
|
lastCheckedAt: true,
|
|
checkDetail: true,
|
|
})
|
|
.refine((v) => (v.locationKind ?? "library") !== "library" || !!v.libraryRootId, {
|
|
message: "library 位置必须指定 libraryRootId",
|
|
});
|
|
export type AssetLocationCreate = z.infer<typeof AssetLocationCreate>;
|
|
export const AssetLocationUpdate = AssetLocation.pick({
|
|
status: true,
|
|
isPrimary: true,
|
|
sizeBytes: true,
|
|
inode: true,
|
|
device: true,
|
|
mtime: true,
|
|
lastSeenAt: true,
|
|
lastCheckedAt: true,
|
|
checkDetail: true,
|
|
}).partial();
|
|
export type AssetLocationUpdate = z.infer<typeof AssetLocationUpdate>;
|
|
|
|
export const BindingKind = z.enum(["entity", "season"]);
|
|
export type BindingKind = z.infer<typeof BindingKind>;
|
|
export const BindingStatus = z.enum(["active", "conflict", "superseded"]);
|
|
export type BindingStatus = z.infer<typeof BindingStatus>;
|
|
|
|
export const PathBinding = EntityBase.extend({
|
|
mediaEntityId: Id,
|
|
contentUnitId: Id.nullable(),
|
|
seasonNumber: z.number().int().nonnegative().nullable(),
|
|
bindingKind: BindingKind,
|
|
libraryRootId: Id,
|
|
relativePath: z.string().min(1),
|
|
templateVersion: z.string().nullable(),
|
|
status: BindingStatus,
|
|
boundAt: Timestamp,
|
|
boundBy: z.string().nullable(),
|
|
supersededById: Id.nullable(),
|
|
note: z.string().nullable(),
|
|
});
|
|
export type PathBinding = z.infer<typeof PathBinding>;
|
|
export const PathBindingCreate = PathBinding.pick({
|
|
mediaEntityId: true,
|
|
contentUnitId: true,
|
|
seasonNumber: true,
|
|
bindingKind: true,
|
|
libraryRootId: true,
|
|
relativePath: true,
|
|
templateVersion: true,
|
|
status: true,
|
|
note: true,
|
|
})
|
|
.partial({ contentUnitId: true, seasonNumber: true, templateVersion: true, status: true, note: true })
|
|
.refine((v) => (v.bindingKind === "season") === (v.seasonNumber !== undefined && v.seasonNumber !== null), {
|
|
message: "season 绑定必须给 seasonNumber,entity 绑定不能给",
|
|
});
|
|
export type PathBindingCreate = z.infer<typeof PathBindingCreate>;
|
|
/** 绑定路径不可就地修改:只能改备注或标记冲突/被替代;换目录走整理操作生成新绑定 */
|
|
export const PathBindingUpdate = z.object({
|
|
status: BindingStatus.optional(),
|
|
supersededById: Id.nullable().optional(),
|
|
note: z.string().nullable().optional(),
|
|
});
|
|
export type PathBindingUpdate = z.infer<typeof PathBindingUpdate>;
|