Files
UHD_Media_Tool/apps/web/app/components/library/versions.tsx
T
rootandClaude Opus 5.5 ceb66f8c77 feat: v0.3.8–v0.3.14 工作区汇总(依赖升级、整理来源、手动入库、原地入库等)
生产 v0.3.8~v0.3.13 均由 8013196 加工作区修改构建部署,此前未提交;本提交补齐源码,
并含未部署的 v0.3.14 原地入库。中间各版本源码状态未保存,无法逐版拆分,详见
docs/plan/17-full-upgrade.md 与 docs/plan/18-organize-release.md。

- v0.3.8 全面升级:Deno workspace 依赖、Router 8 / Vite 8、camofox 1.18.0 / Node 24、
  PostgreSQL 18.6、备份客户端 18.6、演示 qB 5.2.4
- v0.3.9 媒体整理:分类规则、整理来源与目录快照(0059)、文件管理整理入口
- v0.3.10 手动下载与入库:独立手动下载、详情页资源下载、TMDB / Local 元数据兜底
  (metadata.resolve_organize)、正常多版本 / 一次性洗版(0060)
- v0.3.11 搜索详情与桌面黑边修复
- v0.3.12 列表分页、搜索历史、首页概况
- v0.3.13 统一整理弹窗与队列
- v0.3.14 原地入库(0061,未部署)

同批审计修复:
- 刮削入队:确认原记录存在后才删重放产生的新记录;写回 run 关联失败时保持 queued,
  由处理器补关联(对齐 MP 先登记、只在入队失败时记失败)
- 识别升级链只在结果更确定时采用,不再用 no_match 顶替原有歧义候选
- bluray_dir 资产以原盘目录本身为刮削根(MP 同做法)
- metadata.resolve_organize 登记到入库排障 Skill 与能力地图
- db/src/types.ts 按迁移重新生成;contracts 整理动作枚举顺序与数据库对齐
- 测试:原地入库用例改为直接查记录表;domain 测试模板补 version
- 前端:通知铃铛弹层与用户菜单统一样式;站点卡片共用卡头、底栏贴底、分页间距

依赖升级证据只提交摘要文件,registry-snapshot.json 移至 .local/(SHA-256 记于文档)。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-02 15:47:17 +08:00

