From d8ae5e179cd242a1603bf8e37956fdc0041db79c Mon Sep 17 00:00:00 2001 From: root Date: Thu, 1 Oct 2026 00:08:17 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20=E6=96=87=E4=BB=B6=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E3=80=8C=E5=88=AE=E5=89=8A=E2=80=A6=E3=80=8D=E5=AF=B9?= =?UTF-8?q?=E4=BB=BB=E6=84=8F=E7=9B=AE=E5=BD=95=E5=8F=AF=E7=94=A8,?= =?UTF-8?q?=E5=80=99=E9=80=89=E9=80=89=E6=8B=A9=E4=B8=8E=E5=88=AE=E5=89=8A?= =?UTF-8?q?=E7=BB=93=E6=9E=9C(SCR-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- apps/web/app/api/files.ts | 21 +- apps/web/app/api/library.ts | 2 +- apps/web/app/components/files/dialogs.tsx | 242 +++++++++++++++--- apps/web/app/components/files/mock.ts | 90 ++++++- apps/web/app/components/files/model.test.ts | 14 +- apps/web/app/components/files/model.ts | 17 +- .../app/components/library/scrape-panel.tsx | 141 +++++----- apps/web/app/routes/console/files.tsx | 25 +- 8 files changed, 426 insertions(+), 126 deletions(-) diff --git a/apps/web/app/api/files.ts b/apps/web/app/api/files.ts index b0b4f5b5..0156807e 100644 --- a/apps/web/app/api/files.ts +++ b/apps/web/app/api/files.ts @@ -26,6 +26,7 @@ import { FilesRenameResult, type FilesScrapeBatchRequest, FilesScrapeBatchResult, + FilesScrapeRecordsResult, type FilesScrapeRequest, FilesScrapeResult, type FilesSuggestNameRequest, @@ -63,11 +64,14 @@ export type { FilesOrganizeItem, FilesOrganizeRequest, FilesScrapeBatchResult, + FilesScrapeNeedsChoice, + FilesScrapeResult, FilesSuggestNameResult, FilesTransferResult, FilesTreeResult, IdentifyCompletedPayload, NamingTemplateSource, + ScrapeRecord, TmdbCandidate, TrashItem, } from "@umt/contracts"; @@ -271,6 +275,19 @@ export function useFileRecords(q: { ids?: string[]; planIds?: string[] }, enable }); } +/** 刮削记录回查:排队 / 进行中时每 2 秒刷新 */ +export function useScrapeRecords(ids: string[]) { + return useQuery({ + queryKey: ["files", "scrape-records", ids.join(",")], + enabled: ids.length > 0, + retry: false, + queryFn: ({ signal }) => + call(FilesScrapeRecordsResult, "/files/scrape-records", { params: { ids: ids.join(",") }, signal }), + refetchInterval: (query) => + query.state.data?.records.some((r) => r.status === "queued" || r.status === "running") ? 2_000 : false, + }); +} + /** 整理记录 → 源 FileRef(整理记录页"重新整理") */ export function useRecordSources(recordId: string | null) { return useQuery({ @@ -372,8 +389,8 @@ export const useOrganizeFiles = () => useFilesMutation((b: FilesOrganizeRequest) => post(FilesOrganizeResult, "/files/organize", b), { invalidate: notDry, }); -export const useScrape = () => - useMutation({ mutationFn: (b: FilesScrapeRequest) => post(FilesScrapeResult, "/files/scrape", b) }); +/** 刮削(SCR-2):任意目录 / 视频文件就地写 NFO 与图片;认不准的项在 needsChoice 里,选定后再提交 */ +export const useScrape = () => useFilesMutation((b: FilesScrapeRequest) => post(FilesScrapeResult, "/files/scrape", b)); export const useProbe = () => useFilesMutation((b: FilesProbeRequest) => post(FilesProbeResult, "/files/probe", b)); export const useScrapeBatch = () => useMutation({ mutationFn: (b: FilesScrapeBatchRequest) => post(FilesScrapeBatchResult, "/files/scrape-batch", b) }); diff --git a/apps/web/app/api/library.ts b/apps/web/app/api/library.ts index c4899ffe..ad48ff74 100644 --- a/apps/web/app/api/library.ts +++ b/apps/web/app/api/library.ts @@ -82,7 +82,7 @@ export function useLibraryDetail(id: string | undefined) { }); } -/** SCR-1 刮削:写 NFO 与图片到媒体库目录(后台任务);mode 缺省按设置 */ +/** 刮削:对作品目录就地写 NFO 与图片(后台任务,SCR-2 与文件管理「刮削」同一实现);mode 缺省按设置 */ export function useScrapeEntity(id: string) { const qc = useQueryClient(); return useMutation({ diff --git a/apps/web/app/components/files/dialogs.tsx b/apps/web/app/components/files/dialogs.tsx index 51c492a1..9d026129 100644 --- a/apps/web/app/components/files/dialogs.tsx +++ b/apps/web/app/components/files/dialogs.tsx @@ -1,6 +1,7 @@ import { ArrowRight, Copy, + FileDown, FolderInput, FolderPlus, LoaderCircle, @@ -19,6 +20,7 @@ import { type FileRef, type FileRoot, type FilesScrapeBatchResult, + type FilesScrapeNeedsChoice, type FilesSuggestNameResult, type FilesTransferResult, filesErrorReason, @@ -28,11 +30,17 @@ import { useMkdir, useMove, useRename, + useScrape, useScrapeBatch, + useScrapeRecords, useSuggestName, } from "~/api/files.ts"; +import { useSettings } from "~/api/settings.ts"; import { FormField } from "~/components/console/form-field.tsx"; import { StatusBadge } from "~/components/console/status-badge.tsx"; +import { ScrapeRecordView } from "~/components/library/scrape-panel.tsx"; +import { EntityPicker } from "~/components/organize-wizard/entity-picker.tsx"; +import type { WizardEntity } from "~/components/organize-wizard/model.ts"; import { PathSegments } from "~/components/organize-wizard/path-segments.tsx"; import { Button } from "~/components/ui/button.tsx"; import { @@ -986,21 +994,12 @@ export function CopyPathDialog({ root, entry, onClose }: { root: FileRoot; entry ); } -// ─── 刷新元数据 / 刮削 ───────────────────────────────────── +// ─── 刷新元数据 ───────────────────────────────────────────── /** - * 刷新元数据(§7.2):只对已登记项;多选时按作品去重(POST /files/scrape-batch)。 - * write = 刮削(SCR-1):同一接口 write=true,按作品排 scrapeMedia,把 NFO 与图片写进媒体库目录(覆盖方式按设置)。 + * 刷新元数据(§7.2):只对已登记项;多选时按作品去重(POST /files/scrape-batch)。只更新本工具的字段,不写文件。 */ -export function RefreshMetadataDialog({ - items, - write = false, - onClose, -}: { - items: FileEntry[]; - write?: boolean | undefined; - onClose: () => void; -}) { +export function RefreshMetadataDialog({ items, onClose }: { items: FileEntry[]; onClose: () => void }) { const batch = useScrapeBatch(); const key = useMemo(() => newIdempotencyKey(), []); const [result, setResult] = useState(null); @@ -1008,15 +1007,13 @@ export function RefreshMetadataDialog({ const single = entities.length === 1 ? entities[0] : null; const run = () => batch.mutate( - { idempotencyKey: key, mediaEntityIds: entities.map((a) => a?.mediaEntityId as string), write }, + { idempotencyKey: key, mediaEntityIds: entities.map((a) => a?.mediaEntityId as string) }, { onSuccess: (r) => { setResult(r); const skipped = r.skipped.length ? `;${r.skipped.length} 个未提交` : ""; (r.skipped.length ? toast.warning : toast.success)( - write - ? `已提交 ${r.submitted.length} 个作品的刮削${skipped}` - : `已提交 ${r.submitted.length} 个作品,锁定字段不变${skipped}`, + `已提交 ${r.submitted.length} 个作品,锁定字段不变${skipped}`, ); }, onError: (e) => toast.error(filesErrorText(e, "提交失败")), @@ -1027,14 +1024,10 @@ export function RefreshMetadataDialog({ - {write ? "刮削" : "刷新元数据"} + 刷新元数据 {single ? ` · ${single.title}` : ` · ${entities.length} 个作品`} - - {write - ? "把 NFO 和图片写进媒体库目录,已有文件按设置只补缺失或全部覆盖。结果在作品详情的「刮削」里。" - : "按字段从 TMDB 刷新,锁定的字段和路径不变。不写媒体库目录里的文件。"} - + 按字段从 TMDB 刷新,锁定的字段和路径不变。不写目录里的文件。 {!single && !result ? (
    @@ -1052,30 +1045,19 @@ export function RefreshMetadataDialog({

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

    - {write ? null : ( -

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

    - )} +

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

    ))} {result.skipped.map((s) => (

    - {s.ref}: - {s.reason === "not_registered" - ? "没有已入库文件" - : s.reason === "no_entity" - ? "不属于媒体库里的作品" - : s.reason === "not_found" - ? "作品不存在" - : s.reason} + {s.ref}: + {s.reason === "no_entity" ? "不属于媒体库里的作品" : s.reason === "not_found" ? "作品不存在" : s.reason}

    ))} @@ -1087,7 +1069,7 @@ export function RefreshMetadataDialog({ {result ? null : ( )} @@ -1095,3 +1077,181 @@ export function RefreshMetadataDialog({ ); } + +// ─── 刮削(SCR-2) ───────────────────────────────────────── + +const SKIP_TEXT: Record = { + scrape_root_read_only: "只登记模式的目录,不写文件", + scrape_not_video: "不是视频文件或目录", + scrape_no_videos: "目录里没有视频", +}; + +/** + * 刮削(照 MP 文件管理的刮削):任意目录 / 视频文件 → 服务端按路径识别作品 → 在所在目录写 NFO 与图片。 + * 不要求已登记;认不准的项列出候选,选一个后「继续刮削」(可顺手加入媒体库)。覆盖方式缺省按设置,这里可临时改。 + */ +export function ScrapeDialog({ items, root, onClose }: { items: FileEntry[]; root: FileRoot; onClose: () => void }) { + const settings = useSettings(); + const scrape = useScrape(); + const [mode, setMode] = useState<"missing" | "overwrite" | null>(null); + const curMode = mode ?? settings.data?.organize.scrapeMode ?? "missing"; + const [recordIds, setRecordIds] = useState([]); + const [choices, setChoices] = useState([]); + const [picked, setPicked] = useState>({}); + const [skipped, setSkipped] = useState<{ path: string; reason: string }[]>([]); + const [addToLibrary, setAddToLibrary] = useState(false); + const [started, setStarted] = useState(false); + const records = useScrapeRecords(recordIds); + const single = items.length === 1 ? items[0] : null; + + const submit = (list: { path: string; pick?: WizardEntity | null }[]) => + scrape.mutate( + { + idempotencyKey: newIdempotencyKey(), + mode: curMode, + addToLibrary, + items: list.map((x) => ({ + root: root.id, + path: x.path, + ...(x.pick?.id ? { mediaEntityId: x.pick.id } : {}), + ...(x.pick && !x.pick.id && x.pick.external ? { external: x.pick.external } : {}), + })), + }, + { + onSuccess: (r) => { + setStarted(true); + const done = new Set(list.map((x) => x.path)); + setRecordIds((ids) => [...ids, ...r.records.map((x) => x.id)]); + setChoices((cur) => [...cur.filter((c) => !done.has(c.path)), ...r.needsChoice]); + setSkipped((cur) => [...cur, ...r.skipped.map((x) => ({ path: x.path, reason: x.reason }))]); + if (r.entities.length) toast.success(`已加入媒体库:${r.entities.map((e) => e.title).join("、")}`); + }, + onError: (e) => toast.error(filesErrorText(e, "刮削没有提交")), + }, + ); + const ready = choices.filter((c) => picked[c.path]); + const recs = records.data?.records ?? []; + const pending = recs.some((r) => r.status === "queued" || r.status === "running"); + + return ( + !o && onClose()}> + + + 刮削{single ? ` · ${single.name}` : ` · ${items.length} 项`} + + 按路径识别作品,在所在目录写 NFO 和图片,Emby、Jellyfin、Kodi 直接读取。没入库的也能刮削。 + + + {!started ? ( +
    + {!single ? ( +
      + {items.map((e) => ( +
    • + {e.path} +
    • + ))} +
    + ) : null} + +
    + {( + [ + ["missing", "只补缺失"], + ["overwrite", "全部覆盖"], + ] as const + ).map(([v, label]) => ( + + ))} +
    +
    + {curMode === "overwrite" ? ( +

    目录里已有的 NFO 和图片会被替换,包括其他工具写的。

    + ) : null} +
    + ) : ( +
    + {choices.length ? ( +
    +

    认不准作品,选一个再刮削

    + {choices.map((c) => ( +
    +

    {c.path || "目录根"}

    + setPicked((p) => ({ ...p, [c.path]: e }))} + placeholder={c.candidates.length ? "选择作品…" : "搜索作品…"} + /> + {c.warnings.map((w) => ( +

    + {w} +

    + ))} +
    + ))} +
    + + +
    +
    + ) : null} + {recs.map((r) => ( +
    + + {r.match?.mediaEntityId ? ( + + 打开作品 + + ) : null} +
    + ))} + {recordIds.length && !recs.length ?

    加载中…

    : null} + {skipped.map((x) => ( +

    + {x.path || "目录根"}:{SKIP_TEXT[x.reason] ?? x.reason} +

    + ))} +
    + )} + + + {!started ? ( + + ) : choices.length ? ( + + ) : pending ? ( + + ) : null} + +
    +
    + ); +} diff --git a/apps/web/app/components/files/mock.ts b/apps/web/app/components/files/mock.ts index 0905dbb8..d83a6d04 100644 --- a/apps/web/app/components/files/mock.ts +++ b/apps/web/app/components/files/mock.ts @@ -12,6 +12,7 @@ import type { FilesListResponse, FilesOrganizeGroup, FilesTransferResult, + ScrapeRecord, TrashItem, } from "@umt/contracts"; import type { MediaSearchResult } from "~/api/files.ts"; @@ -699,6 +700,82 @@ function identify( let seq = 0; +// SCR-2 刮削:认得出的(KNOWN)直接写,认不出的给两个 TMDB 候选 +const SCRAPES = new Map(); +function mockScrape(b: { + items: { root: string; path: string; mediaEntityId?: string; external?: { externalId: string } }[]; + mode?: "missing" | "overwrite"; +}) { + const records: ScrapeRecord[] = []; + const needsChoice: unknown[] = []; + for (const it of b.items) { + const known = KNOWN.find((k) => k.match.test(it.path) || k.id === it.mediaEntityId); + if (!known && !it.external) { + needsChoice.push({ + root: it.root, + path: it.path, + title: it.path.split("/").pop() ?? it.path, + warnings: [], + candidates: [1972, 2002].map((year, i) => ({ + mediaEntityId: null, + provider: "tmdb", + externalId: String(593 + i), + title: it.path.split("/").pop() ?? it.path, + year, + kind: "movie", + level: "medium", + score: 5 - i, + reasons: ["title:exact"], + posterUrl: null, + })), + }); + continue; + } + const stem = it.path.replace(/\.[^./]+$/, ""); + const now = new Date().toISOString(); + const files = [ + { path: `${stem}.nfo`, kind: "nfo" as const, role: "movie_nfo", action: "written" as const, reason: null }, + { + path: `${it.path}/poster.jpg`, + kind: "image" as const, + role: "poster", + action: "written" as const, + reason: null, + }, + ]; + const rec: ScrapeRecord = { + id: `00000000-0000-4000-8000-${String(++seq).padStart(12, "0")}`, + root: it.root, + path: it.path, + status: "succeeded", + trigger: "manual", + mode: b.mode ?? "missing", + match: { + mediaEntityId: known?.id ?? null, + provider: "tmdb", + externalId: it.external?.externalId ?? "0", + kind: known?.kind === "series" ? "tv" : "movie", + title: known?.title ?? it.path, + year: known?.year ?? null, + source: it.external || it.mediaEntityId ? "chosen" : known ? "auto" : "chosen", + }, + written: files.length, + skipped: 0, + failed: 0, + reason: null, + files, + organizationRecordId: null, + taskId: `run-${seq}`, + requestedAt: now, + startedAt: now, + finishedAt: now, + }; + SCRAPES.set(rec.id, rec); + records.push(rec); + } + return { records, needsChoice, skipped: [], pending: false, entities: [] }; +} + function doMove(body: { items: ItemRef[]; target: ItemRef; @@ -1119,13 +1196,14 @@ export async function mockFiles(path: string, arg: FetcherArg): Promise await delay(400); return organize(b); case "/files/scrape": + await delay(500); + return mockScrape(b); + case "/files/scrape-records": return { - mediaEntityId: b.mediaEntityId, - taskId: `run-${++seq}`, - lockedFields: ["title"], - nfo: b.writeNfo ? "queued" : "not_requested", - images: b.images ? "queued" : "not_requested", - scrapeTaskId: b.writeNfo || b.images ? `run-${++seq}` : null, + records: String(p.ids ?? "") + .split(",") + .map((id) => SCRAPES.get(id)) + .filter(Boolean), }; 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 5bd1fe9d..2d28510f 100644 --- a/apps/web/app/components/files/model.test.ts +++ b/apps/web/app/components/files/model.test.ts @@ -111,7 +111,7 @@ describe("状态与禁用原因", () => { expect(entryStatusView(base)).toBeNull(); }); - it("做种中不能改名/删除;只读登记根不能移动;未登记不能刮削", () => { + it("做种中不能改名/删除;只读登记根不能移动;未登记不能刷新元数据但能刮削", () => { const lib = { kind: "library" as const, writable: true }; const seeding = { ...base, @@ -124,6 +124,8 @@ describe("状态与禁用原因", () => { expect(opDisabledReason("move", base, { kind: "library", writable: false })).toMatch(/只读/); expect(opDisabledReason("rename", base, { kind: "download", writable: false })).toBeNull(); expect(opDisabledReason("scrape", base, lib)).toMatch(/未登记/); + expect(opDisabledReason("writeNfo", base, lib)).toBeNull(); + expect(opDisabledReason("writeNfo", base, { kind: "library", writable: false })).toMatch(/只读/); expect(opDisabledReason("copyPath", { ...base, escape: true }, lib)).toBeNull(); expect(opDisabledReason("rename", { ...base, escape: true }, lib)).toMatch(/登记目录之外/); }); @@ -193,16 +195,18 @@ describe("v2 菜单与副行", () => { const registered = { ...video, status: "registered" as const, asset }; const keys = (xs: ReturnType) => xs.map((x) => ("separator" in x ? "|" : x.key)).join(" "); - it("单项菜单:未登记隐藏刷新元数据/打开作品/整理记录,顺序固定", () => { - expect(keys(menuModel([video], lib))).toBe("open | organize identify rename move copy | probe | copyPath | delete"); + it("单项菜单:未登记隐藏刷新元数据/打开作品/整理记录,刮削照 MP 都有;顺序固定", () => { + expect(keys(menuModel([video], lib))).toBe( + "open | organize identify rename move copy | writeNfo probe | copyPath | delete", + ); expect(keys(menuModel([registered], lib))).toBe( "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"); + it("多选菜单:整理 n 项在前,删除在后;全部已登记才有刷新元数据,刮削不要求登记", () => { + expect(keys(menuModel([video, video], lib))).toBe("organize identify move copy probe writeNfo | delete"); expect(keys(menuModel([registered, registered], lib))).toContain("scrape writeNfo"); }); diff --git a/apps/web/app/components/files/model.ts b/apps/web/app/components/files/model.ts index 9b26aeb7..cd0ea344 100644 --- a/apps/web/app/components/files/model.ts +++ b/apps/web/app/components/files/model.ts @@ -98,8 +98,11 @@ export function opDisabledReason(op: FileOp, e: FileEntry, root: Pick x.escape) ? "含指向登记目录之外的链接" : null); if (any("probe")) add("probe", null); - if (list.every((x) => !!x.asset)) { - add("scrape", null); - add("writeNfo", null); - } + if (list.every((x) => !!x.asset)) add("scrape", null); + if (all("writeNfo")) add("writeNfo", why("writeNfo")); sep(); add("delete", why("delete")); return out; @@ -441,7 +444,7 @@ export const OP_LABEL: Record = { move: "移动到…", copy: "复制到…", scrape: "刷新元数据", - writeNfo: "刮削", + writeNfo: "刮削…", probe: "探测", copyPath: "复制路径…", openMedia: "打开作品", diff --git a/apps/web/app/components/library/scrape-panel.tsx b/apps/web/app/components/library/scrape-panel.tsx index 9231615d..e27eadae 100644 --- a/apps/web/app/components/library/scrape-panel.tsx +++ b/apps/web/app/components/library/scrape-panel.tsx @@ -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 = { +export const SCRAPE_STATUS: Record = { queued: { label: "排队中", tone: "neutral" }, running: { label: "刮削中…", tone: "neutral" }, succeeded: { label: "已完成", tone: "success" }, @@ -25,7 +25,7 @@ const STATUS: Record = { written: { label: "已写入", tone: "success" }, - skipped: { label: "已存在", tone: "neutral" }, + skipped: { label: "跳过", tone: "neutral" }, failed: { label: "失败", tone: "destructive" }, }; @@ -40,10 +40,12 @@ const ROLE: Record = { episode_thumb: "剧照", }; -function counts(s: LibraryScrapeState): string { +const SOURCE: Record = { library: "已入库的作品", auto: "按路径识别", chosen: "手动指定" }; + +export function scrapeCounts(s: Pick): 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 }) { )} {ROLE[f.role] ?? f.role} - {f.action === "failed" ? ( - {reasonLine(f.reason, "写入失败")} + {f.action !== "written" && f.reason && f.reason !== "scrape_exists" ? ( + + {reasonLine(f.reason, "写入失败")} + ) : null} {a.label} ); } +/** 一条刮削记录:状态、目录与识别结果、计数、逐文件结果(缺省不列"已存在"跳过的) */ +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 ( +
    +
    + {SCRAPE_STATUS[s.status].label} + {showPath ? {s.path || "目录根"} : null} + {done && (files.length || s.status === "succeeded") ? ( + {scrapeCounts(s) || "没有要写的文件"} + ) : null} + {s.mode === "overwrite" ? 覆盖已有文件 : null} +
    + {s.match ? ( +

    + {s.match.title} + {s.match.year ? ` (${s.match.year})` : ""} · {s.match.kind === "movie" ? "电影" : "剧集"} ·{" "} + {SOURCE[s.match.source] ?? s.match.source} + {s.match.mediaEntityId ? "" : " · 不在媒体库"} +

    + ) : null} + {s.reason && s.status !== "succeeded" ? ( +

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

    + ) : null} + {shown.length ? ( +
      + {shown.map((f) => ( + + ))} +
    + ) : null} + {hidden > 0 ? ( +
    + +
    + ) : null} +
    + ); +} + 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 ( - - 覆盖重写 - - } - title="覆盖重写 NFO 和图片?" - description="媒体库目录里已有的 NFO 和图片会被替换,包括其他工具写的。视频和字幕不动。" - confirmLabel="覆盖重写" - onConfirm={() => run("overwrite")} - /> - - + hasFiles ? ( + <> + + 覆盖重写 + + } + title="覆盖重写 NFO 和图片?" + description="作品目录里已有的 NFO 和图片会被替换,包括其他工具写的。视频和字幕不动。" + confirmLabel="覆盖重写" + onConfirm={() => run("overwrite")} + /> + + + ) : null } > {!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} + e.asset) ? null : "只对已入库的文件", + disabled: selected.every((e) => opApplies("writeNfo", e)) ? blockedTip("writeNfo") : "只能刮削视频文件或目录", onSelect: () => act("writeNfo", selected), }); items.push({ key: "copyPath", label: "复制路径", icon: ClipboardCopy, onSelect: () => void copyPaths(selected) }); @@ -1151,8 +1153,14 @@ export default function FilesPage() { 刷新元数据 ) : null} - {detail.asset ? ( - @@ -1224,7 +1232,10 @@ export default function FilesPage() { setDialog(null)} /> ) : null} {dialog?.kind === "refresh" ? ( - setDialog(null)} /> + setDialog(null)} /> + ) : null} + {dialog?.kind === "scrape" && root ? ( + setDialog(null)} /> ) : null} {dialog?.kind === "wizard" ? setDialog(null)} /> : null} !o && setDialog(null)} />