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"; 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-2 起与文件管理「刮削」、入库后自动刮削同一实现(对作品目录就地刮削)。 export const SCRAPE_STATUS: Record = { queued: { label: "排队中", tone: "neutral" }, running: { label: "刮削中…", tone: "neutral" }, succeeded: { label: "已完成", tone: "success" }, partial: { label: "部分完成", tone: "warning" }, failed: { label: "失败", tone: "destructive" }, skipped: { label: "未刮削", tone: "neutral" }, }; 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: "背景图", clearlogo: "标志", season_poster: "季海报", episode_thumb: "剧照", }; const SOURCE: Record = { library: "已整理的作品", auto: "按路径识别", chosen: "手动指定" }; export function scrapeCounts(s: Pick): 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 !== "written" && f.reason && !QUIET_SKIPS.has(f.reason) ? ( {reasonLine(f.reason, "写入失败")} ) : null} {a.label}
  • ); } /** 不必逐条列出的跳过:已存在(不是本工具写的 / 只补缺失)、内容没变 */ const QUIET_SKIPS = new Set(["scrape_exists", "scrape_unchanged"]); /** 预期内的跳过(已存在、没变、被人改过);其余跳过(缺元数据、认不出集号)让记录算部分完成 */ export const EXPECTED_SCRAPE_SKIPS = new Set([...QUIET_SKIPS, "scrape_user_modified"]); /** 一条刮削记录:状态、目录与识别结果、计数、逐文件结果(缺省不列"已存在"跳过的) */ 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" || !QUIET_SKIPS.has(f.reason ?? "")); const shown = showAll ? files : important.slice(0, 12); const hidden = files.length - shown.length; const done = s.status !== "queued" && s.status !== "running"; return (
    {SCRAPE_STATUS[s.status].label} {showPath ? {s.path || "目录根"} : null} {done && (files.length || s.status === "succeeded") ? ( {scrapeCounts(s) || "没有要写的文件"} ) : null} {s.mode === "overwrite" ? 覆盖已有文件 : null}
    {s.match ? (

    {s.match.title} {s.match.year ? ` (${s.match.year})` : ""} · {s.match.kind === "movie" ? "电影" : "剧集"} ·{" "} {SOURCE[s.match.source] ?? s.match.source} {s.match.mediaEntityId ? "" : " · 不在媒体库"}

    ) : null} {s.reason && s.status !== "succeeded" ? (

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

    ) : null} {shown.length ? (
      {shown.map((f) => ( ))}
    ) : null} {hidden > 0 ? (
    ) : null}
    ); } export function ScrapePanel({ detail }: { detail: LibraryDetail }) { const s = detail.scrape ?? null; const scrape = useScrapeEntity(detail.entity.id); 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; }, ); return ( 覆盖重写 } title="覆盖重写 NFO 和图片?" description="作品目录里已有的 NFO 和图片会被替换,包括其他工具写的。视频和字幕不动。" confirmLabel="覆盖重写" onConfirm={() => run("overwrite")} /> ) : null } > {!s ? (

    还没刮削过。

    ) : (
    )}
    ); }