Files
UHD_Media_Tool/apps/web/app/components/library/versions.tsx
T
rootandClaude Opus 5.5 3f1c4d6c3b fix(downloads): humanized reasons and TechInfo on the download panel (DL-2b)
- board organize hint uses DEAI-2 reasonLine; add transfer_failed, blocked,
  blocked_by_conflict, main_failed, not_imported_kind, target_root_not_managed,
  download_removed to the reason table with tests
- download detail / plans tab: internal ids, raw codes and JSON moved into TechInfo
- library versions tech block switched to TechInfo (TODO(DEAI-2))
- copy: glossary terms (no 接管/候选), plan reason codes no longer shown raw

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-29 22:00:04 +08:00

261 lines
9.9 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 { JsonView } from "~/components/console/json-view.tsx";
import { consolePath } from "~/components/console/nav.ts";
import { TechInfo } from "~/components/console/tech-info.tsx";
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" };
/** 技术信息:路径、音轨、字幕、入库时间、下载任务、探测原始值 */
function VersionTech({ v }: { v: AssetVersion }) {
return (
<TechInfo
className="mt-1"
items={[
{ label: "文件", value: v.absolutePath ?? v.relativePath ?? v.fileName, wide: true },
{ label: "音轨", value: audioText(v), mono: false },
{ label: "字幕", value: v.subtitleLanguages.map(langLabel).join("、") || null, mono: false },
{ label: "时长", value: duration(v.durationSec), mono: false },
{ label: "色深", value: v.bitDepth ? `${v.bitDepth} bit` : null, mono: false },
{ label: "入库时间", value: v.organizedAt ? formatDateTime(v.organizedAt) : null, mono: false },
{
label: "下载任务",
mono: false,
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, mono: false },
]}
>
{v.probe ? <JsonView label="探测原始值" value={v.probe} defaultOpen={false} maxHeight="16rem" /> : null}
</TechInfo>
);
}
/** 一个版本:规格一行 + 技术信息折叠 */
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>
);
}