330 lines
12 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,
matchGapTexts,
type SeasonVersions,
type UnitMatch,
type 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 { PaginatedList } from "~/components/console/paginated-list.tsx";
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 · 制作组 · 大小);季头一行汇总。
// POLISH-1:点整行展开,多个版本同级并列,选中的版本下方才显示路径、音轨、字幕、时长;
// 规格来源、探测原始值、比对原文放进 TechInfo;与收录版本的差距用 matchGapTexts 转成人话。
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" };
/** 与收录版本的差距 → 人话短句("可换成 2160p"、"缺 HDR 版本");原文留给技术信息 */
export function gapLine(match: UnitMatch | undefined): string | null {
if (match?.state !== "upgradeable") return null;
const texts = matchGapTexts(match.codes);
return texts.length ? texts.join(" · ") : "还有更好的版本可换";
}
/** 选中版本的详情:路径、音轨、字幕、时长;规格来源、探测原始值、比对原文进「技术信息」 */
function VersionDetail({ v, match }: { v: AssetVersion; match?: UnitMatch | undefined }) {
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,
},
].filter((it) => it.value !== null && it.value !== undefined && it.value !== "");
const ownMatch = match && match.assetId === v.assetId ? match : undefined;
return (
<div className="grid gap-2 pt-1 pb-2 pl-2">
<KvList items={items} />
<TechInfo
items={[
{ label: "文件 ID", value: v.assetId, wide: true },
{ label: "规格来源", value: v.specFrom.map((f) => SPEC_FROM[f] ?? f).join(" + ") || null },
{ label: "比对说明", value: ownMatch?.reasons.join(";") || null, wide: true },
{ label: "原因码", value: ownMatch?.codes.join(", ") || null, wide: true },
]}
>
{v.probe ? (
<pre className="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>
) : null}
</TechInfo>
</div>
);
}
/** 一个版本的规格一行:2160p · WEB-DL · HEVC · DV · FLUX · 13.2 GB + 状态 */
export function VersionSpecLine({ 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 (
<span className="flex min-w-0 flex-1 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}
</span>
);
}
const ROW_CLASS =
"flex w-full min-w-0 cursor-pointer items-center gap-2 rounded-lg px-2 py-1.5 text-left transition-colors duration-micro hover:bg-foreground/[0.05] focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:outline-none";
/**
* 版本列表(POLISH-1):每个版本一行同级并列,点一行选中并在它下方展开详情,再点收起。
* 电影面板、没对上集数的文件都用它;剧集每集先收成一行,见 EpisodeVersionList。
*/
export function VersionList({
versions,
match,
initial,
onCollapse,
compact = false,
}: {
versions: AssetVersion[];
match?: UnitMatch | undefined;
/** 初始选中的版本;缺省不选 */
initial?: string | null;
/** 收起选中版本时回调(剧集用来收回到一行) */
onCollapse?: () => void;
compact?: boolean;
}) {
const [selected, setSelected] = useState<string | null>(initial ?? null);
const tagBest = match?.state === "satisfied" && versions.length > 1;
return (
<PaginatedList
rows={versions}
as="ul"
label="入库文件版本"
resetKey={match?.assetId ?? "all"}
compact={compact}
className="space-y-0.5"
>
{(v) => {
const on = selected === v.assetId;
return (
<li key={v.assetId} className={cn("rounded-lg", on && "bg-foreground/[0.04]")}>
<button
type="button"
aria-expanded={on}
data-version-row
onClick={() => {
if (!on) setSelected(v.assetId);
else if (onCollapse) onCollapse();
else setSelected(null);
}}
className={ROW_CLASS}
>
<VersionSpecLine v={v} best={tagBest && v.assetId === match?.assetId} />
<ChevronDown
aria-hidden
className={cn(
"size-3.5 shrink-0 text-muted-foreground transition-transform duration-micro",
on && "rotate-180",
)}
/>
</button>
{on ? <VersionDetail v={v} match={match} /> : null}
</li>
);
}}
</PaginatedList>
);
}
/**
* 一集的版本:收起时一行(达标或最接近的版本 + "N 个版本"),点整行展开;
* 展开后每个版本一行同级并列,选中的版本下方显示文件路径、音轨、字幕等。
* 与收录版本的差距用人话写在下面("可换成 2160p · 缺 HDR 版本")。
*/
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 ordered = [lead, ...versions.filter((v) => v !== lead)];
const gap = gapLine(match);
return (
<div className={cn("-mx-2 mt-1 space-y-0.5", className)}>
{open ? (
<VersionList
compact
versions={ordered}
match={match}
initial={lead.assetId}
onCollapse={() => setOpen(false)}
/>
) : (
<button
type="button"
aria-expanded={false}
data-version-row
onClick={() => setOpen(true)}
className={ROW_CLASS}
>
<VersionSpecLine
v={lead}
aside={
versions.length > 1 ? (
<span className="text-meta whitespace-nowrap text-muted-foreground">
库里有 {versions.length} 个版本
</span>
) : null
}
/>
<ChevronDown aria-hidden className="size-3.5 shrink-0 text-muted-foreground" />
</button>
)}
{gap ? <p className="px-2 text-meta text-warning">{gap}</p> : 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?.state === "satisfied" ? `符合「${target?.name}」` : gapLine(match);
return (
<Panel title="入库版本" description={[summaryText(data.summary), state].filter(Boolean).join(" · ")}>
<div className="-mx-2">
<VersionList versions={versions} match={match} />
</div>
</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 ? (
<div className="-mx-2 mt-2">
<VersionList versions={versions} />
</div>
) : null}
</div>
);
}