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 = { REMUX: "Remux", "BluRay-Disc": "原盘", BluRay: "蓝光压制", }; const CODEC_LABEL: Record = { "H.265": "HEVC" }; const LANG_LABEL: Record = { "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 = { 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 ? ( 查看下载任务 ) : null, }, { label: "规格来源", value: v.specFrom.map((f) => SPEC_FROM[f] ?? f).join(" + ") || null }, ].filter((it) => it.value !== null && it.value !== undefined && it.value !== ""); return (
技术信息
{v.probe ? (
探测原始值
              {JSON.stringify(v.probe, null, 2)}
            
) : null}
); } /** 一个版本:规格一行 + 技术信息折叠 */ 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 (

{parts.length ? ( parts.map((p, i) => ( {i > 0 ? · : null} {p} )) ) : ( {v.fileName} )} {best ? 符合收录版本 : null} {missing ? 文件不在原位置 : null} {aside}

); } /** 一集的版本:一个时直接一行;多个时先显示达标(或第一个)版本,「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 ? (

可升级 {upgrade.reasons.length ? :{upgrade.reasons.join(";")} : null}

) : null; if (versions.length === 1) return (
{note}
); const rest = versions.filter((v) => v !== lead); const tagBest = match?.state === "satisfied"; const toggle = ( ); return (
{note} {open ? (
    {rest.map((v) => (
  • ))}
) : null}
); } /** 电影的入库版本面板:每个文件一行,附与收录版本的比对 */ 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 (
    {versions.map((v) => (
  • 1 && match?.state === "satisfied" && v.assetId === match.assetId} />
  • ))}
); } /** 剧集里没对上具体集数的文件 */ export function UnplacedVersions({ versions }: { versions: AssetVersion[] }) { const [open, setOpen] = useState(false); if (!versions.length) return null; return (
{open ? (
    {versions.map((v) => (
  • ))}
) : null}
); }