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

+8 -2
View File
@@ -123,7 +123,7 @@ function RootNode({
if (!open.has(k)) toggle(k);
}}
onDrop={onDropTo && root.writable && !offline ? (e) => onDropTo({ root: root.id, path: "" }, e) : undefined}
title={disabled ? (offline ? "目录离线" : "只读目录,不能作为目标") : root.hostPath}
title={disabled ? (offline ? "目录离线" : "只读目录,不能作为目标") : root.hostPath}
>
<Icon className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1">
@@ -146,6 +146,12 @@ function RootNode({
正常
</span>
)}
{root.pendingCount ? (
<span className="shrink-0 text-warning">
待识别 {root.pendingCount}
{root.pendingPartial ? "+" : ""}
</span>
) : null}
{root.usage ? <span className="truncate">剩余 {formatBytes(root.usage.freeBytes, 1)}</span> : null}
{!root.writable && root.kind === "library" ? <span>只读</span> : null}
</span>
@@ -258,7 +264,7 @@ function Children({
})}
{q.data?.truncated ? (
<p className="py-1 text-meta text-muted-foreground" style={{ paddingLeft: depth * 14 + 28 }}>
子目录过多,只列出一部分
子目录过多,只列出一部分
</p>
) : null}
</div>
@@ -1,7 +1,7 @@
import { LoaderCircle, Sparkles, Wand2 } from "lucide-react";
import { useEffect } from "react";
import { Link } from "react-router";
import { type FileEntry, type FileRoot, filesErrorText, useIdentify } from "~/api/files.ts";
import { type FileEntry, type FileRoot, filesErrorText } from "~/api/files.ts";
import { RECORD_STATUS_LABEL, RECORD_STATUS_TONE } from "~/api/records.ts";
import { DetailSection } from "~/components/console/detail-sheet.tsx";
import { KvList } from "~/components/console/kv-list.tsx";
@@ -10,6 +10,7 @@ import { candidateEntity, LEVEL, reasonsText } from "~/components/organize-wizar
import type { WizardEntity } from "~/components/organize-wizard/model.ts";
import { Button } from "~/components/ui/button.tsx";
import { contentUnitLabel } from "./model.ts";
import { useIdentifyLive } from "./use-identify-live.ts";
const KIND: Record<string, string> = { movie: "电影", series: "剧集", variety: "综艺" };
@@ -28,7 +29,7 @@ export function IdentifyPanel({
autoRun?: boolean;
onOrganize?: (preset: WizardEntity | null) => void;
}) {
const identify = useIdentify();
const identify = useIdentifyLive();
const mutate = identify.mutate;
// biome-ignore lint/correctness/useExhaustiveDependencies: 换文件或 autoRun 时重跑一次
useEffect(() => {
@@ -56,7 +57,7 @@ export function IdentifyPanel({
) : identify.error ? (
<p className="text-caption text-destructive">{filesErrorText(identify.error, "识别失败")}</p>
) : !identify.data ? (
<p className="text-caption text-muted-foreground">按文件名解析,匹配本地作品和 TMDB。只读,不改文件。</p>
<p className="text-caption text-muted-foreground">按文件名解析,匹配本地作品和 TMDB。只读,不改文件。</p>
) : !it ? (
<p className="text-caption text-muted-foreground">{isDir ? "目录里没有视频文件。" : "没有识别结果。"}</p>
) : (
@@ -64,7 +65,7 @@ export function IdentifyPanel({
{isDir && (identify.data.items.length > 1 || identify.data.truncated) ? (
<p className="text-meta text-muted-foreground">
目录里有 {identify.data.items.length}
{identify.data.truncated ? "+" : ""} 个视频,这里只显示第一个。整理时逐项确认。
{identify.data.truncated ? "+" : ""} 个视频,这里只显示第一个。整理时逐项确认。
</p>
) : null}
<KvList
@@ -86,7 +87,7 @@ export function IdentifyPanel({
这个源文件有 {it.records.count} 条整理记录
{it.records.lastStatus ? (
<StatusBadge tone={RECORD_STATUS_TONE[it.records.lastStatus]}>
最近:{RECORD_STATUS_LABEL[it.records.lastStatus]}
最近:{RECORD_STATUS_LABEL[it.records.lastStatus]}
</StatusBadge>
) : null}
{it.records.lastRecordId ? (
@@ -97,7 +98,7 @@ export function IdentifyPanel({
</p>
) : null}
{it.skipped === "below_min_size" ? (
<p className="text-caption text-muted-foreground">低于最小体积,没有查候选。</p>
<p className="text-caption text-muted-foreground">低于最小体积,没有查候选。</p>
) : null}
{it.warnings.map((w) => (
<p key={w} className="text-caption text-warning">
@@ -113,6 +114,17 @@ export function IdentifyPanel({
key={`${c.provider}-${c.mediaEntityId ?? c.externalId}`}
className="flex items-start gap-2 px-3 py-2"
>
{c.posterUrl ? (
<img
src={c.posterUrl}
alt=""
loading="lazy"
referrerPolicy="no-referrer"
className="h-12 w-8 shrink-0 rounded-sm bg-muted object-cover"
/>
) : (
<span aria-hidden className="h-12 w-8 shrink-0 rounded-sm bg-muted" />
)}
<div className="min-w-0 flex-1">
<p className="flex flex-wrap items-center gap-1.5 text-caption text-foreground">
<span className="truncate">
+44 -20
View File
@@ -58,7 +58,7 @@ const dune = (cu: string | null = null) => ({
assetId: "a-dune",
locationId: "l-dune",
mediaEntityId: "e-dune",
title: "沙丘2",
title: "沙丘 2",
year: 2024,
contentUnit: cu,
slotId: "s-dune-uhd",
@@ -97,6 +97,8 @@ function makeRoots(): { roots: FileRoot[]; trees: Record<string, Node> } {
checkedAt: ago(0.01),
healthReason: null,
usage: { totalBytes: 16_000 * GB, freeBytes: 3_420 * GB },
pendingCount: null,
pendingPartial: false,
},
{
id: "lib:tv",
@@ -109,11 +111,13 @@ function makeRoots(): { roots: FileRoot[]; trees: Record<string, Node> } {
checkedAt: ago(0.01),
healthReason: null,
usage: { totalBytes: 16_000 * GB, freeBytes: 3_420 * GB },
pendingCount: null,
pendingPartial: false,
},
{
id: "lib:nas-old",
kind: "library",
name: "旧 NAS(只读登记)",
name: "旧 NAS(只读登记)",
hostPath: "/mnt/nas-old",
mediaKind: "mixed",
writable: false,
@@ -121,6 +125,8 @@ function makeRoots(): { roots: FileRoot[]; trees: Record<string, Node> } {
checkedAt: ago(3),
healthReason: "挂载点无响应(ENOTCONN)",
usage: null,
pendingCount: null,
pendingPartial: false,
},
{
id: "dl:qb-main:0",
@@ -133,6 +139,8 @@ function makeRoots(): { roots: FileRoot[]; trees: Record<string, Node> } {
checkedAt: ago(0.01),
healthReason: null,
usage: { totalBytes: 8_000 * GB, freeBytes: 1_210 * GB },
pendingCount: 12,
pendingPartial: false,
},
];
const tlouEps = [1, 2, 3, 4, 5, 6, 7].map((ep) =>
@@ -302,7 +310,7 @@ function lazyChildren(n: Node): Node[] {
function rootOf(root: string): FileRoot {
const r = state.roots.find((x) => x.id === root);
if (!r) throw new ApiRequestError("not_found", "not_found: 根不存在或未登记");
if (!r) throw new ApiRequestError("not_found", "not_found:根不存在或未登记");
if (r.health === "offline")
throw new ApiRequestError("upstream_unavailable", `root_unavailable: ${r.healthReason ?? "挂载点无响应"}`);
return r;
@@ -311,12 +319,12 @@ function rootOf(root: string): FileRoot {
function find(root: string, path: string): { node: Node; parent: Node | null } {
rootOf(root);
let node = state.trees[root];
if (!node) throw new ApiRequestError("not_found", "not_found: 根不存在");
if (!node) throw new ApiRequestError("not_found", "not_found:根不存在");
let parent: Node | null = null;
for (const seg of path.split("/").filter(Boolean)) {
if (seg === "..") throw new ApiRequestError("forbidden", "path_escape: 路径越界");
if (seg === "..") throw new ApiRequestError("forbidden", "path_escape:路径越界");
const next = lazyChildren(node).find((c) => c.name === seg);
if (!next) throw new ApiRequestError("not_found", `not_found: 不存在 ${path}`);
if (!next) throw new ApiRequestError("not_found", `not_found:不存在 ${path}`);
parent = node;
node = next;
}
@@ -356,9 +364,22 @@ function toEntry(n: Node, path: string): FileEntry {
download: n.meta?.download ?? null,
media: n.meta?.media ?? null,
dirSummary,
parsedHint: status === "unidentified" ? mockHint(n.name) : null,
};
}
/** mock 版 G7 parsedHint(真实后端用 parseReleasePath) */
function mockHint(name: string): string | null {
const p = parseName(name);
if (!p.title) return null;
const pad = (x: number) => String(x).padStart(2, "0");
const unit =
p.season != null
? `S${pad(p.season)}${p.episodes.length ? `E${pad(p.episodes[0] as number)}${p.episodes.length > 1 ? `-E${pad(p.episodes[p.episodes.length - 1] as number)}` : ""}` : ""}`
: null;
return [!unit && p.year ? `${p.title} (${p.year})` : p.title, unit, p.resolution].filter(Boolean).join(" · ");
}
const STATUS_RANK: Record<string, number> = {
temp: 0,
downloading: 1,
@@ -560,7 +581,7 @@ function parseName(name: string) {
const KNOWN: { match: RegExp; id: string; title: string; year: number; kind: "movie" | "series" }[] = [
{ match: /oppenheimer/i, id: "e-opp", title: "奥本海默", year: 2023, kind: "movie" },
{ match: /dune/i, id: "e-dune", title: "沙丘2", year: 2024, kind: "movie" },
{ match: /dune/i, id: "e-dune", title: "沙丘 2", year: 2024, kind: "movie" },
{ match: /last of us/i, id: "e-tlou", title: "最后生还者", year: 2023, kind: "series" },
];
@@ -624,6 +645,7 @@ function identify(
level: "high" as const,
score: 0.9,
reasons: ["TMDB 标题命中", "季集结构一致"],
posterUrl: null,
},
]
: hit
@@ -638,17 +660,19 @@ function identify(
level: "high" as const,
score: 0.94,
reasons: ["本地作品别名命中", parsed.year ? "年份一致" : "季集结构一致"],
posterUrl: null,
},
{
mediaEntityId: null,
provider: "tmdb",
externalId: "99999",
title: `${parsed.title}(同名旧作)`,
title: `${parsed.title}(同名旧作)`,
year: (parsed.year ?? hit.year) - 30,
kind: hit.kind,
level: "low" as const,
score: 0.31,
reasons: ["仅标题相似"],
posterUrl: null,
},
]
: [];
@@ -683,7 +707,7 @@ function doMove(body: {
const tRoot = rootOf(body.target.root);
const target = find(body.target.root, body.target.path).node;
if (!target.dir || !tRoot.writable)
throw new ApiRequestError("bad_request", "not_writable: 目标必须是可写库根下的目录");
throw new ApiRequestError("bad_request", "not_writable:目标必须是可写库根下的目录");
const pairs = body.items.map((it) => {
const { node } = find(it.root, it.path);
const name = node.name;
@@ -727,12 +751,12 @@ function doMove(body: {
executed: null,
};
if (body.dryRun) return res;
if (hnrBlocks.length) throw new ApiRequestError("conflict", "download_active: 含 H&R 未满足的做种文件,不能移动");
if (hnrBlocks.length) throw new ApiRequestError("conflict", "download_active:含 H&R 未满足的做种文件,不能移动");
const succeeded: string[] = [];
const failed: { path: string; code: string; message: string }[] = [];
for (const p of pairs) {
if (p.conflict) {
failed.push({ path: p.source.path, code: "exists", message: "目标已存在同名项,不覆盖" });
failed.push({ path: p.source.path, code: "exists", message: "目标已存在同名项,不覆盖" });
continue;
}
const n = body.mode === "move" ? detach(p.source) : clone(find(p.source.root, p.source.path).node);
@@ -837,7 +861,7 @@ function organize(body: {
recordId: body.dryRun || conflict ? null : `rec-${++seq}`,
taskId: body.dryRun || conflict ? null : `task-${seq}`,
warnings: items.some((x) => x.season != null) && !tv ? ["电影不使用季/集覆写"] : [],
error: conflict ? { code: "plan_blocked", message: "目标已存在同名文件,按“交给待处理”整组不执行" } : null,
error: conflict ? { code: "plan_blocked", message: "目标已存在同名文件,按「交给待处理」整组不执行" } : null,
action: body.action,
entity: items.some((x) => x.external)
? { provider: "tmdb", externalId: "95396", created: true, title: "人生切割术" }
@@ -901,16 +925,16 @@ export async function mockFiles(path: string, arg: FetcherArg): Promise<unknown>
case "/files/mkdir": {
const { node } = find(b.root, b.path);
if ((node.children ?? []).some((c) => c.name === b.name))
throw new ApiRequestError("conflict", "exists: 同名目录已存在");
throw new ApiRequestError("conflict", "exists:同名目录已存在");
node.children = [...(node.children ?? []), d(b.name, 0, [])];
return { root: b.root, path: join(b.path, b.name) };
}
case "/files/rename": {
const { node, parent } = find(b.root, b.path);
if (node.meta?.download)
throw new ApiRequestError("conflict", "download_active: 做种中,改名会让下载器找不到文件");
throw new ApiRequestError("conflict", "download_active:做种中,改名会让下载器找不到文件");
if ((parent?.children ?? []).some((c) => c.name === b.newName && c !== node))
throw new ApiRequestError("conflict", "exists: 目标名已存在,不覆盖");
throw new ApiRequestError("conflict", "exists:目标名已存在,不覆盖");
const stem = node.name.replace(/\.[^.]+$/, "");
const newStem = String(b.newName).replace(/\.[^.]+$/, "");
const dirPath = String(b.path).split("/").slice(0, -1).join("/");
@@ -964,7 +988,7 @@ export async function mockFiles(path: string, arg: FetcherArg): Promise<unknown>
recordId: null,
};
if (b.dryRun) return base;
if (checks.some((c) => c.blocked)) throw new ApiRequestError("conflict", "download_active: 有被引用项,不能删除");
if (checks.some((c) => c.blocked)) throw new ApiRequestError("conflict", "download_active:有被引用项,不能删除");
const trashed: TrashItem[] = [];
for (const it of b.items as ItemRef[]) {
const n = detach(it);
@@ -1000,7 +1024,7 @@ export async function mockFiles(path: string, arg: FetcherArg): Promise<unknown>
try {
const { node } = find(rootId, parentPath);
if ((node.children ?? []).some((c) => c.name === name)) {
failed.push({ id: t.id, code: "exists", message: `原位置已被占用:${t.originalPath}` });
failed.push({ id: t.id, code: "exists", message: `原位置已被占用:${t.originalPath}` });
continue;
}
node.children = [...(node.children ?? []), t.isDir ? d(name, 1, []) : f(name, t.size, 1)];
@@ -1113,7 +1137,7 @@ export async function mockFiles(path: string, arg: FetcherArg): Promise<unknown>
})),
};
default:
throw new ApiRequestError("not_found", `mock 未实现:${path}`);
throw new ApiRequestError("not_found", `mock 未实现:${path}`);
}
}
@@ -1146,8 +1170,8 @@ function mockRecord(id: string) {
export async function mockPreview(root: string, path: string): Promise<string> {
await delay(100);
if (path.endsWith(".nfo"))
return `<?xml version="1.0" encoding="utf-8"?>\n<!-- umt -->\n<movie>\n <title>沙丘2</title>\n <year>2024</year>\n</movie>`;
return `<?xml version="1.0" encoding="utf-8"?>\n<!-- umt -->\n<movie>\n <title>沙丘 2</title>\n <year>2024</year>\n</movie>`;
if (/\.(srt|ass)$/.test(path))
return "1\n00:00:01,000 --> 00:00:04,000\n厄拉科斯……\n\n2\n00:00:05,000 --> 00:00:07,500\n一切都从这里开始。";
return "1\n00:00:01,000 --> 00:00:04,000\n 厄拉科斯…\n\n2\n00:00:05,000 --> 00:00:07,500\n 一切都从这里开始。";
return `(${root}:${path})`;
}
+57 -3
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import { resolveFilesMock } from "~/api/files.ts";
import {
applyIdentifyCompleted,
clickSelect,
collapseCrumbs,
deleteBlockText,
@@ -17,7 +18,6 @@ import {
opApplies,
opDisabledReason,
parseEpisodes,
parseHint,
pruneSelection,
selectAll,
shiftEpisodes,
@@ -84,6 +84,7 @@ const base = {
download: null,
media: null,
dirSummary: null,
parsedHint: null,
};
describe("状态与禁用原因", () => {
@@ -173,6 +174,7 @@ describe("v2 菜单与副行", () => {
name: "The.Last.of.Us.S01E02.1080p.WEB-DL.mkv",
fileKind: "video" as const,
status: "unidentified" as const,
parsedHint: "The Last of Us · S01E02 · 1080p",
};
const asset = {
assetId: "a",
@@ -227,8 +229,8 @@ describe("v2 菜单与副行", () => {
dirSummary: { videoCount: 13, registeredCount: 0, partial: false },
})?.text,
).toBe("13 视频 · 0 已登记");
expect(parseHint("Oppenheimer.2023.2160p.BluRay.x265.mkv")).toBe("Oppenheimer (2023) · 2160p");
expect(parseHint("readme.txt")).toBeNull();
// 没有 parsedHint(服务端解析不出标题)时不显示副行,前端不自己解析
expect(entrySubline({ ...video, parsedHint: null })).toBeNull();
});
it("初始位置:上次位置 > 下载目录 > 库根;离线根跳过", () => {
@@ -275,3 +277,55 @@ describe("transferTargetProblem(U13)", () => {
expect(transferTargetProblem(items, { root: "dl", path: "packs" })).toBeNull();
});
});
describe("识别远端回填(S-7)", () => {
const item = (path: string, over: Record<string, unknown> = {}) => ({
root: "dl:x:0",
path,
parsed: {
title: "T",
year: null,
season: null,
episodes: [],
resolution: null,
source: null,
releaseGroup: null,
mediaKind: null,
},
candidates: [],
suggestion: { mediaEntityId: null, contentUnitId: null, contentUnit: null },
existing: { assetId: null },
warnings: ["远端候选在后台查询,查完自动补上"],
...over,
});
const base = { items: [item("a.mkv"), item("b.mkv")], truncated: false, remoteTaskId: "run-1", remotePending: 1 };
it("同一 taskId 按 root + path 替换条目并清掉待查标记", () => {
const done = item("b.mkv", { warnings: [], candidates: [{ title: "B" }] });
const r = applyIdentifyCompleted(
base as never,
{
taskId: "run-1",
status: "succeeded",
items: [done],
truncated: false,
excludedBySize: 0,
error: null,
} as never,
);
expect(r.remoteTaskId).toBeNull();
expect(r.remotePending).toBe(0);
expect(r.items[0]).toBe(base.items[0]);
expect(r.items[1]).toBe(done);
});
it("别的 taskId 原样返回;失败时保留本地结果并写明原因", () => {
const other = { taskId: "run-2", status: "succeeded", items: [], truncated: false, excludedBySize: 0, error: null };
expect(applyIdentifyCompleted(base as never, other as never)).toBe(base);
const f = applyIdentifyCompleted(
base as never,
{ ...other, taskId: "run-1", status: "failed", error: "超时" } as never,
);
expect(f.remoteTaskId).toBeNull();
expect(f.items[0]?.warnings).toEqual(["远端查询失败,只有本地结果:超时"]);
});
});
+57 -46
View File
@@ -1,5 +1,12 @@
// 文件管理页的纯逻辑:状态文案、选择模型、键盘导航、路径工具。无 React 依赖,单测覆盖(model.test.ts)。
import type { FileEntry, FileRoot, FileStatus } from "~/api/files.ts";
import type {
FileEntry,
FileIdentifyItem,
FileRoot,
FileStatus,
FilesIdentifyResult,
IdentifyCompletedPayload,
} from "~/api/files.ts";
import type { StatusTone } from "~/components/console/status-badge.tsx";
// ─── 状态徽标 ────────────────────────────────────────────────────
@@ -8,7 +15,7 @@ export type StatusView = { label: string; tone: StatusTone; hint?: string };
/** 行主状态 → 徽标(文字必带,不只靠颜色) */
export function entryStatusView(e: Pick<FileEntry, "status" | "download" | "escape" | "type">): StatusView | null {
if (e.escape) return { label: "链接越界", tone: "destructive", hint: "符号链接指向登记目录之外,所有操作已禁用" };
if (e.escape) return { label: "链接越界", tone: "destructive", hint: "符号链接指向登记目录之外,所有操作已禁用" };
const s: FileStatus = e.status;
switch (s) {
case "temp":
@@ -72,17 +79,17 @@ export function opDisabledReason(op: FileOp, e: FileEntry, root: Pick<FileRoot,
const hnrBlocked = e.download && (e.download.hnr !== "satisfied" || e.status === "downloading");
switch (op) {
case "rename":
if (!rootWritable) return "只读目录,不能改名";
if (seeding) return "做种中,H&R 满足前不能改名";
if (!rootWritable) return "只读目录,不能改名";
if (seeding) return "做种中,H&R 满足前不能改名";
if (e.status === "temp") return "临时文件不能改名";
return null;
case "move":
if (!rootWritable) return "只读目录,不能移出。可以复制";
if (hnrBlocked) return "做种中,H&R 满足前不能移动";
if (!rootWritable) return "只读目录,不能移出。可以复制";
if (hnrBlocked) return "做种中,H&R 满足前不能移动";
return null;
case "delete":
if (!rootWritable) return "只读目录,不能删除";
if (seeding) return "下载任务仍在使用,不能删除";
if (!rootWritable) return "只读目录,不能删除";
if (seeding) return "下载任务仍在使用,不能删除";
return null;
case "organize":
case "identify":
@@ -90,7 +97,7 @@ export function opDisabledReason(op: FileOp, e: FileEntry, root: Pick<FileRoot,
if (e.type === "file" && e.fileKind !== "video") return "只有视频或目录能整理";
return null;
case "scrape":
return e.asset ? null : "未登记,先整理入库";
return e.asset ? null : "未登记,先整理入库";
case "probe":
return e.type === "file" && e.fileKind === "video" ? null : "只能探测视频文件";
case "copy":
@@ -189,8 +196,8 @@ export function transferTargetProblem(
for (const it of items) {
if (it.root !== target.root) continue;
if (target.path === it.path || target.path.startsWith(`${it.path}/`))
return `目标在源目录之内(${baseName(it.path)}),换一个目录`;
if (parentRel(it.path) === target.path) return `${baseName(it.path)} 已在这个目录里,换一个目录`;
return `目标在源目录之内(${baseName(it.path)}),换一个目录`;
if (parentRel(it.path) === target.path) return `${baseName(it.path)} 已在这个目录里,换一个目录`;
}
return null;
}
@@ -302,11 +309,11 @@ export function deleteBlockText(b: string | null): { text: string; soft: boolean
case "is_root":
return { text: "不能删除登记目录本身", soft: false };
case "needs_retire":
return { text: "含已登记资产,勾选“同时退役资产”后才能删除", soft: true };
return { text: "含已登记资产,勾选「同时退役资产」后才能删除", soft: true };
case "needs_unbind":
return { text: "是作品或季的绑定目录,勾选“解除路径绑定”后才能删除", soft: true };
return { text: "是作品或季的绑定目录,勾选「解除路径绑定」后才能删除", soft: true };
case "root_unavailable":
return { text: "目录离线,不能确认文件状态", soft: false };
return { text: "目录离线,不能确认文件状态", soft: false };
case "not_found":
return { text: "文件已不存在", soft: false };
case "escape":
@@ -433,34 +440,7 @@ export const OP_LABEL: Record<MenuKey, string> = {
delete: "删除",
};
/** 从文件名做轻量解析(只用于待识别行的灰字副行,不查库):标题 · S01E01 · 1080p */
export function parseHint(name: string): string | null {
const base = name.replace(/\.[A-Za-z0-9]{1,5}$/, "");
const se = /\bS(\d{1,2})[ ._-]?E(\d{1,3})(?:-?E?(\d{1,3}))?/i.exec(base);
const season = /\bS(\d{1,2})\b/i.exec(base);
const yr = /[.( [](19\d{2}|20\d{2})(?=[.) \]]|$)/.exec(base);
const res = /\b(2160p|1080p|1080i|720p|4K)\b/i.exec(base);
const cuts = [se?.index, season?.index, yr?.index, res?.index].filter((x): x is number => x != null);
const cut = cuts.length ? Math.min(...cuts) : -1;
if (cut <= 0) return null;
const title = base
.slice(0, cut)
.replace(/[._]+/g, " ")
.replace(/[-([ ]+$/, "")
.trim();
if (!title) return null;
const parts = [title];
if (yr && !se) parts[0] = `${title} (${yr[1]})`;
if (se) {
const s = `S${String(Number(se[1])).padStart(2, "0")}`;
const e1 = `E${String(Number(se[2])).padStart(2, "0")}`;
parts.push(se[3] ? `${s}${e1}-E${String(Number(se[3])).padStart(2, "0")}` : `${s}${e1}`);
} else if (season) parts.push(`S${String(Number(season[1])).padStart(2, "0")}`);
if (res) parts.push((res[1] as string).toLowerCase() === "4k" ? "2160p" : (res[1] as string).toLowerCase());
return parts.join(" · ");
}
/** 名称列副行(§4.2):已登记 → 作品 (年) · 季集;待识别视频 → 解析灰字;目录 → n 视频 · m 已登记 */
/** 名称列副行(§4.2):已登记 → 作品 (年) · 季集;待识别视频 → 服务端轻量解析 parsedHint(G7);目录 → n 视频 · m 已登记 */
export function entrySubline(e: FileEntry): { text: string; tone: "link" | "hint" | "meta" } | null {
if (e.asset) {
const t = `${e.asset.title}${e.asset.year ? ` (${e.asset.year})` : ""}`;
@@ -471,10 +451,7 @@ export function entrySubline(e: FileEntry): { text: string; tone: "link" | "hint
if (!s.videoCount && !s.registeredCount) return null;
return { text: `${s.videoCount}${s.partial ? "+" : ""} 视频 · ${s.registeredCount} 已登记`, tone: "meta" };
}
if (e.type === "file" && e.fileKind === "video" && e.status === "unidentified") {
const h = parseHint(e.name);
return h ? { text: h, tone: "hint" } : null;
}
if (e.parsedHint) return { text: e.parsedHint, tone: "hint" };
return null;
}
@@ -505,3 +482,37 @@ export function nameTaken(name: string, self: string, siblings: string[]): boole
if (!n || n === self) return false;
return siblings.some((s) => s !== self && s === n);
}
// ─── 识别:远端结果回填(S-7) ────────────────────────────────────
const identifyKey = (it: Pick<FileIdentifyItem, "root" | "path">) => `${it.root}\u0000${it.path}`;
/** 服务端给"远端在后台查"项加的提示;结果回来后去掉 */
const REMOTE_PENDING_WARNING = /^远端候选在后台查询/;
/**
* SSE identify.completed 回填:taskId 与响应的 remoteTaskId 一致时,按 root + path 用完整结果替换条目;
* 失败时保留本地结果,把"后台查询中"提示换成失败原因。不相关的事件原样返回。
*/
export function applyIdentifyCompleted(r: FilesIdentifyResult, p: IdentifyCompletedPayload): FilesIdentifyResult {
if (!r.remoteTaskId || p.taskId !== r.remoteTaskId) return r;
if (p.status === "failed") {
const note = `远端查询失败,只有本地结果${p.error ? `:${p.error.slice(0, 200)}` : ""}`;
return {
...r,
remoteTaskId: null,
remotePending: 0,
items: r.items.map((it) =>
it.warnings.some((w) => REMOTE_PENDING_WARNING.test(w))
? { ...it, warnings: [...it.warnings.filter((w) => !REMOTE_PENDING_WARNING.test(w)), note] }
: it,
),
};
}
const by = new Map(p.items.map((it) => [identifyKey(it), it]));
return {
...r,
remoteTaskId: null,
remotePending: 0,
items: r.items.map((it) => by.get(identifyKey(it)) ?? it),
};
}
@@ -0,0 +1,29 @@
import { EventType } from "@umt/contracts";
import { useEffect, useRef, useState } from "react";
import { type FilesIdentifyResult, type IdentifyCompletedPayload, useIdentify } from "~/api/files.ts";
import { useSse } from "~/lib/sse.ts";
import { applyIdentifyCompleted } from "./model.ts";
/**
* useIdentify + 远端结果回填(S-7):响应带 remoteTaskId 时,用 SSE identify.completed 里同一 taskId 的完整结果
* 按 root + path 替换条目。事件可能比 HTTP 响应先到,所以挂载期间一直订阅,并缓存最近几条。
* data 是回填后的结果;remotePending 表示还在等远端。
*/
export function useIdentifyLive() {
const m = useIdentify();
const [data, setData] = useState<FilesIdentifyResult | undefined>(undefined);
const early = useRef(new Map<string, IdentifyCompletedPayload>());
useEffect(() => {
const d = m.data;
const hit = d?.remoteTaskId ? early.current.get(d.remoteTaskId) : undefined;
setData(d && hit ? applyIdentifyCompleted(d, hit) : d);
}, [m.data]);
useSse([EventType.identifyCompleted], (ev) => {
if (ev.type !== EventType.identifyCompleted) return;
const p = ev.payload as IdentifyCompletedPayload;
early.current.set(p.taskId, p);
if (early.current.size > 20) early.current.delete(early.current.keys().next().value as string);
setData((d) => (d ? applyIdentifyCompleted(d, p) : d));
});
return { ...m, data, remotePending: !!data?.remoteTaskId };
}
@@ -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}>