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
@@ -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)] });
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user