diff --git a/apps/server/src/modules/acquisition-organize/routes/library.ts b/apps/server/src/modules/acquisition-organize/routes/library.ts index 721c7b8a..17999cd5 100644 --- a/apps/server/src/modules/acquisition-organize/routes/library.ts +++ b/apps/server/src/modules/acquisition-organize/routes/library.ts @@ -510,9 +510,9 @@ export function registerLibraryRoutes(app: ModuleApp, d: AoDeps) { observedAt: new Date().toISOString(), }, ); - // partial:至少一个实例因查询失败/超时为 unknown(no_external_id 不算失败)。契约未列此字段,前端可按需读取 + // partial:至少一个实例因查询失败/超时为 unknown(no_external_id 不算失败) const partial = instances.some((x) => x.status === "unknown" && x.reason !== "no_external_id"); - return { mediaEntityId: e!.id, instances, partial } as MediaServerInventory; + return { mediaEntityId: e!.id, instances, partial } satisfies MediaServerInventory; }, { tag: TAG }, ); diff --git a/apps/server/src/modules/file-manager/hint.ts b/apps/server/src/modules/file-manager/hint.ts new file mode 100644 index 00000000..ee7cd45e --- /dev/null +++ b/apps/server/src/modules/file-manager/hint.ts @@ -0,0 +1,35 @@ +import { parseReleasePath } from "@umt/domain"; + +// G7:列表副行的轻量解析(纯字符串,不查库)。用文件名 + 父/祖父目录作上下文,与识别同一个解析器, +// 输出 `标题 (年) · S01E02 · 1080p`;剧集不带年份,解析不出标题时为 null。 + +const pad = (n: number) => String(n).padStart(2, "0"); + +export function parsedHintOf(rel: string): string | null { + const parts = rel.split("/"); + const file = parts.pop(); + if (!file) return null; + const parent = parts.pop(); + const grandParent = parts.pop(); + let p: ReturnType; + try { + p = parseReleasePath({ file, ...(parent ? { parent } : {}), ...(grandParent ? { grandParent } : {}) }); + } catch { + return null; + } + const title = p.title?.trim(); + if (!title) return null; + const season = p.season ?? (p.episodes ? 1 : undefined); + const out: string[] = []; + let unit: string | null = null; + if (season !== undefined) { + const eps = p.episodes; + unit = eps + ? `S${pad(season)}E${pad(eps.start)}${eps.end > eps.start ? `-E${pad(eps.end)}` : ""}` + : `S${pad(season)}`; + } + out.push(!unit && p.year ? `${title} (${p.year})` : title); + if (unit) out.push(unit); + if (p.resolution) out.push(p.resolution); + return out.join(" · "); +} diff --git a/apps/server/src/modules/file-manager/identify.ts b/apps/server/src/modules/file-manager/identify.ts index b12c2b86..bfe1fda3 100644 --- a/apps/server/src/modules/file-manager/identify.ts +++ b/apps/server/src/modules/file-manager/identify.ts @@ -1,12 +1,14 @@ import { randomUUID } from "node:crypto"; import { readdir, stat } from "node:fs/promises"; import { join } from "node:path"; -import type { - FileIdentifyCandidate, - FileIdentifyItem, - FilesIdentifyRequest, - FilesIdentifyResult, - OrganizationStatus, +import { + EventType, + type FileIdentifyCandidate, + type FileIdentifyItem, + type FilesIdentifyRequest, + type FilesIdentifyResult, + type IdentifyCompletedPayload, + type OrganizationStatus, } from "@umt/contracts"; import { sql } from "@umt/db"; import { type IdentityCandidate, type ParsedRelease, parseReleasePath, queryTitles, rankCandidates } from "@umt/domain"; @@ -23,24 +25,16 @@ import { kindOfName } from "./list.ts"; // 任务在 worker 里跑完整识别(本地 + 远端),完成后发 SSE `identify.completed`(见 IdentifyCompletedPayload)。 // `?wait=true` 保留同步模式:请求内查远端,整体上限 IDENTIFY_WAIT_MAX_MS。 -/** 远端结果推送事件(尚未进 contracts EventType;前端按 taskId 匹配) */ -export const IDENTIFY_COMPLETED_EVENT = "identify.completed"; +/** 远端结果推送事件(contracts EventType.identifyCompleted;前端按 taskId 匹配) */ +export const IDENTIFY_COMPLETED_EVENT = EventType.identifyCompleted; /** 持久任务名 */ export const IDENTIFY_REMOTE_TASK = "identifyRemote"; /** 同步模式(wait=true)整个请求的远端查询上限 */ export const IDENTIFY_WAIT_MAX_MS = 15_000; -export interface IdentifyCompletedPayload { - taskId: string; - status: "succeeded" | "failed"; - /** 完整识别结果(本地 + 远端),按 root + path 替换 HTTP 响应里的同名项 */ - items: FileIdentifyItem[]; - truncated: boolean; - excludedBySize: number; - error: string | null; -} +export type { IdentifyCompletedPayload }; -/** HTTP 响应 = 契约 FilesIdentifyResult + 两个待入契约的字段 */ +/** HTTP 响应 = 契约 FilesIdentifyResult,remoteTaskId / remotePending 必给 */ export type FilesIdentifyResponse = FilesIdentifyResult & { /** 远端查询任务 id(引擎 runId);null = 无需远端或同步模式已查完 */ remoteTaskId: string | null; @@ -146,11 +140,11 @@ export async function identify( } catch (e) { for (const it of r.result.items) if (r.pendingPaths.has(`${it.root}\u0000${it.path}`)) - it.warnings.push(`远端查询入队失败,只查了本地:${errMessage(e).slice(0, 200)}`); + it.warnings.push(`远端查询入队失败,只查了本地:${errMessage(e).slice(0, 200)}`); return { ...r.result, remoteTaskId: null, remotePending: 0 }; } for (const it of r.result.items) - if (r.pendingPaths.has(`${it.root}\u0000${it.path}`)) it.warnings.push("远端候选在后台查询,查完自动补上"); + if (r.pendingPaths.has(`${it.root}\u0000${it.path}`)) it.warnings.push("远端候选在后台查询,查完自动补上"); return { ...r.result, remoteTaskId: taskId, remotePending: r.remotePending }; } @@ -285,7 +279,7 @@ async function identifyCore( if (remoteQueries >= REMOTE_QUERY_MAX) { res = { list: [], - warning: `远端查询已达上限 ${REMOTE_QUERY_MAX} 次,此项只查了本地。缩小范围后重新识别`, + warning: `远端查询已达上限 ${REMOTE_QUERY_MAX} 次,此项只查了本地。缩小范围后重新识别`, }; remoteCache.set(key, res); return res; @@ -298,7 +292,7 @@ async function identifyCore( return res; } if (opts.signal?.aborted) { - res = { list: [], warning: `远端查询超时(${IDENTIFY_WAIT_MAX_MS / 1000}s),此项只查了本地` }; + res = { list: [], warning: `远端查询超时(${IDENTIFY_WAIT_MAX_MS / 1000}s),此项只查了本地` }; remoteCache.set(key, res); return res; } @@ -341,7 +335,7 @@ async function identifyCore( })); res = { list, warning: null }; } catch (e) { - res = { list: [], warning: `远端查询失败,只查了本地:${errMessage(e).slice(0, 200)}` }; + res = { list: [], warning: `远端查询失败,只查了本地:${errMessage(e).slice(0, 200)}` }; } remoteCache.set(key, res); return res; @@ -374,7 +368,7 @@ async function identifyCore( candidates: [], suggestion: { mediaEntityId: null, contentUnitId: null, contentUnit: null }, existing: { assetId: null }, - warnings: [`低于最小体积 ${minSize} 字节,已跳过`], + warnings: [`低于最小体积 ${minSize} 字节,已跳过`], size, skipped: "below_min_size", records, diff --git a/apps/server/src/modules/file-manager/ledger.ts b/apps/server/src/modules/file-manager/ledger.ts index bbffbe1d..3746b71b 100644 --- a/apps/server/src/modules/file-manager/ledger.ts +++ b/apps/server/src/modules/file-manager/ledger.ts @@ -1,6 +1,7 @@ import { sql } from "@umt/db"; import type { Principal } from "../../http/auth.ts"; import { actorOfP, FilesError, type FmDeps, hashJson, jsonb } from "./common.ts"; +import { invalidatePending } from "./list.ts"; // 计划冻结(artifact kind=plan, title=files_plan)、幂等键(artifact title=files_idem)、审计与 SSE。 @@ -44,12 +45,12 @@ export async function loadPlan(d: FmDeps, planId: string, op: string, request .select(["id", "content", "content_hash", "expires_at", "title", "kind"]) .where("id", "=", planId) .executeTakeFirst(); - if (row?.kind !== "plan" || row.title !== "files_plan") throw new FilesError("plan_stale", "planId 不存在,重新预览"); + if (row?.kind !== "plan" || row.title !== "files_plan") throw new FilesError("plan_stale", "planId 不存在,重新预览"); const content = row.content as StoredPlan["content"]; - if (content.op !== op) throw new FilesError("plan_stale", `planId 属于 ${content.op} 操作,不能用于 ${op}`); + if (content.op !== op) throw new FilesError("plan_stale", `planId 属于 ${content.op} 操作,不能用于 ${op}`); if (!row.expires_at || row.expires_at.getTime() <= d.ao.ports.now().getTime()) - throw new FilesError("plan_stale", "计划已过期,重新预览"); - if (content.requestHash !== requestHash) throw new FilesError("plan_stale", "请求与预览时不一致,重新预览"); + throw new FilesError("plan_stale", "计划已过期,重新预览"); + if (content.requestHash !== requestHash) throw new FilesError("plan_stale", "请求与预览时不一致,重新预览"); return { id: row.id, content, contentHash: row.content_hash, expiresAt: row.expires_at }; } @@ -100,7 +101,7 @@ export async function idempotent( if (!row) return undefined; const c = row.content as { reqHash: string; state: string; result?: T }; if (c.reqHash !== reqHash) throw new FilesError("exists", "idempotencyKey 已用于不同的请求"); - if (c.state !== "done") throw new FilesError("exists", "同一 idempotencyKey 的请求仍在执行,稍后重试"); + if (c.state !== "done") throw new FilesError("exists", "同一 idempotencyKey 的请求仍在执行,稍后重试"); return c.result as T; }; const prev = await replay(); @@ -115,7 +116,7 @@ export async function idempotent( if (!id) { const again = await replay(); if (again !== undefined) return again; - throw new FilesError("exists", "同一 idempotencyKey 的请求仍在执行,稍后重试"); + throw new FilesError("exists", "同一 idempotencyKey 的请求仍在执行,稍后重试"); } try { const result = await fn(); @@ -154,5 +155,6 @@ export async function audit( /** SSE `files.changed`:受影响的根与目录(相对路径),前端据此刷新 */ export function filesChanged(d: FmDeps, rootId: string, dirs: string[]): void { + invalidatePending(rootId); void d.ctx.events.publish("files.changed", { rootId, dirs: [...new Set(dirs)] }); } diff --git a/apps/server/src/modules/file-manager/list.ts b/apps/server/src/modules/file-manager/list.ts index a512924d..bef1264f 100644 --- a/apps/server/src/modules/file-manager/list.ts +++ b/apps/server/src/modules/file-manager/list.ts @@ -32,6 +32,7 @@ import { resolveIn, within, } from "./common.ts"; +import { parsedHintOf } from "./hint.ts"; // 读接口:根列表、列目录(流式读取 + 上限截断 + 服务端排序/过滤/分页)、单项详情;行内业务状态批量关联。 @@ -58,12 +59,59 @@ export async function listRoots(d: FmDeps): Promise<{ items: FileRoot[] }> { checkedAt: h.checkedAt.toISOString(), healthReason: h.reason, usage, + ...(def.kind === "download" && h.real && h.health !== "offline" + ? await pendingOf(d, def.id, h.real) + : { pendingCount: null, pendingPartial: false }), }; }), ); return { items }; } +// ── G6:下载目录待识别计数(缓存 5 分钟;文件变更事件使对应根失效) ── + +const PENDING_TTL_MS = 5 * 60_000; +const pendingCache = new Map(); + +export function invalidatePending(rootId: string): void { + pendingCache.delete(rootId); +} + +async function pendingOf( + d: FmDeps, + rootId: string, + rootReal: string, +): Promise<{ pendingCount: number; pendingPartial: boolean }> { + const hit = pendingCache.get(rootId); + if (hit && Date.now() - hit.at < PENDING_TTL_MS) return { pendingCount: hit.count, pendingPartial: hit.partial }; + const r = await countPending(d, rootReal); + pendingCache.set(rootId, { at: Date.now(), ...r }); + return { pendingCount: r.count, pendingPartial: r.partial }; +} + +/** 与列表 status=unidentified 同口径:视频、非临时名、不在纳管下载任务内容内;递归上限 listMaxEntries 项 */ +async function countPending(d: FmDeps, rootReal: string): Promise<{ count: number; partial: boolean }> { + const dls = await managedDownloads(d).catch(() => [] as DownloadRef[]); + let seen = 0; + let count = 0; + let partial = false; + const stack = [rootReal]; + while (stack.length > 0 && !partial) { + const dir = stack.pop()!; + for (const n of await readdir(dir, { withFileTypes: true }).catch(() => [])) { + if (++seen > d.options.listMaxEntries) { + partial = true; + break; + } + if (n.name.startsWith(".") || isTempName(n.name)) continue; + const abs = join(dir, n.name); + if (n.isDirectory()) stack.push(abs); + else if (n.isFile() && kindOfName(n.name) === "video" && downloadsFor(dls, abs).length === 0) count++; + } + } + return { count, partial }; +} + /** 进程对目录有 W_OK(access 语义) */ async function canWrite(p: string): Promise { return access(p, constants.W_OK).then( @@ -331,6 +379,7 @@ export async function annotate( download: dl ? { taskId: dl.taskId, status: dl.status, progress: dl.progress, hnr: dl.hnr } : null, media: a?.media ?? null, dirSummary: null, + parsedHint: status === "unidentified" ? parsedHintOf(r.rel) : null, }; }); } @@ -388,7 +437,7 @@ export async function listDir(d: FmDeps, q: FilesListQuery): Promise null); - if (!st?.isDirectory()) throw new FilesError("bad_request", `不是目录: ${r.rel || "/"}`); + if (!st?.isDirectory()) throw new FilesError("bad_request", `不是目录:${r.rel || "/"}`); } const dirReal = await realpath(r.abs); const dirMtime = Number((await stat(dirReal)).mtimeMs); @@ -409,8 +458,8 @@ export async function listDir(d: FmDeps, q: FilesListQuery): Promise { const r = await resolveIn(d, root, path); const raw = await rawOf(r.abs, r.rel, r.rootReal); - if (!raw) throw new FilesError("not_found", `不存在: ${path}`); + if (!raw) throw new FilesError("not_found", `不存在:${path}`); const [entry] = await annotate(d, r.def, [raw]); if (entry!.type === "dir") await fillDirSummaries(d, r.def, r.rootReal, [entry!]); const st = r.lst!; diff --git a/apps/server/src/modules/sources-identity/capabilities.ts b/apps/server/src/modules/sources-identity/capabilities.ts index 40745e88..3aa06e1d 100644 --- a/apps/server/src/modules/sources-identity/capabilities.ts +++ b/apps/server/src/modules/sources-identity/capabilities.ts @@ -54,6 +54,7 @@ export function registerSourcesCapabilities(registry: CapabilityRegistry, rt: () externalId: string; title: string; year: number | null; + posterPath?: string | null; }[] = []; const tmdb = await rt().tmdb(); if (input.includeRemote && input.query && tmdb) { @@ -66,6 +67,7 @@ export function registerSourcesCapabilities(registry: CapabilityRegistry, rt: () externalId: m.externalId, title: m.title, year: m.year, + posterPath: m.posterPath, }); } } diff --git a/apps/server/test/file-manager/g678.test.ts b/apps/server/test/file-manager/g678.test.ts new file mode 100644 index 00000000..e9b41517 --- /dev/null +++ b/apps/server/test/file-manager/g678.test.ts @@ -0,0 +1,72 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { parsedHintOf } from "../../src/modules/file-manager/hint.ts"; +import { posterUrlOf } from "../../src/modules/file-manager/identify.ts"; +import { createFmHarness, type FmHarness, pgAvailable, READ_KEY, seedDownload } from "./harness.ts"; + +// C1-C 契约收尾:G6 下载目录待识别计数 / G7 列表轻量解析 parsedHint / G8 候选海报 posterUrl。 + +describe("G7 parsedHintOf / G8 posterUrlOf(纯函数)", () => { + it("剧集:标题 · 季集 · 分辨率;父目录补标题", () => { + expect(parsedHintOf("The.Last.of.Us.S01E02.1080p.WEB-DL.mkv")).toBe("The Last of Us · S01E02 · 1080p"); + expect(parsedHintOf("The.Bear.S01.1080p/The.Bear.S01E01.1080p.WEB-DL-GRP.mkv")).toMatch( + /^The Bear · S01E01 · 1080p$/, + ); + }); + it("电影带年份;解析不出标题为 null", () => { + expect(parsedHintOf("Oppenheimer.2023.2160p.BluRay.x265.mkv")).toBe("Oppenheimer (2023) · 2160p"); + expect(parsedHintOf("")).toBeNull(); + }); + it("posterUrl:相对 poster_path 拼 w154,完整 URL 原样,空为 null", () => { + expect(posterUrlOf("/abc.jpg")).toBe("https://image.tmdb.org/t/p/w154/abc.jpg"); + expect(posterUrlOf("https://img.example/x.jpg")).toBe("https://img.example/x.jpg"); + expect(posterUrlOf(null)).toBeNull(); + expect(posterUrlOf("")).toBeNull(); + }); +}); + +const skip = !(await pgAvailable()); + +// biome-ignore lint/suspicious/noExplicitAny: 测试断言直接读响应 JSON +type Loose = any; + +describe.skipIf(skip)("files G6/G7(列表与根)", () => { + let h: FmHarness; + beforeAll(async () => { + h = await createFmHarness("g678"); + await writeFile(join(h.dl, "The.Last.of.Us.S01E02.1080p.WEB-DL.mkv"), "v"); + await writeFile(join(h.dl, "notes.txt"), "t"); + await writeFile(join(h.dl, "Half.Done.2024.1080p.mkv.!qB"), "p"); + await mkdir(join(h.dl, "Show.S02"), { recursive: true }); + await writeFile(join(h.dl, "Show.S02", "Show.S02E01.720p.mkv"), "v"); + // 纳管下载任务内容(做种)不算待识别 + await mkdir(join(h.dl, "Seeding.Film.2020.1080p"), { recursive: true }); + await writeFile(join(h.dl, "Seeding.Film.2020.1080p", "Seeding.Film.2020.1080p.mkv"), "v"); + await seedDownload(h.db, h, { name: "Seeding.Film.2020.1080p", status: "completed" }); + }); + afterAll(async () => { + await h?.close(); + }); + + it("G6:下载目录带 pendingCount(递归、排除临时与做种),库根为 null", async () => { + const r = await h.api("GET", "/files/roots", undefined, READ_KEY); + expect(r.status).toBe(200); + const dl = r.body.data.items.find((x: Loose) => x.id === h.dlRoot); + const lib = r.body.data.items.find((x: Loose) => x.id === h.libRoot); + expect(dl).toMatchObject({ pendingCount: 2, pendingPartial: false }); + expect(lib).toMatchObject({ pendingCount: null, pendingPartial: false }); + }); + + it("G7:待识别视频行带 parsedHint,其它行为 null", async () => { + const r = await h.api("GET", `/files?root=${encodeURIComponent(h.dlRoot)}`, undefined, READ_KEY); + expect(r.status).toBe(200); + const by = (n: string) => r.body.data.entries.find((x: Loose) => x.name === n); + expect(by("The.Last.of.Us.S01E02.1080p.WEB-DL.mkv")).toMatchObject({ + status: "unidentified", + parsedHint: "The Last of Us · S01E02 · 1080p", + }); + expect(by("notes.txt").parsedHint).toBeNull(); + expect(by("Show.S02").parsedHint).toBeNull(); + }); +}); diff --git a/apps/web/app/api/files.ts b/apps/web/app/api/files.ts index cd744895..f0ff1239 100644 --- a/apps/web/app/api/files.ts +++ b/apps/web/app/api/files.ts @@ -56,6 +56,7 @@ export type { FileRef, FileRoot, FileStatus, + FilesIdentifyResult, FilesOnExisting, FilesOrganizeGroup, FilesOrganizeItem, @@ -64,6 +65,7 @@ export type { FilesSuggestNameResult, FilesTransferResult, FilesTreeResult, + IdentifyCompletedPayload, NamingTemplateSource, TmdbCandidate, TrashItem, diff --git a/apps/web/app/components/files/dir-tree.tsx b/apps/web/app/components/files/dir-tree.tsx index d2cfb284..747ce437 100644 --- a/apps/web/app/components/files/dir-tree.tsx +++ b/apps/web/app/components/files/dir-tree.tsx @@ -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} > @@ -146,6 +146,12 @@ function RootNode({ 正常 )} + {root.pendingCount ? ( + + 待识别 {root.pendingCount} + {root.pendingPartial ? "+" : ""} + + ) : null} {root.usage ? 剩余 {formatBytes(root.usage.freeBytes, 1)} : null} {!root.writable && root.kind === "library" ? 只读 : null} @@ -258,7 +264,7 @@ function Children({ })} {q.data?.truncated ? (

