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]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-29 21:02:37 +08:00
1 parent 3763c86481
commit bf9e73e86e
19 files changed
+2060 -9

No files matched your search

+10
View File
@@ -13,6 +13,7 @@ import * as execution from "./execution.ts";
import * as files from "./files.ts";
import * as identity from "./identity.ts";
import * as instances from "./instances.ts";
import * as libraryVersions from "./library-versions.ts";
import * as mcp from "./mcp.ts";
import * as ops from "./ops.ts";
import * as organization from "./organization.ts";
@@ -2181,6 +2182,15 @@ export const Api = {
params: IdParam,
response: organization.LibraryDetail,
}),
versions: ep({
method: "get",
path: "/library/{id}/versions",
scope: "read",
summary: "作品的入库版本:每季每集的本地文件版本与规格、季 / 作品汇总、与版本目标的比对",
params: IdParam,
query: libraryVersions.LibraryVersionsQuery,
response: libraryVersions.LibraryVersions,
}),
reconcile: ep({
method: "post",
path: "/library/reconcile",
+10
View File
@@ -19,6 +19,16 @@ export const DeliveryTraits = z.object({
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>;
+1
View File
@@ -13,6 +13,7 @@ export * from "./events.ts";
export * from "./execution.ts";
export * from "./identity.ts";
export * from "./instances.ts";
export * from "./library-versions.ts";
export * from "./ops.ts";
export * from "./organization.ts";
export * from "./pi.ts";
+123
View File
@@ -0,0 +1,123 @@
import { z } from "zod";
import { AssetStatus, LocationStatus } from "./assets.ts";
import { Id, JsonObject, NonNegInt, Timestamp } from "./common.ts";
import { MediaKind } from "./content.ts";
// LIBV-1 入库版本(GET /library/{id}/versions):一集(或一部电影)可挂多个本地文件,每个文件是一个版本。
// 规格来自文件名解析 + ffprobe(domain versionSpecOf);季 / 作品给汇总;
// 配了版本目标时每集给出比对结果 matches(目前只比一个目标,数组形态留给多目标)。
export const VersionAudio = z.object({
codec: z.string().optional(),
channels: z.string().optional(),
language: z.string().optional(),
});
export type VersionAudio = z.infer<typeof VersionAudio>;
export const AssetVersion = z.object({
assetId: Id,
status: AssetStatus,
resolution: z.string().nullable(),
/** REMUX / BluRay(压制)/ BluRay-Disc(原盘)/ WEB-DL / WEBRip / HDTV … */
source: z.string().nullable(),
isEncode: z.boolean(),
videoCodec: z.string().nullable(),
bitDepth: z.number().int().nullable(),
hdr: z.array(z.string()),
audio: z.array(VersionAudio),
subtitleLanguages: z.array(z.string()),
releaseGroup: z.string().nullable(),
/** 剪辑版本(导演剪辑版等),无标记为 null */
edition: z.string().nullable(),
sizeBytes: NonNegInt.nullable(),
durationSec: NonNegInt.nullable(),
fileName: z.string(),
relativePath: z.string().nullable(),
/** 库根本地路径 + 相对路径;库根未知时为 null */
absolutePath: z.string().nullable(),
libraryRootId: Id.nullable(),
locationStatus: LocationStatus.nullable(),
/** 入库时间(资产登记时间) */
organizedAt: Timestamp,
downloadTaskId: Id.nullable(),
sourceReleaseId: Id.nullable(),
/** 规格来源:name = 文件名,probe = ffprobe */
specFrom: z.array(z.enum(["name", "probe"])),
/** delivery 已按当前口径写回;false = 本次现算,可跑回填脚本 */
specStored: z.boolean(),
/** ffprobe 原始摘要(技术信息折叠里展示) */
probe: JsonObject.nullable(),
/** 覆盖的内容单元(合集文件覆盖多集) */
unitIds: z.array(Id),
});
export type AssetVersion = z.infer<typeof AssetVersion>;
export const VersionSpecGroup = z.object({
resolution: z.string().nullable(),
source: z.string().nullable(),
hdr: z.array(z.string()),
count: NonNegInt,
});
export const VersionSummary = z.object({
versionCount: NonNegInt,
sizeBytes: NonNegInt,
/** 按 分辨率 + 来源 + HDR 分组,多的在前 */
specs: z.array(VersionSpecGroup),
});
export type VersionSummary = z.infer<typeof VersionSummary>;
export const UnitMatchState = z.enum(["satisfied", "missing", "upgradeable"]);
export type UnitMatchState = z.infer<typeof UnitMatchState>;
export const UnitMatch = z.object({
slotId: Id.nullable(),
state: UnitMatchState,
assetId: Id.nullable(),
reasons: z.array(z.string()),
});
export type UnitMatch = z.infer<typeof UnitMatch>;
export const MatchCounts = z.object({ satisfied: NonNegInt, missing: NonNegInt, upgradeable: NonNegInt });
export type MatchCounts = z.infer<typeof MatchCounts>;
export const EpisodeVersions = z.object({
unitId: Id,
episodeNumber: z.number().int(),
segment: z.string().nullable(),
versions: z.array(AssetVersion),
/** 与各版本目标的比对;没有版本目标时不出现 */
matches: z.array(UnitMatch).optional(),
});
export type EpisodeVersions = z.infer<typeof EpisodeVersions>;
export const SeasonVersions = z.object({
seasonNumber: z.number().int(),
unitId: Id.nullable(),
/** 该季总集数(元数据);未知为 null */
episodeTotal: NonNegInt.nullable(),
/** 至少有一个版本的集数 */
episodesWithVersion: NonNegInt,
summary: VersionSummary,
episodes: z.array(EpisodeVersions),
/** 按第一个版本目标统计;没有版本目标时不出现 */
matchCounts: MatchCounts.optional(),
});
export type SeasonVersions = z.infer<typeof SeasonVersions>;
export const LibraryVersionsQuery = z.object({
/** 比对哪个版本目标;缺省取作品的主版本目标 */
slotId: z.uuid().optional(),
});
export type LibraryVersionsQuery = z.infer<typeof LibraryVersionsQuery>;
export const LibraryVersions = z.object({
entityId: Id,
kind: MediaKind,
/** 参与比对的版本目标(当前最多一个);空 = 未配置 */
targets: z.array(z.object({ slotId: Id, name: z.string() })),
summary: VersionSummary,
seasons: z.array(SeasonVersions),
/** 电影:全部版本与比对 */
movie: z.object({ versions: z.array(AssetVersion), matches: z.array(UnitMatch).optional() }).nullable(),
/** 剧集里没对上具体某一集的视频文件 */
unplaced: z.array(AssetVersion),
});
export type LibraryVersions = z.infer<typeof LibraryVersions>;