Files
UHD_Media_Tool/packages/contracts/src/assets.ts
T
rootandClaude Opus 5.5 bf9e73e86e feat(library): show stored versions per episode and movie (LIBV-1)
- 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]>
2026-09-29 21:02:37 +08:00

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>;