feat(web): 文件管理「刮削…」对任意目录可用,候选选择与刮削结果(SCR-2)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-01 00:08:17 +08:00
1 parent 87bf33b42b
commit d8ae5e179c
8 files changed
+426 -126

No files matched your search

+19 -2
View File
@@ -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) });
+1 -1
View File
@@ -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({
+201 -41
View File
@@ -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>
);
}
+84 -6
View File
@@ -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<string, ScrapeRecord>();
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<unknown>
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);
+9 -5
View File
@@ -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<typeof menuModel>) => 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");
});
+10 -7
View File
@@ -98,8 +98,11 @@ 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 "writeNfo":
if (root.kind === "library" && !root.writable) return "只读目录,不写 NFO";
if (e.status === "temp") return "未完成的文件不能刮削";
return null;
case "probe":
return e.type === "file" && e.fileKind === "video" ? null : "只能探测视频文件";
case "copy":
@@ -355,8 +358,10 @@ export function opApplies(op: MenuKey, e: FileEntry): boolean {
return !e.asset && (isVideo(e) || e.type === "dir") && e.status !== "temp";
case "identify":
return isVideo(e) || e.type === "dir";
case "scrape":
case "writeNfo":
// SCR-2:照 MP,任意视频文件或目录都能刮削(不要求已登记)
return isVideo(e) || e.type === "dir";
case "scrape":
case "openMedia":
return !!e.asset;
case "records":
@@ -417,10 +422,8 @@ 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);
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<MenuKey, string> = {
move: "移动到…",
copy: "复制到…",
scrape: "刷新元数据",
writeNfo: "刮削",
writeNfo: "刮削…",
probe: "探测",
copyPath: "复制路径…",
openMedia: "打开作品",
@@ -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<LibraryScrapeState["status"], { label: string; tone: StatusTone }> = {
export const SCRAPE_STATUS: Record<ScrapeRecord["status"], { label: string; tone: StatusTone }> = {
queued: { label: "排队中", tone: "neutral" },
running: { label: "刮削中…", tone: "neutral" },
succeeded: { label: "已完成", tone: "success" },
@@ -25,7 +25,7 @@ const STATUS: Record<LibraryScrapeState["status"], { label: string; tone: Status
const ACTION: Record<ScrapeFileResult["action"], { label: string; tone: StatusTone }> = {
written: { label: "已写入", tone: "success" },
skipped: { label: "已存在", tone: "neutral" },
skipped: { label: "跳过", tone: "neutral" },
failed: { label: "失败", tone: "destructive" },
};
@@ -40,10 +40,12 @@ const ROLE: Record<string, string> = {
episode_thumb: "剧照",
};
function counts(s: LibraryScrapeState): string {
const SOURCE: Record<string, string> = { library: "已入库的作品", auto: "按路径识别", chosen: "手动指定" };
export function scrapeCounts(s: Pick<ScrapeRecord, "written" | "skipped" | "failed">): 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 }) {
)}
</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>
{f.action !== "written" && f.reason && f.reason !== "scrape_exists" ? (
<span className={f.action === "failed" ? "text-meta text-destructive" : "text-meta text-muted-foreground"}>
{reasonLine(f.reason, "写入失败")}
</span>
) : null}
<StatusBadge tone={a.tone}>{a.label}</StatusBadge>
</li>
);
}
/** 一条刮削记录:状态、目录与识别结果、计数、逐文件结果(缺省不列"已存在"跳过的) */
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 (
<div className="grid gap-2">
<div className="flex flex-wrap items-center gap-2">
<StatusBadge tone={SCRAPE_STATUS[s.status].tone}>{SCRAPE_STATUS[s.status].label}</StatusBadge>
{showPath ? <span className="min-w-0 text-sm break-all text-foreground">{s.path || "目录根"}</span> : null}
{done && (files.length || s.status === "succeeded") ? (
<span className="text-sm text-muted-foreground">{scrapeCounts(s) || "没有要写的文件"}</span>
) : null}
{s.mode === "overwrite" ? <span className="text-meta text-muted-foreground">覆盖已有文件</span> : null}
</div>
{s.match ? (
<p className="text-meta text-muted-foreground">
{s.match.title}
{s.match.year ? ` (${s.match.year})` : ""} · {s.match.kind === "movie" ? "电影" : "剧集"} ·{" "}
{SOURCE[s.match.source] ?? s.match.source}
{s.match.mediaEntityId ? "" : " · 不在媒体库"}
</p>
) : null}
{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}
</div>
);
}
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 (
<Panel
@@ -102,61 +148,42 @@ export function ScrapePanel({ detail }: { detail: LibraryDetail }) {
description={
s?.finishedAt
? `上次刮削 ${formatDateTime(s.finishedAt)} · ${s.trigger === "auto" ? "入库后自动" : "手动"}`
: "NFO 和图片写进媒体库目录,Emby、Jellyfin、Kodi 直接读取。"
: "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>
</>
hasFiles ? (
<>
<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>
</>
) : null
}
>
{!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}
<ScrapeRecordView record={s} showPath />
<TechInfo
items={[
{ label: "覆盖方式", value: s.mode === "overwrite" ? "全部覆盖" : "只补缺失" },
{ label: "目录", value: `${s.root}:${s.path}`, mono: true },
s.match?.externalId ? { label: "TMDB", value: s.match.externalId, mono: true } : null,
s.taskId ? { label: "任务", value: s.taskId, mono: true } : null,
s.recordId ? { label: "整理记录", value: s.recordId, mono: true } : null,
s.organizationRecordId ? { label: "整理记录", value: s.organizationRecordId, mono: true } : null,
s.reason ? { label: "原因码", value: reasonCode(s.reason) ?? s.reason, mono: true } : null,
{ label: "提交于", value: formatDateTime(s.requestedAt) },
]}
+18 -7
View File
@@ -61,6 +61,7 @@ import {
MkdirDialog,
RefreshMetadataDialog,
RenameDialog,
ScrapeDialog,
TransferDialog,
} from "~/components/files/dialogs.tsx";
import { DirTree } from "~/components/files/dir-tree.tsx";
@@ -109,7 +110,8 @@ type Dialog =
| { kind: "delete"; items: FileRef[] }
| { kind: "mkdir" }
| { kind: "copyPath"; entry: FileEntry }
| { kind: "refresh"; items: FileEntry[]; write?: boolean }
| { kind: "refresh"; items: FileEntry[] }
| { kind: "scrape"; items: FileEntry[] }
| { kind: "wizard"; source: OrganizeSource }
| { kind: "shortcuts" };
@@ -459,7 +461,7 @@ export default function FilesPage() {
case "scrape":
return setDialog({ kind: "refresh", items: list });
case "writeNfo":
return setDialog({ kind: "refresh", items: list, write: true });
return setDialog({ kind: "scrape", items: list });
case "probe":
return doProbe(list);
case "copyPath":
@@ -709,9 +711,9 @@ export default function FilesPage() {
});
items.push({
key: "writeNfo",
label: "刮削",
label: "刮削…",
icon: FileDown,
disabled: selected.every((e) => 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() {
刷新元数据
</Button>
) : null}
{detail.asset ? (
<Button size="sm" variant="outline" onClick={() => act("writeNfo", [detail])}>
{opApplies("writeNfo", detail) ? (
<Button
size="sm"
variant="outline"
onClick={() => act("writeNfo", [detail])}
disabled={!!opDisabledReason("writeNfo", detail, root)}
title={opDisabledReason("writeNfo", detail, root) ?? undefined}
>
<FileDown />
刮削
</Button>
@@ -1224,7 +1232,10 @@ export default function FilesPage() {
<CopyPathDialog root={root} entry={dialog.entry} onClose={() => setDialog(null)} />
) : null}
{dialog?.kind === "refresh" ? (
<RefreshMetadataDialog items={dialog.items} write={dialog.write} onClose={() => setDialog(null)} />
<RefreshMetadataDialog items={dialog.items} onClose={() => setDialog(null)} />
) : null}
{dialog?.kind === "scrape" && root ? (
<ScrapeDialog items={dialog.items} root={root} 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)} />