生产 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]>
330 lines
12 KiB
TypeScript
330 lines
12 KiB
TypeScript
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>
|
||
);
|
||
}
|