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

@@ -1,6 +1,7 @@
import { appendFile, mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { IDENTIFY_COMPLETED_EVENT, runIdentifyRemote } from "../../src/modules/file-manager/identify.ts";
import {
createFmHarness,
EXEC_KEY,
@@ -69,16 +70,74 @@ describe.skipIf(skip)("files 识别 / 整理 / 探测 / 刮削", () => {
expect(pool).toHaveLength(0);
});
it("identify:远端候选(catalog.lookup)", async () => {
const r = await h.api("POST", "/files/identify", {
it("identify:远端候选(catalog.lookup,wait=true 同步模式)", async () => {
const r = await h.api("POST", "/files/identify?wait=true", {
items: [{ root: h.dlRoot, path: "Remote.Film.2019.1080p.mkv" }],
minSizeBytes: 0,
});
expect(r.status).toBe(200);
expect(r.body.data.remoteTaskId).toBeNull();
const c = r.body.data.items[0].candidates;
expect(c[0]).toMatchObject({ provider: "tmdb", externalId: "999", mediaEntityId: null, kind: "movie" });
});
it("identify:缺省入队远端查询,先回本地命中 + remoteTaskId;任务完成发 identify.completed(S-7)", async () => {
const before = h.engine.enqueued.length;
const body = {
items: [
{ root: h.dlRoot, path: "Remote.Film.2019.1080p.mkv" },
{ root: h.dlRoot, path: "Blue.Film.2021.2160p.BluRay.x265-GRP.mkv" },
],
minSizeBytes: 0,
};
const r = await h.api("POST", "/files/identify", body);
expect(r.status).toBe(200);
const [remote, local] = r.body.data.items;
// 本地命中照常返回;远端候选不在请求内查
expect(remote.candidates).toEqual([]);
expect(remote.warnings.some((w: string) => w.includes("后台"))).toBe(true);
expect(local.candidates[0]).toMatchObject({ mediaEntityId: film });
expect(r.body.data.remotePending).toBe(2);
const job = h.engine.enqueued.slice(before).find((e) => e.task === "identifyRemote");
expect(job).toBeTruthy();
expect(r.body.data.remoteTaskId).toMatch(/^run-/);
// 模拟 worker 执行任务
const out = await runIdentifyRemote(
h.module.deps(),
r.body.data.remoteTaskId,
job!.input as Parameters<typeof runIdentifyRemote>[2],
);
expect(out.status).toBe("succeeded");
const ev = h.events.filter((e) => e.type === IDENTIFY_COMPLETED_EVENT).at(-1);
expect(ev?.payload.taskId).toBe(r.body.data.remoteTaskId);
const done = ev!.payload.items.find((i: { path: string }) => i.path === "Remote.Film.2019.1080p.mkv");
expect(done.candidates[0]).toMatchObject({ provider: "tmdb", externalId: "999" });
expect(done.warnings.some((w: string) => w.includes("后台"))).toBe(false);
});
it("identify:无需远端查询时不入队(remoteTaskId=null)", async () => {
const before = h.engine.enqueued.length;
await mkdir(join(h.dl, "untitled"), { recursive: true });
await writeFile(join(h.dl, "untitled", "1080p.mkv"), "x");
const r = await h.api("POST", "/files/identify", { items: [{ root: h.dlRoot, path: "untitled/1080p.mkv" }] });
expect(r.status).toBe(200);
expect(r.body.data.remoteTaskId).toBeNull();
expect(h.engine.enqueued.slice(before).some((e) => e.task === "identifyRemote")).toBe(false);
});
it("identify:任务里识别失败也发 identify.completed(status=failed)", async () => {
const out = await runIdentifyRemote(h.module.deps(), "run-x", {
body: { items: [{ root: h.dlRoot, path: "no-such-file.mkv" }] } as never,
actorId: "system",
});
expect(out.status).toBe("failed");
expect(h.events.at(-1)).toMatchObject({
type: IDENTIFY_COMPLETED_EVENT,
payload: { taskId: "run-x", status: "failed" },
});
});
it("identify:去重后的远端查询单次上限 20,超出的项只本地召回并给 warning(验收 07 S-6)", async () => {
await mkdir(join(h.dl, "many"), { recursive: true });
const names = Array.from({ length: 23 }, (_, i) => `Distinct Title ${String.fromCharCode(65 + i)}.2019.1080p.mkv`);
@@ -0,0 +1,104 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
createHarness,
type Harness,
pgAvailable,
seedEntity,
} from "../../src/modules/acquisition-organize/__tests__/harness.ts";
import { MEDIA_SERVER_LIMITS } from "../../src/modules/acquisition-organize/routes/library.ts";
// C1-D:GET /inventory/media-servers 按实例并行(allSettled)+ 实例内并发上限 + 整实例超时;部分失败 partial=true。
const hasPg = await pgAvailable();
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
describe.skipIf(!hasPg)("GET /inventory/media-servers 并行 + 超时 + partial", () => {
let h: Harness;
let entityId: string;
const ids: Record<string, string> = {};
const saved = { ...MEDIA_SERVER_LIMITS };
const inflight: Record<string, { now: number; max: number }> = {};
beforeAll(async () => {
h = await createHarness("msinv");
MEDIA_SERVER_LIMITS.timeoutMs = 400;
MEDIA_SERVER_LIMITS.perInstanceConcurrency = 2;
entityId = await seedEntity(h.db, { kind: "movie", title: "并行库存", year: 2020 });
await h.db
.insertInto("external_identity")
.values(
["tmdb", "imdb", "tvdb"].map((provider, i) => ({
media_entity_id: entityId,
provider,
entity_type: "movie",
external_id: `x${i}`,
status: "accepted",
})) as never,
)
.execute();
for (const name of ["a-ok", "b-slow", "c-boom"]) {
const r = await h.db
.insertInto("media_server_instance")
.values({ name, base_url: `http://${name}.invalid` })
.returning("id")
.executeTakeFirstOrThrow();
ids[name] = r.id;
inflight[name] = { now: 0, max: 0 };
}
h.d.ports.mediaServer = (async (inst: { name: string }) => {
if (inst.name === "c-boom") throw new Error("connect refused");
return {
async findByProviderId(provider: string) {
const f = inflight[inst.name]!;
f.now++;
f.max = Math.max(f.max, f.now);
try {
await sleep(inst.name === "b-slow" ? 5_000 : 60);
return provider === "Tmdb"
? {
status: "present" as const,
items: [{ itemId: "i1", type: "Movie", name: "并行库存", year: 2020, path: "/m", versions: [] }],
}
: { status: "not_found" as const };
} finally {
f.now--;
}
},
};
}) as never;
}, 60_000);
afterAll(async () => {
Object.assign(MEDIA_SERVER_LIMITS, saved);
await h?.close();
});
it("实例并行、慢实例超时、异常实例 unknown,整体 partial", async () => {
const t0 = Date.now();
const r = await h.api("GET", `/inventory/media-servers?mediaEntityId=${entityId}`);
const took = Date.now() - t0;
expect(r.status).toBe(200);
type Inst = { name: string; status: string; reason: string | null; items: unknown[] };
const by = Object.fromEntries((r.body.data.instances as Inst[]).map((x) => [x.name, x]));
expect(by["a-ok"]).toMatchObject({ status: "present", reason: null });
expect(by["a-ok"]!.items).toHaveLength(1);
expect(by["b-slow"]).toMatchObject({ status: "unknown" });
expect(by["b-slow"]!.reason).toMatch(/^timeout/);
expect(by["c-boom"]).toMatchObject({ status: "unknown", reason: "connect refused" });
expect(r.body.data.partial).toBe(true);
// 串行时至少 0.4s(b 超时)+ 3×60ms;并行应接近单个超时
expect(took).toBeLessThan(2_000);
// 实例内并发上限 2(3 个外部 ID)
expect(inflight["a-ok"]!.max).toBe(2);
});
it("全部成功时 partial=false;无外部 ID 的 unknown 不算失败", async () => {
await h.db.updateTable("media_server_instance").set({ enabled: false }).where("name", "!=", "a-ok").execute();
const r = await h.api("GET", `/inventory/media-servers?mediaEntityId=${entityId}`);
expect(r.body.data.partial).toBe(false);
const bareId = await seedEntity(h.db, { kind: "movie", title: "无外部ID", year: 2021 });
const r2 = await h.api("GET", `/inventory/media-servers?mediaEntityId=${bareId}`);
expect(r2.body.data.instances[0]).toMatchObject({ status: "unknown", reason: "no_external_id" });
expect(r2.body.data.partial).toBe(false);
});
});