- 子目录过多,只列出一部分 + 子目录过多,只列出一部分

) : null} diff --git a/apps/web/app/components/files/identify-panel.tsx b/apps/web/app/components/files/identify-panel.tsx index 4b3fafcd..e40b783a 100644 --- a/apps/web/app/components/files/identify-panel.tsx +++ b/apps/web/app/components/files/identify-panel.tsx @@ -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 = { 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 ? (

{filesErrorText(identify.error, "识别失败")}

) : !identify.data ? ( -

按文件名解析,匹配本地作品和 TMDB。只读,不改文件。

+

按文件名解析,匹配本地作品和 TMDB。只读,不改文件。

) : !it ? (

{isDir ? "目录里没有视频文件。" : "没有识别结果。"}

) : ( @@ -64,7 +65,7 @@ export function IdentifyPanel({ {isDir && (identify.data.items.length > 1 || identify.data.truncated) ? (

目录里有 {identify.data.items.length} - {identify.data.truncated ? "+" : ""} 个视频,这里只显示第一个。整理时逐项确认。 + {identify.data.truncated ? "+" : ""} 个视频,这里只显示第一个。整理时逐项确认。

) : null} - 最近:{RECORD_STATUS_LABEL[it.records.lastStatus]} + 最近:{RECORD_STATUS_LABEL[it.records.lastStatus]} ) : null} {it.records.lastRecordId ? ( @@ -97,7 +98,7 @@ export function IdentifyPanel({

) : null} {it.skipped === "below_min_size" ? ( -

低于最小体积,没有查候选。

+

低于最小体积,没有查候选。

) : null} {it.warnings.map((w) => (

@@ -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 ? ( + + ) : ( + + )}

diff --git a/apps/web/app/components/files/mock.ts b/apps/web/app/components/files/mock.ts index 74e82e7e..99d0fbc2 100644 --- a/apps/web/app/components/files/mock.ts +++ b/apps/web/app/components/files/mock.ts @@ -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 } { 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 } { 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 } { 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 } { 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 = { 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 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 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 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 })), }; 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 { await delay(100); if (path.endsWith(".nfo")) - return `\n\n\n 沙丘2\n 2024\n`; + return `\n\n\n 沙丘 2\n 2024\n`; 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})`; } diff --git a/apps/web/app/components/files/model.test.ts b/apps/web/app/components/files/model.test.ts index 65b5a1f0..751f710d 100644 --- a/apps/web/app/components/files/model.test.ts +++ b/apps/web/app/components/files/model.test.ts @@ -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 = {}) => ({ + 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(["远端查询失败,只有本地结果:超时"]); + }); +}); diff --git a/apps/web/app/components/files/model.ts b/apps/web/app/components/files/model.ts index 8af24286..401e1ba0 100644 --- a/apps/web/app/components/files/model.ts +++ b/apps/web/app/components/files/model.ts @@ -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): 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 = { 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) => `${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), + }; +} diff --git a/apps/web/app/components/files/use-identify-live.ts b/apps/web/app/components/files/use-identify-live.ts new file mode 100644 index 00000000..7e522fcd --- /dev/null +++ b/apps/web/app/components/files/use-identify-live.ts @@ -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(undefined); + const early = useRef(new Map()); + 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 }; +} diff --git a/apps/web/app/components/organize-wizard/model.test.ts b/apps/web/app/components/organize-wizard/model.test.ts index e8df0605..11edb48d 100644 --- a/apps/web/app/components/organize-wizard/model.test.ts +++ b/apps/web/app/components/organize-wizard/model.test.ts @@ -51,6 +51,7 @@ const item = ( level: over.level ?? "high", score: 1, reasons: [], + posterUrl: null, }, ], suggestion: { mediaEntityId: null, contentUnitId: null, contentUnit: null }, diff --git a/apps/web/app/components/organize-wizard/organize-wizard.tsx b/apps/web/app/components/organize-wizard/organize-wizard.tsx index 18e70d4b..fb390d35 100644 --- a/apps/web/app/components/organize-wizard/organize-wizard.tsx +++ b/apps/web/app/components/organize-wizard/organize-wizard.tsx @@ -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(), early: new Map() }); + 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 = (

- {src.pending ? "定位源文件…" : "识别中:匹配本地作品与 TMDB…"} + {src.pending ? "定位源文件…" : "识别中:匹配本地作品与 TMDB…"}

); } else if (src.error || (!src.items.length && src.unresolved.length) || !src.items.length) { body = (

- {src.error ? filesErrorText(src.error, "读取整理记录失败") : "源文件不在任何登记目录里,或已经不存在。"} + {src.error ? filesErrorText(src.error, "读取整理记录失败") : "源文件不在任何登记目录里,或已经不存在。"}

{src.unresolved.length ? (
    @@ -310,7 +338,7 @@ export function OrganizeWizard({ source, onClose }: { source: OrganizeSource; on
) : null}

- 在设置里检查下载器路径映射,或到 + 在设置里检查下载器路径映射,或到 文件管理 @@ -335,7 +363,7 @@ export function OrganizeWizard({ source, onClose }: { source: OrganizeSource; on

{src.unresolved.length ? (

- {src.unresolved.length} 个源路径不在登记目录里或已不存在,已跳过。 + {src.unresolved.length} 个源路径不在登记目录里或已不存在,已跳过。

) : null} { 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 ? ( -

整理在后台执行,状态每 3 秒刷新,文件列表会自动更新。

+

+ 整理在后台执行,状态每 3 秒刷新,文件列表会自动更新。 +

) : null} ) : null} @@ -472,7 +502,7 @@ export function OrganizeWizard({ source, onClose }: { source: OrganizeSource; on <> {rows.length && !canNext ? ( - {stats.pending ? `${stats.pending} 项待定:选作品或排除` : "没有要整理的项"} + {stats.pending ? `${stats.pending} 项待定:选作品或排除` : "没有要整理的项"} ) : null}