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
+427
-127
No files matched your search
@@ -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) });
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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,73 +70,42 @@ 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 ScrapePanel({ detail }: { detail: LibraryDetail }) {
|
||||
const s = detail.scrape ?? null;
|
||||
const scrape = useScrapeEntity(detail.entity.id);
|
||||
/** 一条刮削记录:状态、目录与识别结果、计数、逐文件结果(缺省不列"已存在"跳过的) */
|
||||
export function ScrapeRecordView({ record: s, showPath = false }: { record: ScrapeRecord; showPath?: boolean }) {
|
||||
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 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 (
|
||||
<Panel
|
||||
title="刮削"
|
||||
description={
|
||||
s?.finishedAt
|
||||
? `上次刮削 ${formatDateTime(s.finishedAt)} · ${s.trigger === "auto" ? "入库后自动" : "手动"}`
|
||||
: "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>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{!s ? (
|
||||
<p className="text-sm text-muted-foreground">还没刮削过。</p>
|
||||
) : (
|
||||
<div className="grid gap-3">
|
||||
<div className="grid gap-2">
|
||||
<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>
|
||||
<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}
|
||||
@@ -152,11 +123,67 @@ export function ScrapePanel({ detail }: { detail: LibraryDetail }) {
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ScrapePanel({ detail }: { detail: LibraryDetail }) {
|
||||
const s = detail.scrape ?? null;
|
||||
const scrape = useScrapeEntity(detail.entity.id);
|
||||
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;
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<Panel
|
||||
title="刮削"
|
||||
description={
|
||||
s?.finishedAt
|
||||
? `上次刮削 ${formatDateTime(s.finishedAt)} · ${s.trigger === "auto" ? "入库后自动" : "手动"}`
|
||||
: "NFO 和图片写进作品目录,Emby、Jellyfin、Kodi 直接读取。"
|
||||
}
|
||||
actions={
|
||||
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">
|
||||
<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) },
|
||||
]}
|
||||
|
||||
@@ -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)} />
|
||||
|
||||
Reference in new issue
Block a user