169 lines
6.8 KiB
TypeScript
169 lines
6.8 KiB
TypeScript
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>
|
||
);
|
||
}
|