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 = { queued: { label: "排队中", tone: "neutral" }, running: { label: "刮削中…", tone: "neutral" }, succeeded: { label: "已完成", tone: "success" }, partial: { label: "部分完成", tone: "warning" }, failed: { label: "失败", tone: "destructive" }, }; const ACTION: Record = { written: { label: "已写入", tone: "success" }, skipped: { label: "已存在", tone: "neutral" }, failed: { label: "失败", tone: "destructive" }, }; const ROLE: Record = { 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 (
  • {f.path ? ( <> {slash >= 0 ? {f.path.slice(0, slash + 1)} : null} {f.path.slice(slash + 1)} ) : ( (ROLE[f.role] ?? f.role) )} {ROLE[f.role] ?? f.role} {f.action === "failed" ? ( {reasonLine(f.reason, "写入失败")} ) : null} {a.label}
  • ); } 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 ( 覆盖重写 } title="覆盖重写 NFO 和图片?" description="媒体库目录里已有的 NFO 和图片会被替换,包括其他工具写的。视频和字幕不动。" confirmLabel="覆盖重写" onConfirm={() => run("overwrite")} /> } > {!s ? (

    还没刮削过。

    ) : (
    {STATUS[s.status].label} {s.status !== "queued" && s.status !== "running" ? ( {counts(s) || "没有要写的文件"} ) : null} {s.mode === "overwrite" ? 覆盖已有文件 : null}
    {s.reason && s.status !== "succeeded" ? (

    {reasonLine(s.reason, "刮削失败")}

    ) : null} {shown.length ? (
      {shown.map((f) => ( ))}
    ) : null} {hidden > 0 ? (
    ) : null}
    )}
    ); }