feat(web): 文件管理「刮削…」对任意目录可用,候选选择与刮削结果(SCR-2)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
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) },
|
||||
]}
|
||||
|
||||
Reference in new issue
Block a user