feat(web): 文件管理「刮削…」对任意目录可用,候选选择与刮削结果(SCR-2)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-01 00:08:17 +08:00
1 parent 87bf33b42b
commit d8ae5e179c
8 files changed
+426 -126

No files matched your search

@@ -1,4 +1,4 @@
import type { LibraryDetail, LibraryScrapeState, ScrapeFileResult } from "@umt/contracts";
import type { LibraryDetail, ScrapeFileResult, ScrapeRecord } from "@umt/contracts";
import { FileText, Image as ImageIcon, RefreshCw } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
@@ -12,10 +12,10 @@ import { reasonCode, reasonLine } from "~/lib/reasons.ts";
import { formatDateTime } from "~/lib/utils.ts";
import { Panel } from "./parts.tsx";
// SCR-1 刮削状态:最近一次往媒体库目录写的 NFO 与图片(逐文件结果)+「重新刮削」「覆盖重写」。
// 与入库后自动刮削、文件管理「刮削」同一个后台任务。
// 刮削状态:作品最近一次刮削记录(目录、识别结果、逐文件结果)+「重新刮削」「覆盖重写」。
// SCR-2 起与文件管理「刮削」、入库后自动刮削同一实现(对作品目录就地刮削)。
const STATUS: Record<LibraryScrapeState["status"], { label: string; tone: StatusTone }> = {
export const SCRAPE_STATUS: Record<ScrapeRecord["status"], { label: string; tone: StatusTone }> = {
queued: { label: "排队中", tone: "neutral" },
running: { label: "刮削中…", tone: "neutral" },
succeeded: { label: "已完成", tone: "success" },
@@ -25,7 +25,7 @@ const STATUS: Record<LibraryScrapeState["status"], { label: string; tone: Status
const ACTION: Record<ScrapeFileResult["action"], { label: string; tone: StatusTone }> = {
written: { label: "已写入", tone: "success" },
skipped: { label: "已存在", tone: "neutral" },
skipped: { label: "跳过", tone: "neutral" },
failed: { label: "失败", tone: "destructive" },
};
@@ -40,10 +40,12 @@ const ROLE: Record<string, string> = {
episode_thumb: "剧照",
};
function counts(s: LibraryScrapeState): string {
const SOURCE: Record<string, string> = { library: "已入库的作品", auto: "按路径识别", chosen: "手动指定" };
export function scrapeCounts(s: Pick<ScrapeRecord, "written" | "skipped" | "failed">): string {
return [
s.written ? `写入 ${s.written} 个文件` : null,
s.skipped ? `跳过 ${s.skipped} 个已有文件` : null,
s.skipped ? `跳过 ${s.skipped} 个` : null,
s.failed ? `${s.failed} 个失败` : null,
]
.filter(Boolean)
@@ -68,18 +70,66 @@ function FileRow({ f }: { f: ScrapeFileResult }) {
)}
</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>
{f.action !== "written" && f.reason && f.reason !== "scrape_exists" ? (
<span className={f.action === "failed" ? "text-meta text-destructive" : "text-meta text-muted-foreground"}>
{reasonLine(f.reason, "写入失败")}
</span>
) : null}
<StatusBadge tone={a.tone}>{a.label}</StatusBadge>
</li>
);
}
/** 一条刮削记录:状态、目录与识别结果、计数、逐文件结果(缺省不列"已存在"跳过的) */
export function ScrapeRecordView({ record: s, showPath = false }: { record: ScrapeRecord; showPath?: boolean }) {
const [showAll, setShowAll] = useState(false);
const files = s.files;
const important = files.filter((f) => f.action !== "skipped" || f.reason !== "scrape_exists");
const shown = showAll ? files : important.slice(0, 12);
const hidden = files.length - shown.length;
const done = s.status !== "queued" && s.status !== "running";
return (
<div className="grid gap-2">
<div className="flex flex-wrap items-center gap-2">
<StatusBadge tone={SCRAPE_STATUS[s.status].tone}>{SCRAPE_STATUS[s.status].label}</StatusBadge>
{showPath ? <span className="min-w-0 text-sm break-all text-foreground">{s.path || "目录根"}</span> : null}
{done && (files.length || s.status === "succeeded") ? (
<span className="text-sm text-muted-foreground">{scrapeCounts(s) || "没有要写的文件"}</span>
) : null}
{s.mode === "overwrite" ? <span className="text-meta text-muted-foreground">覆盖已有文件</span> : null}
</div>
{s.match ? (
<p className="text-meta text-muted-foreground">
{s.match.title}
{s.match.year ? ` (${s.match.year})` : ""} · {s.match.kind === "movie" ? "电影" : "剧集"} ·{" "}
{SOURCE[s.match.source] ?? s.match.source}
{s.match.mediaEntityId ? "" : " · 不在媒体库"}
</p>
) : null}
{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}
</div>
);
}
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";
@@ -91,10 +141,6 @@ export function ScrapePanel({ detail }: { detail: LibraryDetail }) {
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
@@ -102,61 +148,42 @@ export function ScrapePanel({ detail }: { detail: LibraryDetail }) {
description={
s?.finishedAt
? `上次刮削 ${formatDateTime(s.finishedAt)} · ${s.trigger === "auto" ? "入库后自动" : "手动"}`
: "NFO 和图片写进媒体库目录,Emby、Jellyfin、Kodi 直接读取。"
: "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>
</>
hasFiles ? (
<>
<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>
</>
) : null
}
>
{!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}
<ScrapeRecordView record={s} showPath />
<TechInfo
items={[
{ label: "覆盖方式", value: s.mode === "overwrite" ? "全部覆盖" : "只补缺失" },
{ label: "目录", value: `${s.root}:${s.path}`, mono: true },
s.match?.externalId ? { label: "TMDB", value: s.match.externalId, mono: true } : null,
s.taskId ? { label: "任务", value: s.taskId, mono: true } : null,
s.recordId ? { label: "整理记录", value: s.recordId, mono: true } : null,
s.organizationRecordId ? { label: "整理记录", value: s.organizationRecordId, mono: true } : null,
s.reason ? { label: "原因码", value: reasonCode(s.reason) ?? s.reason, mono: true } : null,
{ label: "提交于", value: formatDateTime(s.requestedAt) },
]}