perf(server): parallel media-server inventory, queued identify remote lookups, dev DB cleanup

- GET /inventory/media-servers: per-instance Promise.allSettled, per-instance
  concurrency cap (2) and timeout (8s); failed/timed-out instances are unknown,
  response carries partial=true (field not yet in contracts)
- S-7: POST /files/identify returns local matches plus remoteTaskId/remotePending
  and enqueues identifyRemote; the worker runs the TMDB lookups and publishes SSE
  identify.completed {taskId,status,items,truncated,excludedBySize,error};
  ?wait=true keeps the synchronous path, capped at 15s overall
- catalog.lookup / release.search honour the caller's abort signal
- scripts/dev-db-clean.ts: dry-run by default, --yes to drop leftover test
  schemas and w32-/w33-/fe4-/qa- prefixed entities; refuses non localhost:55433
- runbook: dev DB cleanup

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-28 19:47:41 +08:00
1 parent 5b8927d1dc
commit fd2f7bc33d
10 files changed
+683 -67

No files matched your search

@@ -463,7 +463,7 @@ describe.skipIf(!hasPg)("acquisition-organize 纵切(真实 PG + mock qB + 临
const inv = await h.api("GET", `/inventory?entityIds=${movieId}`);
expect(inv.body.data.items[0].versions[0]).toMatchObject({ resolution: "1080p", available: true });
const ms = await h.api("GET", `/inventory/media-servers?mediaEntityId=${movieId}`);
expect(ms.body.data).toEqual({ mediaEntityId: movieId, instances: [] });
expect(ms.body.data).toEqual({ mediaEntityId: movieId, instances: [], partial: false });
});
it("自动整理被拦(attention)后手动整理成功:Slot 经 verifying 落 satisfied,整理事项自动关闭(QA U1)", async () => {
@@ -33,6 +33,85 @@ import { mount, paginate } from "./helpers.ts";
const TAG = "library";
const ACTIVE_DL = ["queued", "submitting", "downloading", "stalled", "paused", "completed", "importing"] as const;
/** Emby 库存查询:每实例内部并发上限与整实例超时(毫秒) */
export const MEDIA_SERVER_LIMITS = { perInstanceConcurrency: 2, timeoutMs: 8_000 };
const MS_PROVIDER_KEY: Record<string, string> = { tmdb: "Tmdb", imdb: "Imdb", tvdb: "Tvdb", douban: "Douban" };
type MsInstance = MediaServerInventory["instances"][number];
type MsInstanceRow = Parameters<AoDeps["ports"]["mediaServer"]>[0];
const errText = (e: unknown) => (e instanceof Error ? e.message : String(e));
/**
* 查单个实例:外部 ID 逐个 findByProviderId(实例内并发 ≤ perInstanceConcurrency),整实例超过 timeoutMs 记 unknown。
* 超时后底层请求由 Emby 客户端自身超时收尾,结果丢弃。
*/
export async function queryMediaServer(
d: AoDeps,
inst: MsInstanceRow,
usable: { provider: string; external_id: string }[],
type: "Movie" | "Series",
limits: { perInstanceConcurrency: number; timeoutMs: number },
): Promise<MsInstance> {
const observedAt = new Date().toISOString();
const base = { instanceId: inst.id, name: inst.name, observedAt };
if (usable.length === 0) return { ...base, status: "unknown", reason: "no_external_id", items: [] };
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(
() => reject(new Error(`timeout: 媒体服务器 ${limits.timeoutMs / 1000}s 内未响应`)),
limits.timeoutMs,
);
});
const work = (async () => {
const client = await d.ports.mediaServer(inst);
const results: Awaited<ReturnType<typeof client.findByProviderId>>[] = new Array(usable.length);
let next = 0;
const lane = async () => {
while (next < usable.length) {
const k = next++;
const i = usable[k]!;
results[k] = await client.findByProviderId(MS_PROVIDER_KEY[i.provider]!, i.external_id, type);
}
};
await Promise.all(Array.from({ length: Math.min(limits.perInstanceConcurrency, usable.length) }, lane));
return results;
})();
work.catch(() => {});
try {
const results = await Promise.race([work, timeout]);
let status: MsInstance["status"] = "not_found";
let reason: string | null = null;
const items: MsInstance["items"] = [];
for (const r of results) {
if (r.status === "present") {
status = "present";
reason = null;
for (const it of r.items) {
if (items.some((x) => x.itemId === it.itemId)) continue;
items.push({
itemId: it.itemId,
type: it.type,
name: it.name,
year: it.year ?? null,
path: it.path ?? null,
versions: it.versions.map((v) => JSON.parse(JSON.stringify(v)) as Record<string, unknown>),
});
}
} else if (r.status === "unknown" && status !== "present") {
status = "unknown";
reason = r.reason;
}
}
return { ...base, status, reason, items };
} catch (err) {
return { ...base, status: "unknown", reason: errText(err), items: [] };
} finally {
clearTimeout(timer);
}
}
const emptyCounts = (): SlotStatusCounts => ({
empty: 0,
reserved: 0,
@@ -414,54 +493,26 @@ export function registerLibraryRoutes(app: ModuleApp, d: AoDeps) {
.orderBy("name")
.execute();
const type = e!.kind === "movie" ? "Movie" : "Series";
const KEY: Record<string, string> = { tmdb: "Tmdb", imdb: "Imdb", tvdb: "Tvdb", douban: "Douban" };
const out: MediaServerInventory = { mediaEntityId: e!.id, instances: [] };
for (const inst of insts) {
const observedAt = new Date().toISOString();
const usable = idents.filter((i) => KEY[i.provider]);
if (usable.length === 0) {
out.instances.push({
instanceId: inst.id,
name: inst.name,
status: "unknown",
reason: "no_external_id",
items: [],
observedAt,
});
continue;
}
let status: "present" | "not_found" | "unknown" = "not_found";
let reason: string | null = null;
const items: MediaServerInventory["instances"][number]["items"] = [];
try {
const client = await d.ports.mediaServer(inst);
for (const i of usable) {
const r = await client.findByProviderId(KEY[i.provider]!, i.external_id, type);
if (r.status === "present") {
status = "present";
for (const it of r.items) {
if (items.some((x) => x.itemId === it.itemId)) continue;
items.push({
itemId: it.itemId,
type: it.type,
name: it.name,
year: it.year ?? null,
path: it.path ?? null,
versions: it.versions.map((v) => JSON.parse(JSON.stringify(v)) as Record<string, unknown>),
});
}
} else if (r.status === "unknown" && status !== "present") {
status = "unknown";
reason = r.reason;
}
}
} catch (err) {
status = "unknown";
reason = (err as Error).message;
}
out.instances.push({ instanceId: inst.id, name: inst.name, status, reason, items, observedAt });
}
return out;
const usable = idents.filter((i) => MS_PROVIDER_KEY[i.provider]);
// 按实例并行(allSettled):单个 Emby 慢/挂只让自己 unknown,不拖住其它实例;每实例内部限并发 + 总超时
const settled = await Promise.allSettled(
insts.map((inst) => queryMediaServer(d, inst, usable, type, MEDIA_SERVER_LIMITS)),
);
const instances = settled.map((r, i) =>
r.status === "fulfilled"
? r.value
: {
instanceId: insts[i]!.id,
name: insts[i]!.name,
status: "unknown" as const,
reason: errText(r.reason),
items: [],
observedAt: new Date().toISOString(),
},
);
// 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;
},
{ tag: TAG },
);