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

@@ -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 },
);
@@ -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<typeof parseReleasePath>;
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(" · ");
}
@@ -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,
@@ -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<C>(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<C>["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<T>(
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<T>(
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)] });
}
+53 -4
View File
@@ -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<string, { at: number; count: number; partial: boolean }>();
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<boolean> {
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<FilesListRe
const r = await resolveIn(d, q.root, q.path);
if (r.isSymlink || !r.lst?.isDirectory()) {
const st = await stat(r.abs).catch(() => 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<FilesListRe
names.push(ent.name);
}
} catch (e) {
if (isOfflineErr(e)) throw new FilesError("root_unavailable", `读取目录失败(${errnoOf(e)}),根可能已离线`);
throw new FilesError("bad_request", `读取目录失败: ${errnoOf(e) ?? String(e)}`);
if (isOfflineErr(e)) throw new FilesError("root_unavailable", `读取目录失败(${errnoOf(e)}),根可能已离线`);
throw new FilesError("bad_request", `读取目录失败:${errnoOf(e) ?? String(e)}`);
}
let pool = names;
@@ -548,7 +597,7 @@ export async function bindingsAt(d: FmDeps, def: RootDef, rel: string, prefix =
export async function statEntry(d: FmDeps, root: string, path: string): Promise<FileStat> {
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!;
@@ -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,
});
}
}