feat(web): 刮削设置分区、作品详情刮削状态与文件管理「刮削」(SCR-1)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-30 22:36:14 +08:00
1 parent f38eca419e
commit df033130f5
10 files changed
+313 -23

No files matched your search

@@ -0,0 +1,168 @@
import type { LibraryDetail, LibraryScrapeState, ScrapeFileResult } from "@umt/contracts";
import { FileText, Image as ImageIcon, RefreshCw } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { useScrapeEntity } from "~/api/library.ts";
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx";
import { TechInfo } from "~/components/console/tech-info.tsx";
import { Button } from "~/components/ui/button.tsx";
import { errorMessage } from "~/lib/api.ts";
import { reasonCode, reasonLine } from "~/lib/reasons.ts";
import { formatDateTime } from "~/lib/utils.ts";
import { Panel } from "./parts.tsx";
// SCR-1 刮削状态:最近一次往媒体库目录写的 NFO 与图片(逐文件结果)+「重新刮削」「覆盖重写」。
// 与入库后自动刮削、文件管理「刮削」同一个后台任务。
const STATUS: Record<LibraryScrapeState["status"], { label: string; tone: StatusTone }> = {
queued: { label: "排队中", tone: "neutral" },
running: { label: "刮削中…", tone: "neutral" },
succeeded: { label: "已完成", tone: "success" },
partial: { label: "部分完成", tone: "warning" },
failed: { label: "失败", tone: "destructive" },
};
const ACTION: Record<ScrapeFileResult["action"], { label: string; tone: StatusTone }> = {
written: { label: "已写入", tone: "success" },
skipped: { label: "已存在", tone: "neutral" },
failed: { label: "失败", tone: "destructive" },
};
const ROLE: Record<string, string> = {
movie_nfo: "电影 NFO",
tvshow_nfo: "剧集 NFO",
season_nfo: "季 NFO",
episode_nfo: "集 NFO",
poster: "海报",
fanart: "背景图",
season_poster: "季海报",
episode_thumb: "剧照",
};
function counts(s: LibraryScrapeState): string {
return [
s.written ? `写入 ${s.written} 个文件` : null,
s.skipped ? `跳过 ${s.skipped} 个已有文件` : null,
s.failed ? `${s.failed} 个失败` : null,
]
.filter(Boolean)
.join(",");
}
function FileRow({ f }: { f: ScrapeFileResult }) {
const slash = f.path.lastIndexOf("/");
const Icon = f.kind === "nfo" ? FileText : ImageIcon;
const a = ACTION[f.action];
return (
<li className="flex flex-wrap items-center gap-x-3 gap-y-1 py-1.5">
<Icon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden />
<span className="min-w-0 flex-1 basis-56 text-sm break-all text-foreground">
{f.path ? (
<>
{slash >= 0 ? <span className="text-muted-foreground">{f.path.slice(0, slash + 1)}</span> : null}
{f.path.slice(slash + 1)}
</>
) : (
(ROLE[f.role] ?? f.role)
)}
</span>
<span className="text-meta text-muted-foreground">{ROLE[f.role] ?? f.role}</span>
{f.action === "failed" ? (
<span className="text-meta text-destructive">{reasonLine(f.reason, "写入失败")}</span>
) : null}
<StatusBadge tone={a.tone}>{a.label}</StatusBadge>
</li>
);
}
export function ScrapePanel({ detail }: { detail: LibraryDetail }) {
const s = detail.scrape ?? null;
const scrape = useScrapeEntity(detail.entity.id);
const [showAll, setShowAll] = useState(false);
const hasFiles = detail.assets.some((a) => a.asset.status === "active");
if (!s && !hasFiles) return null;
const busy = scrape.isPending || s?.status === "queued" || s?.status === "running";
const run = (mode?: "overwrite") =>
scrape.mutateAsync(mode ? { mode } : {}).then(
(r) => toast.success(r.deduplicated ? "已在排队" : "已提交刮削"),
(err) => {
toast.error(errorMessage(err, "刮削没有提交"));
throw err;
},
);
const files = s?.files ?? [];
const important = files.filter((f) => f.action !== "skipped");
const shown = showAll ? files : important.slice(0, 12);
const hidden = files.length - shown.length;
return (
<Panel
title="刮削"
description={
s?.finishedAt
? `上次刮削 ${formatDateTime(s.finishedAt)} · ${s.trigger === "auto" ? "入库后自动" : "手动"}`
: "NFO 和图片写进媒体库目录,Emby、Jellyfin、Kodi 直接读取。"
}
actions={
<>
<ConfirmDialog
trigger={
<Button variant="ghost" size="sm" disabled={busy}>
覆盖重写
</Button>
}
title="覆盖重写 NFO 和图片?"
description="媒体库目录里已有的 NFO 和图片会被替换,包括其他工具写的。视频和字幕不动。"
confirmLabel="覆盖重写"
onConfirm={() => run("overwrite")}
/>
<Button variant="outline" size="sm" disabled={busy} onClick={() => void run().catch(() => {})}>
<RefreshCw className={busy ? "animate-spin" : undefined} />
{s ? "重新刮削" : "刮削"}
</Button>
</>
}
>
{!s ? (
<p className="text-sm text-muted-foreground">还没刮削过。</p>
) : (
<div className="grid gap-3">
<div className="flex flex-wrap items-center gap-2">
<StatusBadge tone={STATUS[s.status].tone}>{STATUS[s.status].label}</StatusBadge>
{s.status !== "queued" && s.status !== "running" ? (
<span className="text-sm text-foreground">{counts(s) || "没有要写的文件"}</span>
) : null}
{s.mode === "overwrite" ? <span className="text-meta text-muted-foreground">覆盖已有文件</span> : null}
</div>
{s.reason && s.status !== "succeeded" ? (
<p className="text-sm text-muted-foreground">{reasonLine(s.reason, "刮削失败")}</p>
) : null}
{shown.length ? (
<ul className="divide-y divide-border/60">
{shown.map((f) => (
<FileRow key={`${f.path}:${f.role}`} f={f} />
))}
</ul>
) : null}
{hidden > 0 ? (
<div>
<Button variant="ghost" size="sm" onClick={() => setShowAll(true)}>
{important.length === 0 ? `查看 ${hidden} 个已有文件` : `显示全部 ${files.length} 个文件`}
</Button>
</div>
) : null}
<TechInfo
items={[
{ label: "覆盖方式", value: s.mode === "overwrite" ? "全部覆盖" : "只补缺失" },
s.taskId ? { label: "任务", value: s.taskId, mono: true } : null,
s.recordId ? { label: "整理记录", value: s.recordId, mono: true } : null,
s.reason ? { label: "原因码", value: reasonCode(s.reason) ?? s.reason, mono: true } : null,
{ label: "提交于", value: formatDateTime(s.requestedAt) },
]}
/>
</div>
)}
</Panel>
);
}