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,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<FilesScrapeBatchResult | null>(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({
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{write ? "刮削" : "刷新元数据"}
|
||||
刷新元数据
|
||||
{single ? ` · ${single.title}` : ` · ${entities.length} 个作品`}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{write
|
||||
? "把 NFO 和图片写进媒体库目录,已有文件按设置只补缺失或全部覆盖。结果在作品详情的「刮削」里。"
|
||||
: "按字段从 TMDB 刷新,锁定的字段和路径不变。不写媒体库目录里的文件。"}
|
||||
</DialogDescription>
|
||||
<DialogDescription>按字段从 TMDB 刷新,锁定的字段和路径不变。不写目录里的文件。</DialogDescription>
|
||||
</DialogHeader>
|
||||
{!single && !result ? (
|
||||
<ul className="max-h-40 space-y-0.5 overflow-auto text-caption">
|
||||
@@ -1052,30 +1045,19 @@ export function RefreshMetadataDialog({
|
||||
<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={write ? `/console/library/${s.mediaEntityId}` : `/console/engine/${s.taskId}`}
|
||||
>
|
||||
{write ? "查看结果" : "查看运行"}
|
||||
<Link className="shrink-0 text-meta underline" to={`/console/engine/${s.taskId}`}>
|
||||
查看运行
|
||||
</Link>
|
||||
</p>
|
||||
{write ? null : (
|
||||
<p className="text-meta text-muted-foreground">
|
||||
{s.lockedFields.length ? `锁定字段:${s.lockedFields.join("、")}` : "没有锁定字段"}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-meta text-muted-foreground">
|
||||
{s.lockedFields.length ? `锁定字段:${s.lockedFields.join("、")}` : "没有锁定字段"}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
{result.skipped.map((s) => (
|
||||
<p key={s.ref} className="text-meta text-warning">
|
||||
{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}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
@@ -1087,7 +1069,7 @@ export function RefreshMetadataDialog({
|
||||
{result ? null : (
|
||||
<Button onClick={run} disabled={batch.isPending || !entities.length}>
|
||||
{batch.isPending ? <LoaderCircle className="animate-spin" /> : <RefreshCw />}
|
||||
{write ? "刮削" : "刷新"}
|
||||
刷新
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
@@ -1095,3 +1077,181 @@ export function RefreshMetadataDialog({
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 刮削(SCR-2) ─────────────────────────────────────────
|
||||
|
||||
const SKIP_TEXT: Record<string, string> = {
|
||||
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<string[]>([]);
|
||||
const [choices, setChoices] = useState<FilesScrapeNeedsChoice[]>([]);
|
||||
const [picked, setPicked] = useState<Record<string, WizardEntity | null>>({});
|
||||
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 (
|
||||
<Dialog open onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>刮削{single ? ` · ${single.name}` : ` · ${items.length} 项`}</DialogTitle>
|
||||
<DialogDescription>
|
||||
按路径识别作品,在所在目录写 NFO 和图片,Emby、Jellyfin、Kodi 直接读取。没入库的也能刮削。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{!started ? (
|
||||
<div className="grid gap-3">
|
||||
{!single ? (
|
||||
<ul className="max-h-40 space-y-0.5 overflow-auto text-caption">
|
||||
{items.map((e) => (
|
||||
<li key={e.path} className="truncate">
|
||||
{e.path}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
<FormField label="已有文件">
|
||||
<fieldset aria-label="已有文件" className="m-0 flex flex-wrap gap-1.5 border-0 p-0">
|
||||
{(
|
||||
[
|
||||
["missing", "只补缺失"],
|
||||
["overwrite", "全部覆盖"],
|
||||
] as const
|
||||
).map(([v, label]) => (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
aria-pressed={curMode === v}
|
||||
onClick={() => setMode(v)}
|
||||
className={cn(
|
||||
"inline-flex h-7 cursor-pointer items-center rounded-full border px-2.5 text-meta font-medium",
|
||||
curMode === v
|
||||
? "pill-selected text-foreground"
|
||||
: "border-border text-muted-foreground hover:bg-foreground/[0.06] hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</fieldset>
|
||||
</FormField>
|
||||
{curMode === "overwrite" ? (
|
||||
<p className="text-meta text-warning">目录里已有的 NFO 和图片会被替换,包括其他工具写的。</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid max-h-[60vh] gap-4 overflow-auto">
|
||||
{choices.length ? (
|
||||
<section className="grid gap-2">
|
||||
<p className="text-sm font-medium">认不准作品,选一个再刮削</p>
|
||||
{choices.map((c) => (
|
||||
<div key={c.path} className="grid gap-1.5 rounded-lg border border-border/60 px-3 py-2">
|
||||
<p className="text-caption break-all">{c.path || "目录根"}</p>
|
||||
<EntityPicker
|
||||
candidates={c.candidates}
|
||||
value={picked[c.path] ?? null}
|
||||
onChange={(e) => setPicked((p) => ({ ...p, [c.path]: e }))}
|
||||
placeholder={c.candidates.length ? "选择作品…" : "搜索作品…"}
|
||||
/>
|
||||
{c.warnings.map((w) => (
|
||||
<p key={w} className="text-meta text-muted-foreground">
|
||||
{w}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center gap-2 text-caption">
|
||||
<Switch id="scrape-add-to-library" checked={addToLibrary} onCheckedChange={setAddToLibrary} />
|
||||
<label htmlFor="scrape-add-to-library">同时加入媒体库</label>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
{recs.map((r) => (
|
||||
<div key={r.id} className="rounded-lg border border-border/60 px-3 py-2">
|
||||
<ScrapeRecordView record={r} showPath />
|
||||
{r.match?.mediaEntityId ? (
|
||||
<Link className="text-meta underline" to={`/console/library/${r.match.mediaEntityId}`}>
|
||||
打开作品
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
{recordIds.length && !recs.length ? <p className="text-caption text-muted-foreground">加载中…</p> : null}
|
||||
{skipped.map((x) => (
|
||||
<p key={x.path} className="text-meta text-warning break-all">
|
||||
{x.path || "目录根"}:{SKIP_TEXT[x.reason] ?? x.reason}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
{started ? "完成" : "取消"}
|
||||
</Button>
|
||||
{!started ? (
|
||||
<Button onClick={() => submit(items.map((e) => ({ path: e.path })))} disabled={scrape.isPending}>
|
||||
{scrape.isPending ? <LoaderCircle className="animate-spin" /> : <FileDown />}
|
||||
{scrape.isPending ? "识别中…" : "刮削"}
|
||||
</Button>
|
||||
) : choices.length ? (
|
||||
<Button
|
||||
onClick={() => submit(ready.map((c) => ({ path: c.path, pick: picked[c.path] })))}
|
||||
disabled={scrape.isPending || !ready.length}
|
||||
>
|
||||
{scrape.isPending ? <LoaderCircle className="animate-spin" /> : <FileDown />}
|
||||
继续刮削
|
||||
</Button>
|
||||
) : pending ? (
|
||||
<Button disabled>
|
||||
<LoaderCircle className="animate-spin" />
|
||||
刮削中…
|
||||
</Button>
|
||||
) : null}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user