import { ArrowRight, Copy, FileDown, FolderInput, FolderPlus, LoaderCircle, PenLine, RefreshCw, Trash2, TriangleAlert, Undo2, Upload, Wand2, } from "lucide-react"; import { useEffect, useMemo, useRef, useState } from "react"; import { Link } from "react-router"; import { toast } from "sonner"; import { type FileEntry, type FileRef, type FileRoot, type FilesScrapeBatchResult, type FilesScrapeNeedsChoice, type FilesSuggestNameResult, type FilesTransferResult, filesErrorReason, filesErrorText, useDelete, useFileStat, useMkdir, useMove, useRename, useScrape, useScrapeBatch, useScrapeRecords, useSuggestName, useUpload, } 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 { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "~/components/ui/dialog.tsx"; import { Input } from "~/components/ui/input.tsx"; import { Switch } from "~/components/ui/switch.tsx"; import { cn, formatBytes } from "~/lib/utils.ts"; import { DirPicker } from "./dir-picker.tsx"; import { baseName, deleteBlockText, displayPath, nameProblem, nameTaken, newIdempotencyKey, splitCommon, stemRange, transferTargetProblem, } from "./model.ts"; // ─── 共用 ─────────────────────────────────────────────────────── /** 前后路径对照:公共前缀淡显,差异部分高亮 */ export function PathPair({ from, to, tone }: { from: string; to: string | null; tone?: "conflict" | "skip" }) { if (!to) return

{from}

; const s = splitCommon(from, to); return (

{s.common} {s.a}

{s.common} {s.b}

); } function Notice({ tone, children }: { tone: "warning" | "destructive" | "neutral"; children: React.ReactNode }) { return (
{tone !== "neutral" ? : null}
{children}
); } function useDebounced(v: T, ms: number): T { const [d, setD] = useState(v); useEffect(() => { const t = setTimeout(() => setD(v), ms); return () => clearTimeout(t); }, [v, ms]); return d; } // ─── 上传 ─────────────────────────────────────────────────────── /** * 上传到当前目录(对照 MP 文件管理的上传;Alist 根经服务端转传):选多个文件逐个上传,同名不覆盖并提示。 * 只读目录与下载目录不能上传。 */ export function UploadButton({ root, path }: { root: FileRoot; path: string }) { const upload = useUpload(); const input = useRef(null); const [busy, setBusy] = useState(null); const disabled = !root.writable || busy !== null; const run = async (files: FileList | null) => { if (!files?.length) return; let ok = 0; const failed: string[] = []; for (const file of Array.from(files)) { setBusy(file.name); try { await upload.mutateAsync({ root: root.id, path, file }); ok++; } catch (e) { failed.push(`${file.name}:${filesErrorReason(e) === "exists" ? "同名已存在,没有覆盖" : filesErrorText(e)}`); } } setBusy(null); if (input.current) input.current.value = ""; if (ok) toast.success(`已上传 ${ok} 个文件到 ${displayPath(root, path)}`); if (failed.length) toast.error(`${failed.length} 个文件没有上传`, { description: failed.join("\n") }); }; return ( void run(e.currentTarget.files)} /> ); } // ─── 新建目录 ─────────────────────────────────────────────────── export function MkdirDialog({ root, path, open, onOpenChange, onCreated, }: { root: FileRoot; path: string; open: boolean; onOpenChange: (o: boolean) => void; onCreated?: (path: string) => void; }) { const [name, setName] = useState(""); const [err, setErr] = useState(null); const mkdir = useMkdir(); const key = useMemo(() => (open ? newIdempotencyKey() : ""), [open]); const submit = (ev: React.FormEvent) => { ev.preventDefault(); const p = nameProblem(name); if (p) return setErr(p); mkdir.mutate( { idempotencyKey: key, root: root.id, path, name: name.trim() }, { onSuccess: (r) => { toast.success(`已新建目录 ${name.trim()}`); setName(""); onOpenChange(false); onCreated?.(r.path); }, onError: (e) => setErr(filesErrorReason(e) === "exists" ? "同名项已存在" : filesErrorText(e)), }, ); }; return (
新建目录 {displayPath(root, path)}/ { setName(e.target.value); setErr(null); }} />
); } // ─── 重命名 ───────────────────────────────────────────────────── const SUGGEST_REASON: Record = { identity_uncertain: "识别不可信,没有推荐名", }; const TEMPLATE_SOURCE: Record = { entity: "作品覆盖", root: "媒体库目录", global: "全局", override: "覆写", }; function suggestReasonText(r: string | null): string { if (!r) return "没有推荐名"; if (SUGGEST_REASON[r]) return SUGGEST_REASON[r] as string; if (r.startsWith("naming_failed")) return `命名模板渲染失败:${r.replace(/^naming_failed:\s*/, "")}`; return r; } /** * 重命名(§8):默认值取命名模板推荐名(POST /files/suggest-name),用户没改过时自动填入; * `按模板生成` 重新取;同名即时提示;输入停顿后 dryRun 列出一起改名的附属文件与资产/绑定同步。 */ export function RenameDialog({ root, entry, siblings = [], onClose, }: { root: FileRoot; entry: FileEntry; /** 当前目录其他项的名称(同名即时提示) */ siblings?: string[]; onClose: () => void; }) { const isDir = entry.type === "dir"; const [name, setName] = useState(entry.name); const edited = useRef(false); const [withSidecars, setWithSidecars] = useState(true); const [err, setErr] = useState(null); const [suggested, setSuggested] = useState(null); const inputRef = useRef(null); const key = useMemo(() => newIdempotencyKey(), []); const preview = useRename(); const exec = useRename(); const suggest = useSuggestName(); const debounced = useDebounced(name.trim(), 350); const problem = nameProblem(name); const changed = name.trim() !== entry.name; const taken = nameTaken(name, entry.name, siblings); const canSuggest = isDir || entry.fileKind === "video"; const selectStem = (v: string) => { const el = inputRef.current; if (!el) return; el.focus(); const [a, b] = stemRange(v, isDir); requestAnimationFrame(() => el.setSelectionRange(a, b)); }; // biome-ignore lint/correctness/useExhaustiveDependencies: 打开时选中主名一次 useEffect(() => selectStem(entry.name), [entry.name, isDir]); const runSuggest = (apply: boolean) => suggest.mutate( { root: root.id, path: entry.path }, { onSuccess: (r) => { setSuggested(r); if (r.name && apply) { setName(r.name); setErr(null); selectStem(r.name); } }, }, ); // 打开时取推荐名;用户还没动过输入框就直接填入 const suggestMutate = suggest.mutate; // biome-ignore lint/correctness/useExhaustiveDependencies: 只在打开时取一次 useEffect(() => { if (!canSuggest) return; suggestMutate( { root: root.id, path: entry.path }, { onSuccess: (r) => { setSuggested(r); if (r.name && r.name !== entry.name && !edited.current) { setName(r.name); selectStem(r.name); } }, }, ); }, [suggestMutate]); // 输入停顿后 dryRun:列出将一起改名的附属文件与资产/绑定同步 const previewMutate = preview.mutate; useEffect(() => { if (!debounced || debounced === entry.name || nameProblem(debounced)) return; previewMutate({ idempotencyKey: `${key}-preview`, root: root.id, path: entry.path, newName: debounced, withSidecars, dryRun: true, }); }, [debounced, withSidecars, entry.name, entry.path, key, root.id, previewMutate]); const submit = (ev: React.FormEvent) => { ev.preventDefault(); if (problem) return setErr(problem); if (!changed) return onClose(); if (taken) return setErr("同名已存在,不会覆盖"); exec.mutate( { idempotencyKey: key, root: root.id, path: entry.path, newName: name.trim(), withSidecars, dryRun: false }, { onSuccess: (r) => { toast.success( `已重命名${r.renames.length > 1 ? `(含 ${r.renames.length - 1} 个附属文件)` : ""}`, r.recordId ? { action: { label: "整理记录", onClick: () => window.location.assign(`/console/organize/${r.recordId}`), }, } : undefined, ); onClose(); }, onError: (e) => { const reason = filesErrorReason(e); setErr( reason === "exists" ? "同名已存在,不会覆盖" : reason === "download_active" ? "做种中,改名会让下载器找不到文件" : filesErrorText(e), ); }, }, ); }; const pv = preview.data && preview.variables?.newName === debounced ? preview.data : null; const pvErr = preview.error && preview.variables?.newName === debounced ? preview.error : null; const usingSuggestion = !!suggested?.name && name.trim() === suggested.name; return ( !o && onClose()}>
重命名 {entry.name}
{ setName(e.target.value); edited.current = true; setErr(null); }} className="font-mono" /> {canSuggest ? ( ) : null}
{canSuggest ? (
{suggest.isPending && !suggested ? (

取推荐名…

) : suggest.error ? (

{filesErrorText(suggest.error, "取推荐名失败")}

) : suggested && !suggested.name ? (

{suggestReasonText(suggested.reason)}

) : suggested?.name ? ( <>

{suggested.name === entry.name ? "当前名称与命名模板一致" : usingSuggestion ? "已填入推荐名" : `推荐名:${suggested.name}`} {suggested.templateSource ? `· 模板来源:${TEMPLATE_SOURCE[suggested.templateSource] ?? suggested.templateSource}` : ""} {usingSuggestion && name.trim() !== entry.name ? ( ) : null}

{suggested.segments.length > 1 ? ( ) : null} ) : null}
) : null} {!isDir ? (
同名附属文件一起改 主干相同的字幕、NFO、图片
) : null} {changed && !problem && !taken ? (
{preview.isPending ? (

预览中

) : null} {pvErr ? {filesErrorText(pvErr)} : null} {pv ? ( <>
    {pv.renames.map((r) => (
  • {r.role === "main" ? "本体" : "附属"}
  • ))}
{pv.assetLocations > 0 ? ( 同步 {pv.assetLocations} 条文件位置,写一条整理记录。 ) : null} {pv.bindings.length > 0 ? ( 这是作品或季的绑定目录。改名后旧绑定标为已替代,之后整理用新目录。 ) : null} ) : null}
) : null}
); } // ─── 移动 / 复制到… ───────────────────────────────────────────── export function TransferDialog({ roots, items, initialMode, initialTarget, onClose, }: { roots: FileRoot[]; items: FileRef[]; initialMode: "move" | "copy"; initialTarget?: FileRef | null; onClose: () => void; }) { const [mode, setMode] = useState(initialMode); const [target, setTarget] = useState(initialTarget ?? null); const [plan, setPlan] = useState(null); const [err, setErr] = useState(null); const key = useMemo(() => newIdempotencyKey(), []); const dry = useMove(); const exec = useMove(); const rootById = new Map(roots.map((r) => [r.id, r])); const targetRoot = target ? rootById.get(target.root) : undefined; const runPreview = (t: FileRef, m: "move" | "copy") => { setPlan(null); setErr(null); const problem = transferTargetProblem(items, t); if (problem) { setErr(problem); return; } dry.mutate( { idempotencyKey: `${key}-dry-${m}-${t.root}-${t.path}`, items, target: t, mode: m, dryRun: true }, { onSuccess: setPlan, onError: (e) => setErr(filesErrorText(e, "预览失败")) }, ); }; // 拖拽进来时目标已定:直接出预览 const dryMutate = dry.mutate; // biome-ignore lint/correctness/useExhaustiveDependencies: 只在打开时跑一次 useEffect(() => { if (initialTarget) runPreview(initialTarget, initialMode); }, [dryMutate]); const conflicts = plan?.pairs.filter((p) => p.conflict) ?? []; const hnrBlocked = mode === "move" && (plan?.hnrBlocks.length ?? 0) > 0; const canRun = !!plan && !hnrBlocked && plan.spaceOk && conflicts.length < plan.pairs.length; const run = () => { if (!plan || !target) return; exec.mutate( { idempotencyKey: key, items, target, mode, dryRun: false, planId: plan.planId }, { onSuccess: (r) => { const ok = r.executed?.succeeded.length ?? 0; const failed = r.executed?.failed ?? []; const recordId = r.executed?.recordId; const msg = `${mode === "move" ? "已移动" : "已复制"} ${ok} 项${failed.length ? `,${failed.length} 项失败` : ""}`; (failed.length ? toast.warning : toast.success)(msg, { description: failed .slice(0, 3) .map((f) => `${baseName(f.path)}:${f.message}`) .join("\n"), action: recordId ? { label: "整理记录", onClick: () => window.location.assign(`/console/organize/${recordId}`) } : undefined, }); onClose(); }, onError: (e) => { const reason = filesErrorReason(e); if (reason === "plan_stale") { setErr("源文件在预览后有变化,已重新预览"); runPreview(target, mode); } else setErr(filesErrorText(e)); }, }, ); }; return ( !o && onClose()}> {mode === "move" ? "移动到…" : "复制到…"} {items.length === 1 ? baseName(items[0]?.path ?? "") : `${items.length} 项`} · 先预览再执行。跨卷移动按复制 → 核验 → 删源。
{(["move", "copy"] as const).map((m) => ( ))}
{ setTarget(loc); runPreview(loc, mode); }} className="max-h-72 md:max-h-96" />

