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

+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(["远端查询失败,只有本地结果:超时"]);
});
});