Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
203 lines
8.6 KiB
TypeScript
203 lines
8.6 KiB
TypeScript
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<ScrapeRecord["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" },
|
||
skipped: { label: "未刮削", tone: "neutral" },
|
||
};
|
||
|
||
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: "背景图",
|
||
clearlogo: "标志",
|
||
season_poster: "季海报",
|
||
episode_thumb: "剧照",
|
||
};
|
||
|
||
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.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 !== "written" && f.reason && !QUIET_SKIPS.has(f.reason) ? (
|
||
<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>
|
||
);
|
||
}
|
||
|
||
/** 不必逐条列出的跳过:已存在(不是本工具写的 / 只补缺失)、内容没变 */
|
||
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 (
|
||
<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 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 (
|
||
<Panel
|
||
title="刮削"
|
||
description={
|
||
s?.finishedAt
|
||
? `上次刮削 ${formatDateTime(s.finishedAt)} · ${s.trigger === "auto" ? "整理后自动" : "手动"}`
|
||
: "NFO 和图片写进作品目录,Emby、Jellyfin、Kodi 直接读取。"
|
||
}
|
||
actions={
|
||
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">
|
||
<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.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) },
|
||
]}
|
||
/>
|
||
</div>
|
||
)}
|
||
</Panel>
|
||
);
|
||
}
|