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:
1 parent
3763c86481
commit
bf9e73e86e
19 files changed
+2060
-9
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>;
|
||||
Reference in new issue
Block a user