feat(contracts,server,web): files G6–G8 fields and identify S-7 contract
- G6: FileRoot.pendingCount/pendingPartial for download roots (same rule as the unidentified filter, recursive, cached 5 min, invalidated by files.changed) - G7: FileEntry.parsedHint from parseReleasePath (file + parent dirs); the web subline uses it and no longer parses names itself - G8: FileIdentifyCandidate.posterUrl (TMDB poster_path via adapter and catalog.lookup, local entities from metadata.posterPath); thumbnails in the identify panel - S-7 contract: FilesIdentifyResult.remoteTaskId/remotePending, MediaServerInventory.partial, EventType identify.completed with IdentifyCompletedPayload; identify panel (useIdentifyLive) and organize wizard merge remote results by root + path, buffering events that beat the response Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
fd2f7bc33d
commit
abc69886ea
26 files changed
+510
-134
No files matched your search
@@ -51,6 +51,7 @@ const item = (
|
||||
level: over.level ?? "high",
|
||||
score: 1,
|
||||
reasons: [],
|
||||
posterUrl: null,
|
||||
},
|
||||
],
|
||||
suggestion: { mediaEntityId: null, contentUnitId: null, contentUnit: null },
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { CursorPage, PathMapping } from "@umt/contracts";
|
||||
import { type CursorPage, EventType, type IdentifyCompletedPayload, type PathMapping } from "@umt/contracts";
|
||||
import { ArrowLeft, ArrowRight, Check, FileStack, LoaderCircle, RotateCw } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
@@ -24,6 +24,7 @@ import { useIsMobile } from "~/components/files/use-mobile.ts";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "~/components/ui/sheet.tsx";
|
||||
import { apiData } from "~/lib/api.ts";
|
||||
import { useSse } from "~/lib/sse.ts";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
import {
|
||||
applyEntityTo,
|
||||
@@ -109,7 +110,7 @@ function useResolvedSource(source: OrganizeSource, roots: FileRoot[] | undefined
|
||||
...base,
|
||||
items: ref ? [ref] : [],
|
||||
hnrPaths: ref && source.hnrBlocked ? [ref.path] : [],
|
||||
unresolved: ref ? [] : [source.contentPath ?? "(下载任务没有内容路径)"],
|
||||
unresolved: ref ? [] : [source.contentPath ?? "(下载任务没有内容路径)"],
|
||||
label: `下载任务 · ${source.name}`,
|
||||
};
|
||||
}
|
||||
@@ -159,6 +160,29 @@ export function OrganizeWizard({ source, onClose }: { source: OrganizeSource; on
|
||||
const exec = useOrganizeFiles();
|
||||
const records = useFileRecords({ ids: recordIds }, recordIds.length > 0);
|
||||
|
||||
// S-7:识别响应带 remoteTaskId 时,远端候选由 SSE identify.completed 补上(保留已选的作品 / 季集)。
|
||||
// 事件可能比响应先到:先到的暂存,响应回来再对账。
|
||||
const remote = useRef({ waiting: new Set<string>(), early: new Map<string, IdentifyCompletedPayload>() });
|
||||
const applyRemote = useCallback((p: IdentifyCompletedPayload) => {
|
||||
if (p.status === "succeeded") setRows((rs) => mergeIdentified(rs, p.items));
|
||||
else toast.error(`远端候选查询失败,只有本地结果${p.error ? `:${p.error.slice(0, 120)}` : ""}`);
|
||||
}, []);
|
||||
const expectRemote = useCallback(
|
||||
(taskId: string | null | undefined) => {
|
||||
if (!taskId) return;
|
||||
const early = remote.current.early.get(taskId);
|
||||
if (early) applyRemote(early);
|
||||
else remote.current.waiting.add(taskId);
|
||||
},
|
||||
[applyRemote],
|
||||
);
|
||||
useSse([EventType.identifyCompleted], (ev) => {
|
||||
const p = ev.payload as IdentifyCompletedPayload;
|
||||
if (remote.current.waiting.delete(p.taskId)) return applyRemote(p);
|
||||
remote.current.early.set(p.taskId, p);
|
||||
if (remote.current.early.size > 20) remote.current.early.delete(remote.current.early.keys().next().value as string);
|
||||
});
|
||||
|
||||
// 设置里的"整理默认"(方式 / 最小体积);读不到用默认值
|
||||
const ready = settings.isFetched || settings.isError || filesMockEnabled();
|
||||
const defaults = useMemo(() => {
|
||||
@@ -189,10 +213,11 @@ export function OrganizeWizard({ source, onClose }: { source: OrganizeSource; on
|
||||
}
|
||||
setRows(next);
|
||||
setTruncated(r.truncated);
|
||||
expectRemote(r.remoteTaskId);
|
||||
},
|
||||
},
|
||||
);
|
||||
}, [started, ready, src, defaults, identifyMutate]);
|
||||
}, [started, ready, src, defaults, identifyMutate, expectRemote]);
|
||||
|
||||
const active = rows.filter((r) => !r.excluded);
|
||||
const stats = wizardStats(rows);
|
||||
@@ -218,7 +243,10 @@ export function OrganizeWizard({ source, onClose }: { source: OrganizeSource; on
|
||||
reidentify.mutate(
|
||||
{ items: r.reidentify.map((x) => ({ root: x.root, path: x.path })), minSizeBytes: bytes },
|
||||
{
|
||||
onSuccess: (res) => setRows((rs) => mergeIdentified(rs, res.items)),
|
||||
onSuccess: (res) => {
|
||||
setRows((rs) => mergeIdentified(rs, res.items));
|
||||
expectRemote(res.remoteTaskId);
|
||||
},
|
||||
onError: (e) => toast.error(filesErrorText(e, "补充识别失败")),
|
||||
},
|
||||
);
|
||||
@@ -267,13 +295,13 @@ export function OrganizeWizard({ source, onClose }: { source: OrganizeSource; on
|
||||
(x) =>
|
||||
(x.entity?.id && ids.has(x.entity.id)) || (x.entity?.external && ext.has(x.entity.external.externalId)),
|
||||
);
|
||||
runPreview(again.length ? again : preview.rows, `${stale.length} 组源文件在预览后有变化,已重新预览`);
|
||||
runPreview(again.length ? again : preview.rows, `${stale.length} 组源文件在预览后有变化,已重新预览`);
|
||||
} else setPreview(null);
|
||||
},
|
||||
onError: (e) => {
|
||||
if (filesErrorReason(e) === "plan_stale") {
|
||||
runPreview(preview.rows, "源文件在预览后有变化,已重新预览");
|
||||
toast.warning("源文件在预览后有变化,已重新预览");
|
||||
runPreview(preview.rows, "源文件在预览后有变化,已重新预览");
|
||||
toast.warning("源文件在预览后有变化,已重新预览");
|
||||
} else toast.error(filesErrorText(e, "执行失败"));
|
||||
},
|
||||
});
|
||||
@@ -291,14 +319,14 @@ export function OrganizeWizard({ source, onClose }: { source: OrganizeSource; on
|
||||
body = (
|
||||
<p className="flex items-center gap-2 text-caption text-muted-foreground">
|
||||
<LoaderCircle className="h-4 w-4 animate-spin" />
|
||||
{src.pending ? "定位源文件…" : "识别中:匹配本地作品与 TMDB…"}
|
||||
{src.pending ? "定位源文件…" : "识别中:匹配本地作品与 TMDB…"}
|
||||
</p>
|
||||
);
|
||||
} else if (src.error || (!src.items.length && src.unresolved.length) || !src.items.length) {
|
||||
body = (
|
||||
<div className="space-y-2 text-caption">
|
||||
<p className="text-destructive">
|
||||
{src.error ? filesErrorText(src.error, "读取整理记录失败") : "源文件不在任何登记目录里,或已经不存在。"}
|
||||
{src.error ? filesErrorText(src.error, "读取整理记录失败") : "源文件不在任何登记目录里,或已经不存在。"}
|
||||
</p>
|
||||
{src.unresolved.length ? (
|
||||
<ul className="space-y-0.5 font-mono text-meta text-muted-foreground">
|
||||
@@ -310,7 +338,7 @@ export function OrganizeWizard({ source, onClose }: { source: OrganizeSource; on
|
||||
</ul>
|
||||
) : null}
|
||||
<p className="text-muted-foreground">
|
||||
在设置里检查下载器路径映射,或到
|
||||
在设置里检查下载器路径映射,或到
|
||||
<Link to="/console/files" className="mx-1 underline underline-offset-2" onClick={onClose}>
|
||||
文件管理
|
||||
</Link>
|
||||
@@ -335,7 +363,7 @@ export function OrganizeWizard({ source, onClose }: { source: OrganizeSource; on
|
||||
<div className="space-y-3">
|
||||
{src.unresolved.length ? (
|
||||
<p className="rounded-lg border border-warning/35 bg-warning/10 px-3 py-1.5 text-meta text-warning">
|
||||
{src.unresolved.length} 个源路径不在登记目录里或已不存在,已跳过。
|
||||
{src.unresolved.length} 个源路径不在登记目录里或已不存在,已跳过。
|
||||
</p>
|
||||
) : null}
|
||||
<StepFiles
|
||||
@@ -351,7 +379,7 @@ export function OrganizeWizard({ source, onClose }: { source: OrganizeSource; on
|
||||
onBulkOffset={(n) => {
|
||||
const r = applyOffsetTo(rows, selected, n);
|
||||
setRows(r.rows);
|
||||
if (r.failed) toast.warning(`${r.failed} 项偏移后集号小于 1,没有改`);
|
||||
if (r.failed) toast.warning(`${r.failed} 项偏移后集号小于 1,没有改`);
|
||||
}}
|
||||
minSizeMb={minSizeMb}
|
||||
onMinSizeMb={onMinSizeMb}
|
||||
@@ -390,7 +418,9 @@ export function OrganizeWizard({ source, onClose }: { source: OrganizeSource; on
|
||||
/>
|
||||
))}
|
||||
{executed ? (
|
||||
<p className="text-caption text-muted-foreground">整理在后台执行,状态每 3 秒刷新,文件列表会自动更新。</p>
|
||||
<p className="text-caption text-muted-foreground">
|
||||
整理在后台执行,状态每 3 秒刷新,文件列表会自动更新。
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
) : null}
|
||||
@@ -472,7 +502,7 @@ export function OrganizeWizard({ source, onClose }: { source: OrganizeSource; on
|
||||
<>
|
||||
{rows.length && !canNext ? (
|
||||
<span className="text-meta text-warning">
|
||||
{stats.pending ? `${stats.pending} 项待定:选作品或排除` : "没有要整理的项"}
|
||||
{stats.pending ? `${stats.pending} 项待定:选作品或排除` : "没有要整理的项"}
|
||||
</span>
|
||||
) : null}
|
||||
<Button onClick={() => setStep(1)} disabled={!canNext}>
|
||||
|
||||
Reference in new issue
Block a user