Files
UHD_Media_Tool/apps/web/app/components/library/versions.tsx
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

274 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}