可选可写的媒体库目录或 Alist 存储;本机和 Alist 之间会上传或下载。

{target && targetRoot ? (

目标目录

{displayPath(targetRoot, target.path)}

) : ( 在左侧选择目标目录。 )} {dry.isPending ? (

生成预览

) : null} {err ? {err} : null} {plan ? ( <>
{plan.pairs.length} 项 · {formatBytes(plan.totalBytes, 1)} {plan.spaceOk ? "空间足够" : "空间不足"} {plan.freeBytes != null ? ` · 剩余 ${formatBytes(plan.freeBytes, 1)}` : ""} {conflicts.length ? ( 冲突 {conflicts.length}(跳过,不覆盖) ) : null} {plan.pairs.some((p) => p.registered) ? ( 含已整理文件,执行后同步位置 ) : null}
{hnrBlocked ? ( {plan.hnrBlocks.length} 项做种中且 H&R 未满足,不能移动。改用复制,或等 H&R 满足后再移动。 ) : null} {plan.truncated ? ( 源文件过多,预览只列出前一部分,执行时处理全部。 ) : null}
    {plan.pairs.map((p) => { const sr = rootById.get(p.source.root); const tr = rootById.get(p.target.root); return (
  • {p.conflict ? ( 目标已存在 · 跳过 ) : ( {mode === "move" ? "移动" : "复制"} )} {formatBytes(p.size, 1)}
  • ); })}
) : null}
); } // ─── 删除 ─────────────────────────────────────────────────────── export function DeleteDialog({ roots, items, isAdmin, onClose, }: { roots: FileRoot[]; items: FileRef[]; isAdmin: boolean; onClose: () => void; }) { const [retire, setRetire] = useState(false); const [unbind, setUnbind] = useState(false); const [mode, setMode] = useState<"trash" | "permanent">("trash"); const [confirmText, setConfirmText] = useState(""); const [err, setErr] = useState(null); const key = useMemo(() => newIdempotencyKey(), []); const check = useDelete(); const exec = useDelete(); const rootById = new Map(roots.map((r) => [r.id, r])); const checkMutate = check.mutate; useEffect(() => { checkMutate( { idempotencyKey: `${key}-check`, items, mode: "trash", dryRun: true, retireAssets: false, unbind: false }, { onError: (e) => setErr(filesErrorText(e, "引用检查失败")) }, ); }, [checkMutate, items, key]); const checks = check.data?.checks ?? []; const hasAssets = checks.some((c) => c.assets.length > 0); const hasBindings = checks.some((c) => c.bindings.length > 0); const effective = (b: string | null) => b === "needs_retire" && retire ? null : b === "needs_unbind" && unbind ? null : b; const hardBlocked = checks.filter((c) => { const b = effective(c.blocked); return b && !deleteBlockText(b)?.soft; }); const softPending = checks.filter((c) => { const b = effective(c.blocked); return b && deleteBlockText(b)?.soft; }); const single = items.length === 1 ? baseName(items[0]?.path ?? "") : null; const confirmPhrase = single ?? `永久删除 ${items.length} 项`; const permanentOk = mode !== "permanent" || confirmText.trim() === confirmPhrase; const deletable = checks.length - hardBlocked.length; const canRun = !!check.data && hardBlocked.length === 0 && softPending.length === 0 && permanentOk; const run = () => exec.mutate( { idempotencyKey: `${key}-${mode}`, items, mode, dryRun: false, retireAssets: retire, unbind, confirmName: mode === "permanent" && single ? confirmText.trim() : undefined, planId: check.data?.planId ?? undefined, }, { onSuccess: (r) => { const n = mode === "trash" ? r.trashed.length : r.deleted.length; (r.failed.length ? toast.warning : toast.success)( mode === "trash" ? `已移入回收站 ${n} 项` : `已永久删除 ${n} 项`, { description: r.failed.map((f) => `${baseName(f.path)}:${f.message}`).join("\n") || undefined, action: mode === "trash" ? { label: "回收站", onClick: () => window.location.assign("/console/files/trash") } : undefined, }, ); onClose(); }, onError: (e) => { const reason = filesErrorReason(e); setErr( reason === "permanent_disabled" ? "设置里未开启永久删除(files.allowPermanentDelete)" : reason === "approval_required" ? "用 API Key 删除,每次都要审批" : filesErrorText(e), ); }, }, ); return ( !o && onClose()}> {single ? `删除 ${single}?` : `删除 ${items.length} 项?`} 已检查做种任务、已整理文件和路径绑定。下载器里的任务不受影响。
{check.isPending ? (

引用检查中…

) : null} {err ? {err} : null} {checks.length ? (
    {checks.map((c) => { const b = deleteBlockText(effective(c.blocked)); const r = rootById.get(c.root); return (
  • {b ? ( {b.soft ? "需确认" : "不能删除"} ) : ( 可删除 )} {baseName(c.path) || r?.name} {c.isDir ? "目录 · " : ""} {formatBytes(c.size, 1)}
    {b ? (

    {b.text}

    ) : null}
    {c.downloads.map((d) => (

    下载任务{" "} {d.taskId.slice(0, 8)} {" "} · {d.status} · H&R{" "} {d.hnr === "satisfied" ? "已满足" : d.hnr === "unsatisfied" ? "未满足" : "未知"}

    ))} {c.assets.length ? (

    已整理文件 {c.assets.length} 个(删除后从媒体库移除,已满足的收录版本退回待处理)

    ) : null} {c.bindings.map((bd) => (

    路径绑定:{bd.title} {bd.seasonNumber != null ? ` 第 ${bd.seasonNumber} 季` : ""}

    ))} {c.sharedInode ?

    共享 inode(硬链接):只删这一处链接,其他位置不受影响

    : null}
  • ); })}
) : null} {hasAssets ? ( ) : null} {hasBindings ? ( ) : null} {checks.length && deletable > 0 ? (
{mode === "permanent" ? ( 输入 {confirmPhrase} 以确认 } htmlFor="del-confirm" > setConfirmText(e.target.value)} className="font-mono" autoComplete="off" /> ) : null}
) : null}
); } // ─── 复制路径 ─────────────────────────────────────────────────── export function CopyPathDialog({ root, entry, onClose }: { root: FileRoot; entry: FileEntry; onClose: () => void }) { const stat = useFileStat(root.id, entry.path); const local = displayPath(root, entry.path); const rows = [ { label: root.storage ? "Alist 路径" : "本机路径", value: local }, { label: "真实路径(解析链接后)", value: stat.data?.realPath ?? null }, { label: "登记目录内相对路径", value: entry.path || "(登记目录本身)" }, ]; const copy = async (v: string) => { try { await navigator.clipboard.writeText(v); toast.success("已复制"); } catch { toast.error("浏览器不允许写剪贴板"); } }; return ( !o && onClose()}> 复制路径 {entry.name}
    {rows.map((r) => (
  • {r.label}

    {r.value ?? (stat.isPending ? "…" : "—")}

    {r.value ? ( ) : null}
  • ))}

