- 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]>
274 lines
11 KiB
TypeScript
274 lines
11 KiB
TypeScript
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>
|
||
);
|
||
}
|