feat(web): 刮削设置分区、作品详情刮削状态与文件管理「刮削」(SCR-1)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-30 22:36:14 +08:00
1 parent f38eca419e
commit df033130f5
10 files changed
+313 -23

No files matched your search

+17
View File
@@ -7,9 +7,11 @@ import {
LibraryItem,
type LibraryListQuery,
LibraryRoot,
LibraryScrapeResult,
MediaEntity,
MediaEntityDetail,
type MediaKind,
type ScrapeMode,
type UnitMetadataField,
} from "@umt/contracts";
import type { z } from "zod";
@@ -72,6 +74,21 @@ export function useLibraryDetail(id: string | undefined) {
queryKey: libraryKeys.detail(id ?? ""),
enabled: !!id,
queryFn: ({ signal }) => getParsed(LibraryDetail, `/library/${id}`, undefined, signal),
// 刮削排队 / 进行中时每 3 秒取一次,拿到结果就停
refetchInterval: (q) => {
const s = q.state.data?.scrape?.status;
return s === "queued" || s === "running" ? 3_000 : false;
},
});
}
/** SCR-1 刮削:写 NFO 与图片到媒体库目录(后台任务);mode 缺省按设置 */
export function useScrapeEntity(id: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (v: { mode?: ScrapeMode } = {}) =>
parseOrThrow(LibraryScrapeResult, await api.post<unknown>(`/library/${id}/scrape`, v), "/library/{id}/scrape"),
onSuccess: () => void qc.invalidateQueries({ queryKey: libraryKeys.detail(id) }),
});
}
+36 -13
View File
@@ -986,13 +986,21 @@ export function CopyPathDialog({ root, entry, onClose }: { root: FileRoot; entry
);
}
// ─── 刷新元数据(MP 的"刮削") ─────────────────────────────────────
// ─── 刷新元数据 / 刮削 ─────────────────────────────────────
/**
* 刷新元数据(§7.2):只对已登记项;多选时按作品去重(POST /files/scrape-batch)。
* NFO / 图片投影还没实现,不显示这两项。
* write = 刮削(SCR-1):同一接口 write=true,按作品排 scrapeMedia,把 NFO 与图片写进媒体库目录(覆盖方式按设置)。
*/
export function RefreshMetadataDialog({ items, onClose }: { items: FileEntry[]; onClose: () => void }) {
export function RefreshMetadataDialog({
items,
write = false,
onClose,
}: {
items: FileEntry[];
write?: boolean | undefined;
onClose: () => void;
}) {
const batch = useScrapeBatch();
const key = useMemo(() => newIdempotencyKey(), []);
const [result, setResult] = useState<FilesScrapeBatchResult | null>(null);
@@ -1000,12 +1008,15 @@ export function RefreshMetadataDialog({ items, onClose }: { items: FileEntry[];
const single = entities.length === 1 ? entities[0] : null;
const run = () =>
batch.mutate(
{ idempotencyKey: key, mediaEntityIds: entities.map((a) => a?.mediaEntityId as string) },
{ idempotencyKey: key, mediaEntityIds: entities.map((a) => a?.mediaEntityId as string), write },
{
onSuccess: (r) => {
setResult(r);
const skipped = r.skipped.length ? `;${r.skipped.length} 个未提交` : "";
(r.skipped.length ? toast.warning : toast.success)(
`已提交 ${r.submitted.length} 个作品,锁定字段不变${r.skipped.length ? `;${r.skipped.length} 个未提交` : ""}`,
write
? `已提交 ${r.submitted.length} 个作品的刮削${skipped}`
: `已提交 ${r.submitted.length} 个作品,锁定字段不变${skipped}`,
);
},
onError: (e) => toast.error(filesErrorText(e, "提交失败")),
@@ -1015,8 +1026,15 @@ export function RefreshMetadataDialog({ items, onClose }: { items: FileEntry[];
<Dialog open onOpenChange={(o) => !o && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>刷新元数据{single ? ` · ${single.title}` : ` · ${entities.length} 个作品`}</DialogTitle>
<DialogDescription>按字段刷新,锁定的字段和路径不变。即 MP 的「刮削」。</DialogDescription>
<DialogTitle>
{write ? "刮削" : "刷新元数据"}
{single ? ` · ${single.title}` : ` · ${entities.length} 个作品`}
</DialogTitle>
<DialogDescription>
{write
? "把 NFO 和图片写进媒体库目录,已有文件按设置只补缺失或全部覆盖。结果在作品详情的「刮削」里。"
: "按字段从 TMDB 刷新,锁定的字段和路径不变。不写媒体库目录里的文件。"}
</DialogDescription>
</DialogHeader>
{!single && !result ? (
<ul className="max-h-40 space-y-0.5 overflow-auto text-caption">
@@ -1034,13 +1052,18 @@ export function RefreshMetadataDialog({ items, onClose }: { items: FileEntry[];
<div key={s.mediaEntityId} className="rounded-lg border border-border/60 px-3 py-2">
<p className="flex items-center justify-between gap-2">
<span className="truncate">{s.title}</span>
<Link className="shrink-0 text-meta underline" to={`/console/engine/${s.taskId}`}>
查看运行
<Link
className="shrink-0 text-meta underline"
to={write ? `/console/library/${s.mediaEntityId}` : `/console/engine/${s.taskId}`}
>
{write ? "查看结果" : "查看运行"}
</Link>
</p>
<p className="text-meta text-muted-foreground">
{s.lockedFields.length ? `锁定字段:${s.lockedFields.join("、")}` : "没有锁定字段"}
</p>
{write ? null : (
<p className="text-meta text-muted-foreground">
{s.lockedFields.length ? `锁定字段:${s.lockedFields.join("、")}` : "没有锁定字段"}
</p>
)}
</div>
))}
{result.skipped.map((s) => (
@@ -1064,7 +1087,7 @@ export function RefreshMetadataDialog({ items, onClose }: { items: FileEntry[];
{result ? null : (
<Button onClick={run} disabled={batch.isPending || !entities.length}>
{batch.isPending ? <LoaderCircle className="animate-spin" /> : <RefreshCw />}
刷新
{write ? "刮削" : "刷新"}
</Button>
)}
</DialogFooter>
+3 -2
View File
@@ -1123,8 +1123,9 @@ export async function mockFiles(path: string, arg: FetcherArg): Promise<unknown>
mediaEntityId: b.mediaEntityId,
taskId: `run-${++seq}`,
lockedFields: ["title"],
nfo: b.writeNfo ? "not_implemented" : "not_requested",
images: b.images ? "not_implemented" : "not_requested",
nfo: b.writeNfo ? "queued" : "not_requested",
images: b.images ? "queued" : "not_requested",
scrapeTaskId: b.writeNfo || b.images ? `run-${++seq}` : null,
};
case "/files/probe":
await delay(600);
+2 -2
View File
@@ -196,14 +196,14 @@ describe("v2 菜单与副行", () => {
it("单项菜单:未登记隐藏刷新元数据/打开作品/整理记录,顺序固定", () => {
expect(keys(menuModel([video], lib))).toBe("open | organize identify rename move copy | probe | copyPath | delete");
expect(keys(menuModel([registered], lib))).toBe(
"open | identify rename move copy | scrape probe | copyPath openMedia records | delete",
"open | identify rename move copy | scrape writeNfo probe | copyPath openMedia records | delete",
);
expect(opApplies("organize", registered)).toBe(false);
});
it("多选菜单:整理 n 项在前,删除在后;全部已登记才有刷新元数据", () => {
expect(keys(menuModel([video, video], lib))).toBe("organize identify move copy probe | delete");
expect(keys(menuModel([registered, registered], lib))).toContain("scrape");
expect(keys(menuModel([registered, registered], lib))).toContain("scrape writeNfo");
});
it("被状态阻止的项灰显带原因", () => {
+12 -3
View File
@@ -64,6 +64,7 @@ export type FileOp =
| "organize"
| "identify"
| "scrape"
| "writeNfo"
| "probe"
| "delete"
| "copyPath"
@@ -97,6 +98,7 @@ export function opDisabledReason(op: FileOp, e: FileEntry, root: Pick<FileRoot,
if (e.type === "file" && e.fileKind !== "video") return "只有视频或目录能整理";
return null;
case "scrape":
case "writeNfo":
return e.asset ? null : "未登记,先整理入库";
case "probe":
return e.type === "file" && e.fileKind === "video" ? null : "只能探测视频文件";
@@ -334,6 +336,7 @@ export type MenuKey =
| "move"
| "copy"
| "scrape"
| "writeNfo"
| "probe"
| "copyPath"
| "openMedia"
@@ -353,6 +356,7 @@ export function opApplies(op: MenuKey, e: FileEntry): boolean {
case "identify":
return isVideo(e) || e.type === "dir";
case "scrape":
case "writeNfo":
case "openMedia":
return !!e.asset;
case "records":
@@ -366,8 +370,8 @@ export function opApplies(op: MenuKey, e: FileEntry): boolean {
/**
* 单项 / 多选菜单的项与顺序(规格 §5.2):
* 单项:打开 ─ 整理 识别 重命名 移动 复制 ─ 刷新元数据 探测 ─ 复制路径 打开作品 查看整理记录 ─ 删除
* 多选:整理 n 项 识别 移动 复制 探测 刷新元数据 ─ 删除 n 项
* 单项:打开 ─ 整理 识别 重命名 移动 复制 ─ 刷新元数据 刮削 探测 ─ 复制路径 打开作品 查看整理记录 ─ 删除
* 多选:整理 n 项 识别 移动 复制 探测 刷新元数据 刮削 ─ 删除 n 项
*/
export function menuModel(list: FileEntry[], root: Pick<FileRoot, "kind" | "writable">): MenuModelItem[] {
const multi = list.length > 1;
@@ -391,6 +395,7 @@ export function menuModel(list: FileEntry[], root: Pick<FileRoot, "kind" | "writ
add("copy", e.escape ? "链接指向登记目录之外" : null);
sep();
if (opApplies("scrape", e)) add("scrape", why("scrape"));
if (opApplies("writeNfo", e)) add("writeNfo", why("writeNfo"));
if (opApplies("probe", e)) add("probe", null);
sep();
add("copyPath", null);
@@ -412,7 +417,10 @@ export function menuModel(list: FileEntry[], root: Pick<FileRoot, "kind" | "writ
add("move", why("move"));
add("copy", list.some((x) => x.escape) ? "含指向登记目录之外的链接" : null);
if (any("probe")) add("probe", null);
if (list.every((x) => !!x.asset)) add("scrape", null);
if (list.every((x) => !!x.asset)) {
add("scrape", null);
add("writeNfo", null);
}
sep();
add("delete", why("delete"));
return out;
@@ -433,6 +441,7 @@ export const OP_LABEL: Record<MenuKey, string> = {
move: "移动到…",
copy: "复制到…",
scrape: "刷新元数据",
writeNfo: "刮削",
probe: "探测",
copyPath: "复制路径…",
openMedia: "打开作品",
@@ -0,0 +1,168 @@
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>
);
}
+53 -1
View File
@@ -4,7 +4,7 @@ import { str } from "./resource-section.tsx";
import { SettingsBlock } from "./settings-block.tsx";
import { type CardSpec, SettingsCard, SettingsGate } from "./settings-card.tsx";
// 媒体库与目录:库根(含哨兵状态)+ 默认整理方式 + 命名模板
// 媒体库与目录:库根(含哨兵状态)+ 默认整理方式 + 刮削 + 命名模板
const ORGANIZE_CARD: CardSpec = {
anchor: "organize",
@@ -39,6 +39,57 @@ const ORGANIZE_CARD: CardSpec = {
}),
};
// SCR-1 刮削:入库后写 NFO 与图片到媒体库目录(Emby / Jellyfin / Kodi 读取)
const SCRAPE_CARD: CardSpec = {
anchor: "scrape",
title: "刮削",
description: "把作品信息写成 NFO,连同海报、背景图放进媒体库目录,Emby、Jellyfin、Kodi 直接读取。",
fields: [
{
name: "organize.scrapeAfterImport",
label: "入库后自动刮削",
kind: "switch",
hint: "关闭时可在作品详情或文件管理里手动刮削",
},
{
name: "organize.scrapeMode",
label: "已有文件",
kind: "select",
options: [
{ value: "missing", label: "只补缺失" },
{ value: "overwrite", label: "全部覆盖" },
],
hint: "全部覆盖会替换目录里已有的 NFO 和图片,包括其他工具写的",
},
{
name: "organize.scrapeImages",
label: "下载图片",
kind: "switch",
hint: "海报、背景图、季海报",
},
{
name: "organize.scrapeEpisodeThumbs",
label: "下载剧照",
kind: "switch",
hint: "每集一张,存为「视频名-thumb.jpg」;关闭下载图片时不生效",
},
],
fromSettings: (s) => ({
"organize.scrapeAfterImport": s.organize.scrapeAfterImport,
"organize.scrapeMode": s.organize.scrapeMode,
"organize.scrapeImages": s.organize.scrapeImages,
"organize.scrapeEpisodeThumbs": s.organize.scrapeEpisodeThumbs,
}),
toPatch: (v) => ({
organize: {
scrapeAfterImport: v["organize.scrapeAfterImport"] === true,
scrapeMode: str(v["organize.scrapeMode"]) === "overwrite" ? "overwrite" : "missing",
scrapeImages: v["organize.scrapeImages"] === true,
scrapeEpisodeThumbs: v["organize.scrapeEpisodeThumbs"] === true,
},
}),
};
export function LibrarySection() {
return (
<div className="grid grid-cols-1 gap-8">
@@ -46,6 +97,7 @@ export function LibrarySection() {
<LibraryRootsTable />
</SettingsBlock>
<SettingsGate>{(s) => <SettingsCard section="library" spec={ORGANIZE_CARD} settings={s} />}</SettingsGate>
<SettingsGate>{(s) => <SettingsCard section="library" spec={SCRAPE_CARD} settings={s} />}</SettingsGate>
<NamingSection />
</div>
);
+1
View File
@@ -45,6 +45,7 @@ export const WORKFLOW_LABEL: Record<string, string> = {
evaluateAcquisition: "评估候选",
submitDownload: "提交下载",
verifyAndImport: "核验入库",
scrapeMedia: "刮削",
refreshMetadata: "刷新元数据",
reconcileLibrary: "文件核对",
cleanupDownloadCache: "清理缓存",
+19 -2
View File
@@ -9,6 +9,7 @@ import {
Download,
Eye,
EyeOff,
FileDown,
FileStack,
FolderInput,
FolderOpen,
@@ -108,7 +109,7 @@ type Dialog =
| { kind: "delete"; items: FileRef[] }
| { kind: "mkdir" }
| { kind: "copyPath"; entry: FileEntry }
| { kind: "refresh"; items: FileEntry[] }
| { kind: "refresh"; items: FileEntry[]; write?: boolean }
| { kind: "wizard"; source: OrganizeSource }
| { kind: "shortcuts" };
@@ -131,6 +132,7 @@ const MENU_ICON: Record<MenuKey, typeof FolderOpen> = {
move: FolderInput,
copy: Copy,
scrape: RefreshCw,
writeNfo: FileDown,
probe: ScanSearch,
copyPath: ClipboardCopy,
openMedia: Library,
@@ -456,6 +458,8 @@ export default function FilesPage() {
return setDialog({ kind: "transfer", items: refs(list), mode: op });
case "scrape":
return setDialog({ kind: "refresh", items: list });
case "writeNfo":
return setDialog({ kind: "refresh", items: list, write: true });
case "probe":
return doProbe(list);
case "copyPath":
@@ -703,6 +707,13 @@ export default function FilesPage() {
disabled: selected.every((e) => e.asset) ? null : "只对已入库的文件",
onSelect: () => act("scrape", selected),
});
items.push({
key: "writeNfo",
label: "刮削",
icon: FileDown,
disabled: selected.every((e) => e.asset) ? null : "只对已入库的文件",
onSelect: () => act("writeNfo", selected),
});
items.push({ key: "copyPath", label: "复制路径", icon: ClipboardCopy, onSelect: () => void copyPaths(selected) });
return items;
};
@@ -1140,6 +1151,12 @@ export default function FilesPage() {
刷新元数据
</Button>
) : null}
{detail.asset ? (
<Button size="sm" variant="outline" onClick={() => act("writeNfo", [detail])}>
<FileDown />
刮削
</Button>
) : null}
<Button
variant="outline"
size="sm"
@@ -1207,7 +1224,7 @@ export default function FilesPage() {
<CopyPathDialog root={root} entry={dialog.entry} onClose={() => setDialog(null)} />
) : null}
{dialog?.kind === "refresh" ? (
<RefreshMetadataDialog items={dialog.items} onClose={() => setDialog(null)} />
<RefreshMetadataDialog items={dialog.items} write={dialog.write} onClose={() => setDialog(null)} />
) : null}
{dialog?.kind === "wizard" ? <OrganizeWizard source={dialog.source} onClose={() => setDialog(null)} /> : null}
<ShortcutsDialog open={dialog?.kind === "shortcuts"} onOpenChange={(o) => !o && setDialog(null)} />
@@ -63,6 +63,7 @@ import {
VERIFY_STATUS,
} from "~/components/library/labels.ts";
import { Chip, ChipList, Panel, ProgressLine, posterPathOf, relTime, Tone } from "~/components/library/parts.tsx";
import { ScrapePanel } from "~/components/library/scrape-panel.tsx";
import { SlotCard } from "~/components/library/slot-card.tsx";
import { finalOf, UnitFieldDialog } from "~/components/library/unit-field-dialog.tsx";
import {
@@ -1185,6 +1186,7 @@ export default function LibraryDetailPage() {
<MetadataPanel detail={d} entity={entity.data} />
</div>
<PathsPanel detail={d} />
<ScrapePanel detail={d} />
{e.kind !== "movie" || (entity.data?.contentUnits.length ?? 0) > 0 ? (
<UnitsPanel entity={entity} detail={d} />
) : null}