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:
rootandClaude Opus 5.5 committed 2026-09-28 20:09:29 +08:00
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}>