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

+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})`;
}