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
@@ -0,0 +1,273 @@
|
||||
import type { AssetVersion, SeasonVersions, UnitMatch, VersionSummary } from "@umt/contracts";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { type ReactNode, useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { useLibraryVersions } from "~/api/library-versions.ts";
|
||||
import { type KvItem, KvList } from "~/components/console/kv-list.tsx";
|
||||
import { consolePath } from "~/components/console/nav.ts";
|
||||
import { Panel } from "~/components/library/parts.tsx";
|
||||
import { cn, formatBytes, formatDateTime } from "~/lib/utils.ts";
|
||||
|
||||
// LIBV-1 入库版本:每集一行规格(分辨率 · 来源 · 编码 · HDR · 制作组 · 大小),多个版本可展开逐行;
|
||||
// 季头一行汇总;路径、音轨、字幕、探测原始值放进「技术信息」折叠。
|
||||
|
||||
const SOURCE_LABEL: Record<string, string> = {
|
||||
REMUX: "Remux",
|
||||
"BluRay-Disc": "原盘",
|
||||
BluRay: "蓝光压制",
|
||||
};
|
||||
const CODEC_LABEL: Record<string, string> = { "H.265": "HEVC" };
|
||||
const LANG_LABEL: Record<string, string> = {
|
||||
"zh-Hans": "简中",
|
||||
"zh-Hant": "繁中",
|
||||
zh: "中文",
|
||||
yue: "粤语",
|
||||
en: "英语",
|
||||
ja: "日语",
|
||||
ko: "韩语",
|
||||
fr: "法语",
|
||||
de: "德语",
|
||||
es: "西语",
|
||||
};
|
||||
|
||||
export const sourceLabel = (s: string | null) => (s ? (SOURCE_LABEL[s] ?? s) : null);
|
||||
const langLabel = (l: string) => LANG_LABEL[l] ?? l;
|
||||
|
||||
/** DV 带 HDR10 兜底时只写 DV */
|
||||
export function hdrLabel(hdr: readonly string[]): string | null {
|
||||
const shown = hdr.includes("DV") ? hdr.filter((h) => h !== "HDR10") : hdr;
|
||||
return shown.length ? shown.join("/") : null;
|
||||
}
|
||||
|
||||
/** 一行规格:2160p · WEB-DL · HEVC · DV · FLUX · 13.2 GB */
|
||||
export function versionParts(v: AssetVersion): string[] {
|
||||
return [
|
||||
v.resolution,
|
||||
sourceLabel(v.source),
|
||||
v.videoCodec ? (CODEC_LABEL[v.videoCodec] ?? v.videoCodec) : null,
|
||||
hdrLabel(v.hdr),
|
||||
v.edition,
|
||||
v.releaseGroup,
|
||||
v.sizeBytes !== null ? formatBytes(v.sizeBytes, 1) : null,
|
||||
].filter((x): x is string => !!x);
|
||||
}
|
||||
|
||||
/** 季 / 作品汇总:2160p WEB-DL DV ×8 · 1080p 蓝光压制 ×2 · 共 78 GB */
|
||||
export function summaryText(s: VersionSummary): string {
|
||||
const specs = s.specs.map((g) => {
|
||||
const label = [g.resolution, sourceLabel(g.source), hdrLabel(g.hdr)].filter(Boolean).join(" ") || "规格未知";
|
||||
return s.specs.length > 1 ? `${label} ×${g.count}` : label;
|
||||
});
|
||||
const shown = specs.length > 2 ? [...specs.slice(0, 2), `等 ${specs.length} 种`] : specs;
|
||||
return [...shown, s.sizeBytes ? `共 ${formatBytes(s.sizeBytes, 1)}` : null].filter(Boolean).join(" · ");
|
||||
}
|
||||
|
||||
export function seasonLine(s: SeasonVersions, withMatch = true): string | null {
|
||||
if (s.summary.versionCount === 0) return null;
|
||||
const count = `${s.episodesWithVersion}/${s.episodeTotal ?? "?"} 集`;
|
||||
const up = withMatch && s.matchCounts?.upgradeable ? `${s.matchCounts.upgradeable} 集可升级` : null;
|
||||
return [count, summaryText(s.summary), up].filter(Boolean).join(" · ");
|
||||
}
|
||||
|
||||
function audioText(v: AssetVersion): string | null {
|
||||
if (!v.audio.length) return null;
|
||||
return v.audio
|
||||
.map((a) => [a.codec, a.channels, a.language ? langLabel(a.language) : null].filter(Boolean).join(" "))
|
||||
.join(";");
|
||||
}
|
||||
|
||||
function duration(sec: number | null): string | null {
|
||||
if (!sec) return null;
|
||||
const h = Math.floor(sec / 3600);
|
||||
const m = Math.round((sec % 3600) / 60);
|
||||
return h ? `${h} 小时 ${m} 分钟` : `${m} 分钟`;
|
||||
}
|
||||
|
||||
const SPEC_FROM: Record<string, string> = { name: "文件名", probe: "ffprobe" };
|
||||
|
||||
/** 技术信息:路径、音轨、字幕、入库时间、下载任务、探测原始值 */
|
||||
// TODO(DEAI-2):TechInfo 组件合入后改用 components/console/tech-info.tsx,和其他页面统一折叠样式与默认展开偏好
|
||||
function VersionTech({ v }: { v: AssetVersion }) {
|
||||
const items: KvItem[] = [
|
||||
{ label: "文件", value: v.absolutePath ?? v.relativePath ?? v.fileName, mono: true, wide: true },
|
||||
{ label: "音轨", value: audioText(v) },
|
||||
{ label: "字幕", value: v.subtitleLanguages.map(langLabel).join("、") || null },
|
||||
{ label: "时长", value: duration(v.durationSec) },
|
||||
{ label: "色深", value: v.bitDepth ? `${v.bitDepth} bit` : null },
|
||||
{ label: "入库时间", value: formatDateTime(v.organizedAt) },
|
||||
{
|
||||
label: "下载任务",
|
||||
value: v.downloadTaskId ? (
|
||||
<Link to={consolePath("downloads", v.downloadTaskId)} className="underline-offset-2 hover:underline">
|
||||
查看下载任务
|
||||
</Link>
|
||||
) : null,
|
||||
},
|
||||
{ label: "规格来源", value: v.specFrom.map((f) => SPEC_FROM[f] ?? f).join(" + ") || null },
|
||||
].filter((it) => it.value !== null && it.value !== undefined && it.value !== "");
|
||||
return (
|
||||
<details className="group mt-1">
|
||||
<summary className="inline-flex cursor-pointer list-none items-center gap-1 text-meta text-muted-foreground hover:text-foreground [&::-webkit-details-marker]:hidden">
|
||||
技术信息
|
||||
<ChevronDown className="size-3.5 transition-transform duration-micro group-open:rotate-180" aria-hidden />
|
||||
</summary>
|
||||
<div className="grid gap-3 pt-2 pb-1">
|
||||
<KvList items={items} />
|
||||
{v.probe ? (
|
||||
<details className="group/raw">
|
||||
<summary className="cursor-pointer list-none text-meta text-muted-foreground hover:text-foreground [&::-webkit-details-marker]:hidden">
|
||||
探测原始值
|
||||
</summary>
|
||||
<pre className="mt-1 max-h-64 overflow-auto rounded-lg bg-muted/40 p-2 font-mono text-caption break-all whitespace-pre-wrap text-foreground">
|
||||
{JSON.stringify(v.probe, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
) : null}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
/** 一个版本:规格一行 + 技术信息折叠 */
|
||||
export function VersionRow({ v, best, aside }: { v: AssetVersion; best?: boolean; aside?: ReactNode }) {
|
||||
const parts = versionParts(v);
|
||||
const missing = v.status === "missing" || (v.locationStatus !== null && v.locationStatus !== "available");
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<p className="flex min-w-0 flex-wrap items-baseline gap-x-1.5 text-caption tabular-nums text-muted-foreground">
|
||||
{parts.length ? (
|
||||
parts.map((p, i) => (
|
||||
<span key={`${i}-${p}`} className={cn("whitespace-nowrap", i === 0 && "font-medium text-foreground")}>
|
||||
{i > 0 ? <span className="mr-1.5 text-muted-foreground/60">·</span> : null}
|
||||
{p}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className="break-all">{v.fileName}</span>
|
||||
)}
|
||||
{best ? <span className="text-meta text-success">符合收录版本</span> : null}
|
||||
{missing ? <span className="text-meta text-warning">文件不在原位置</span> : null}
|
||||
{aside}
|
||||
</p>
|
||||
<VersionTech v={v} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 一集的版本:一个时直接一行;多个时先显示达标(或第一个)版本,「N 个版本」展开逐行 */
|
||||
export function EpisodeVersionList({
|
||||
versions,
|
||||
match,
|
||||
className,
|
||||
}: {
|
||||
versions: AssetVersion[];
|
||||
match?: UnitMatch | undefined;
|
||||
className?: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
if (!versions.length) return null;
|
||||
const lead = versions.find((v) => v.assetId === match?.assetId) ?? versions[0]!;
|
||||
const upgrade = match?.state === "upgradeable" ? match : null;
|
||||
const note = upgrade ? (
|
||||
<p className="text-meta text-warning">
|
||||
可升级
|
||||
{upgrade.reasons.length ? <span className="text-muted-foreground">:{upgrade.reasons.join(";")}</span> : null}
|
||||
</p>
|
||||
) : null;
|
||||
if (versions.length === 1)
|
||||
return (
|
||||
<div className={cn("mt-1.5 space-y-0.5", className)}>
|
||||
<VersionRow v={lead} />
|
||||
{note}
|
||||
</div>
|
||||
);
|
||||
const rest = versions.filter((v) => v !== lead);
|
||||
const tagBest = match?.state === "satisfied";
|
||||
const toggle = (
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen(!open)}
|
||||
className="ml-1 inline-flex cursor-pointer items-center gap-0.5 rounded-md text-meta text-muted-foreground hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:outline-none"
|
||||
>
|
||||
{versions.length} 个版本
|
||||
<ChevronDown className={cn("size-3.5 transition-transform duration-micro", open && "rotate-180")} aria-hidden />
|
||||
</button>
|
||||
);
|
||||
return (
|
||||
<div className={cn("mt-1.5 space-y-1", className)}>
|
||||
<VersionRow v={lead} best={open && tagBest} aside={toggle} />
|
||||
{note}
|
||||
{open ? (
|
||||
<ul className="space-y-2 border-l border-border/70 pl-3">
|
||||
{rest.map((v) => (
|
||||
<li key={v.assetId}>
|
||||
<VersionRow v={v} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 电影的入库版本面板:每个文件一行,附与收录版本的比对 */
|
||||
export function MovieVersionsPanel({ entityId }: { entityId: string }) {
|
||||
const q = useLibraryVersions(entityId);
|
||||
const data = q.data;
|
||||
if (!data?.movie || data.movie.versions.length === 0) return null;
|
||||
const match = data.movie.matches?.[0];
|
||||
const target = data.targets[0];
|
||||
const versions = [...data.movie.versions].sort(
|
||||
(a, b) => Number(b.assetId === match?.assetId) - Number(a.assetId === match?.assetId),
|
||||
);
|
||||
const state = match
|
||||
? match.state === "satisfied"
|
||||
? `符合「${target?.name}」`
|
||||
: match.state === "upgradeable"
|
||||
? `可升级${match.reasons.length ? `:${match.reasons.join(";")}` : ""}`
|
||||
: null
|
||||
: null;
|
||||
return (
|
||||
<Panel title="入库版本" description={[summaryText(data.summary), state].filter(Boolean).join(" · ")}>
|
||||
<ul className="divide-y divide-border/60">
|
||||
{versions.map((v) => (
|
||||
<li key={v.assetId} className="py-2.5 first:pt-0 last:pb-0">
|
||||
<VersionRow
|
||||
v={v}
|
||||
best={versions.length > 1 && match?.state === "satisfied" && v.assetId === match.assetId}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
/** 剧集里没对上具体集数的文件 */
|
||||
export function UnplacedVersions({ versions }: { versions: AssetVersion[] }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
if (!versions.length) return null;
|
||||
return (
|
||||
<div className="mt-3 rounded-xl border border-border/70 px-3 py-2.5">
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen(!open)}
|
||||
className="inline-flex w-full cursor-pointer items-center justify-between gap-2 text-left text-caption text-foreground"
|
||||
>
|
||||
<span>{versions.length} 个文件没对上集数</span>
|
||||
<ChevronDown className={cn("size-4 shrink-0 text-muted-foreground", open && "rotate-180")} aria-hidden />
|
||||
</button>
|
||||
{open ? (
|
||||
<ul className="mt-2 space-y-2">
|
||||
{versions.map((v) => (
|
||||
<li key={v.assetId}>
|
||||
<VersionRow v={v} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user