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:
1 parent
fd2f7bc33d
commit
abc69886ea
26 files changed
+510
-134
No files matched your search
@@ -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})`;
|
||||
}
|
||||
Reference in new issue
Block a user