diff --git a/apps/web/app/api/library.ts b/apps/web/app/api/library.ts index 4a6bdea4..c4899ffe 100644 --- a/apps/web/app/api/library.ts +++ b/apps/web/app/api/library.ts @@ -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(`/library/${id}/scrape`, v), "/library/{id}/scrape"), + onSuccess: () => void qc.invalidateQueries({ queryKey: libraryKeys.detail(id) }), }); } diff --git a/apps/web/app/components/files/dialogs.tsx b/apps/web/app/components/files/dialogs.tsx index 36a4ab1b..51c492a1 100644 --- a/apps/web/app/components/files/dialogs.tsx +++ b/apps/web/app/components/files/dialogs.tsx @@ -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(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[]; !o && onClose()}> - 刷新元数据{single ? ` · ${single.title}` : ` · ${entities.length} 个作品`} - 按字段刷新,锁定的字段和路径不变。即 MP 的「刮削」。 + + {write ? "刮削" : "刷新元数据"} + {single ? ` · ${single.title}` : ` · ${entities.length} 个作品`} + + + {write + ? "把 NFO 和图片写进媒体库目录,已有文件按设置只补缺失或全部覆盖。结果在作品详情的「刮削」里。" + : "按字段从 TMDB 刷新,锁定的字段和路径不变。不写媒体库目录里的文件。"} + {!single && !result ? (
    @@ -1034,13 +1052,18 @@ export function RefreshMetadataDialog({ items, onClose }: { items: FileEntry[];

    {s.title} - - 查看运行 + + {write ? "查看结果" : "查看运行"}

    -

    - {s.lockedFields.length ? `锁定字段:${s.lockedFields.join("、")}` : "没有锁定字段"} -

    + {write ? null : ( +

    + {s.lockedFields.length ? `锁定字段:${s.lockedFields.join("、")}` : "没有锁定字段"} +

    + )}
    ))} {result.skipped.map((s) => ( @@ -1064,7 +1087,7 @@ export function RefreshMetadataDialog({ items, onClose }: { items: FileEntry[]; {result ? null : ( )} diff --git a/apps/web/app/components/files/mock.ts b/apps/web/app/components/files/mock.ts index a4606000..0905dbb8 100644 --- a/apps/web/app/components/files/mock.ts +++ b/apps/web/app/components/files/mock.ts @@ -1123,8 +1123,9 @@ export async function mockFiles(path: string, arg: FetcherArg): Promise 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); diff --git a/apps/web/app/components/files/model.test.ts b/apps/web/app/components/files/model.test.ts index 310fc6d4..5bd1fe9d 100644 --- a/apps/web/app/components/files/model.test.ts +++ b/apps/web/app/components/files/model.test.ts @@ -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("被状态阻止的项灰显带原因", () => { diff --git a/apps/web/app/components/files/model.ts b/apps/web/app/components/files/model.ts index b647ef78..9b26aeb7 100644 --- a/apps/web/app/components/files/model.ts +++ b/apps/web/app/components/files/model.ts @@ -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): MenuModelItem[] { const multi = list.length > 1; @@ -391,6 +395,7 @@ export function menuModel(list: FileEntry[], root: Pick 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 = { move: "移动到…", copy: "复制到…", scrape: "刷新元数据", + writeNfo: "刮削", probe: "探测", copyPath: "复制路径…", openMedia: "打开作品", diff --git a/apps/web/app/components/library/scrape-panel.tsx b/apps/web/app/components/library/scrape-panel.tsx new file mode 100644 index 00000000..9231615d --- /dev/null +++ b/apps/web/app/components/library/scrape-panel.tsx @@ -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 = { + queued: { label: "排队中", tone: "neutral" }, + running: { label: "刮削中…", tone: "neutral" }, + succeeded: { label: "已完成", tone: "success" }, + partial: { label: "部分完成", tone: "warning" }, + failed: { label: "失败", tone: "destructive" }, +}; + +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: "背景图", + 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 ( +
  • + + + {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 === "failed" ? ( + {reasonLine(f.reason, "写入失败")} + ) : null} + {a.label} +
  • + ); +} + +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 ( + + + 覆盖重写 + + } + title="覆盖重写 NFO 和图片?" + description="媒体库目录里已有的 NFO 和图片会被替换,包括其他工具写的。视频和字幕不动。" + confirmLabel="覆盖重写" + onConfirm={() => run("overwrite")} + /> + + + } + > + {!s ? ( +

    还没刮削过。

    + ) : ( +
    +
    + {STATUS[s.status].label} + {s.status !== "queued" && s.status !== "running" ? ( + {counts(s) || "没有要写的文件"} + ) : null} + {s.mode === "overwrite" ? 覆盖已有文件 : null} +
    + {s.reason && s.status !== "succeeded" ? ( +

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

    + ) : null} + {shown.length ? ( +
      + {shown.map((f) => ( + + ))} +
    + ) : null} + {hidden > 0 ? ( +
    + +
    + ) : null} + +
    + )} +
    + ); +} diff --git a/apps/web/app/components/settings/library.tsx b/apps/web/app/components/settings/library.tsx index 6a11045c..aca6bd84 100644 --- a/apps/web/app/components/settings/library.tsx +++ b/apps/web/app/components/settings/library.tsx @@ -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 (
    @@ -46,6 +97,7 @@ export function LibrarySection() { {(s) => } + {(s) => }
    ); diff --git a/apps/web/app/components/tasks/labels.ts b/apps/web/app/components/tasks/labels.ts index a4abf435..71e8ad25 100644 --- a/apps/web/app/components/tasks/labels.ts +++ b/apps/web/app/components/tasks/labels.ts @@ -45,6 +45,7 @@ export const WORKFLOW_LABEL: Record = { evaluateAcquisition: "评估候选", submitDownload: "提交下载", verifyAndImport: "核验入库", + scrapeMedia: "刮削", refreshMetadata: "刷新元数据", reconcileLibrary: "文件核对", cleanupDownloadCache: "清理缓存", diff --git a/apps/web/app/routes/console/files.tsx b/apps/web/app/routes/console/files.tsx index 681555d7..420e4ea3 100644 --- a/apps/web/app/routes/console/files.tsx +++ b/apps/web/app/routes/console/files.tsx @@ -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 = { 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() { 刷新元数据 ) : null} + {detail.asset ? ( + + ) : null}