下载器与媒体服务器看到的路径按「设置 → 路径映射」换算。

); } // ─── 刷新元数据 ───────────────────────────────────────────── /** * 刷新元数据(§7.2):只对已登记项;多选时按作品去重(POST /files/scrape-batch)。只更新本工具的字段,不写文件。 */ export function RefreshMetadataDialog({ items, onClose }: { items: FileEntry[]; onClose: () => void }) { const batch = useScrapeBatch(); const key = useMemo(() => newIdempotencyKey(), []); const [result, setResult] = useState(null); const entities = [...new Map(items.filter((e) => e.asset).map((e) => [e.asset?.mediaEntityId, e.asset])).values()]; const single = entities.length === 1 ? entities[0] : null; const run = () => batch.mutate( { 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)( `已提交 ${r.submitted.length} 个作品,锁定字段不变${skipped}`, ); }, onError: (e) => toast.error(filesErrorText(e, "提交失败")), }, ); return ( !o && onClose()}> 刷新元数据 {single ? ` · ${single.title}` : ` · ${entities.length} 个作品`} 按字段从 TMDB 刷新,锁定的字段和路径不变。不写目录里的文件。 {!single && !result ? (
    {entities.map((a) => (
  • {a?.title} {a?.year ? ` (${a.year})` : ""}
  • ))}
) : null} {result ? (
{result.submitted.map((s) => (

{s.title} 查看运行

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

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

{s.ref}: {s.reason === "no_entity" ? "不属于媒体库里的作品" : s.reason === "not_found" ? "作品不存在" : s.reason}

))}
) : null} {result ? null : ( )}
); } // ─── 刮削(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" | "update" | null>(null); const curMode = mode ?? settings.data?.organize.scrapeMode ?? "update"; 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}
{( [ ["update", "更新"], ["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}
); }