Files
UHD_Media_Tool/apps/server/src/modules/search/routes.ts
T
rootandClaude Opus 5.5 a0946bf480 feat(search): global search aggregation backend (W9-G)
- GET /search?q=&scope=all|media|releases|actions&limit=: local works (title_alias
  exact/prefix/trgm + name prefix, slot status summary), TMDB candidates (cached,
  localEntityId), cross-site releases, page/action jump list; tookMs per group
- cross-site: enabled sites via shared per-site limiter, 15s AbortSignal timeout,
  site concurrency cap 4, same-query coalescing, 30s cache; partial + failedSites;
  results upserted to source_release; inventory overlay via GET /inventory
- GET /search/releases: site/resolution/source/size/free/H&R filters, sort, cursor,
  facets; shares the cache
- POST /search/releases/{siteId}/{siteReleaseId}/plan: forwards in-process to
  POST /acquisition-plans (same auth, validation, audit, rejections)
- contracts/search.ts, runbook, tests (partial/timeout, coalesce, cache, overlay,
  filters/sort, plan)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-28 15:05:58 +08:00

225 lines
8.3 KiB
TypeScript

import { createRoute, type RouteConfig } from "@hono/zod-openapi";
import {
Api,
type ApiScope,
apiResponse,
type EndpointDef,
type InventoryEntry,
type InventoryResponse,
type ManualAcquisitionResult,
type ReleaseSearchPage,
type ReleaseSearchQuery,
type SearchQuery,
type SearchReleaseParams,
type SearchReleasePlanRequest,
type SearchResponse,
} from "@umt/contracts";
import type { Context } from "hono";
import type { z } from "zod";
import { type AuthEnv, requireScope } from "../../http/auth.ts";
import { ApiException } from "../../http/errors.ts";
import type { SourcesRuntime } from "../sources-identity/runtime.ts";
import { upsertSiteRelease } from "../sources-identity/service/snapshot.ts";
import type { ModuleApp, ServerContext } from "../types.ts";
import { matchActions } from "./actions.ts";
import { InternalDispatcher } from "./dispatch.ts";
import { searchLocalMedia, TmdbSearch, withInventory } from "./media.ts";
import { decorate, facets, filterReleases, ReleaseFanout, sortReleases } from "./releases.ts";
export interface SearchDeps {
runtime: () => SourcesRuntime;
siteTimeoutMs: number;
siteConcurrency: number;
cacheTtlMs: number;
tmdbCacheTtlMs: number;
now: () => number;
}
type Infer<T> = T extends z.ZodType ? z.infer<T> : Record<string, never>;
interface Input<D extends EndpointDef> {
params: Infer<D["params"]>;
query: Infer<D["query"]>;
body: Infer<D["body"]>;
c: Context<AuthEnv>;
}
function route<D extends EndpointDef>(app: ModuleApp, def: D, handler: (input: Input<D>) => Promise<unknown>) {
const request: RouteConfig["request"] = {};
if (def.params) request.params = def.params as never;
if (def.query) request.query = def.query as never;
if (def.body) request.body = { content: { "application/json": { schema: def.body as never } }, required: true };
const r = createRoute({
method: def.method,
path: def.path,
summary: def.summary,
tags: ["search"],
middleware: [requireScope(def.scope as ApiScope)] as never,
request,
responses: {
200: { description: def.summary, content: { "application/json": { schema: apiResponse(def.response) } } },
},
});
// biome-ignore lint/suspicious/noExplicitAny: 路由泛型在封装里擦除,入参已由 zod-openapi 校验
app.openapi(r, (async (c: any) => {
const input = {
params: def.params ? c.req.valid("param") : {},
query: def.query ? c.req.valid("query") : {},
body: def.body ? c.req.valid("json") : {},
c,
} as Input<D>;
const data = def.response.parse(await handler(input));
return c.json({ ok: true, data }, 200);
// biome-ignore lint/suspicious/noExplicitAny: 同上
}) as any);
}
const since = (t0: number) => Math.round(performance.now() - t0);
export function registerSearchRoutes(app: ModuleApp, ctx: ServerContext, d: SearchDeps) {
const fanout = new ReleaseFanout(d.runtime, {
timeoutMs: d.siteTimeoutMs,
concurrency: d.siteConcurrency,
ttlMs: d.cacheTtlMs,
now: d.now,
});
const tmdb = new TmdbSearch(d.runtime, { ttlMs: d.tmdbCacheTtlMs, timeoutMs: d.siteTimeoutMs, now: d.now });
const dispatch = new InternalDispatcher(app);
/** 库存叠加:转调 GET /inventory(每次最多 200 个作品) */
const inventory =
(c: Context<AuthEnv>) =>
async (ids: string[]): Promise<Map<string, InventoryEntry>> => {
const out = new Map<string, InventoryEntry>();
for (let i = 0; i < ids.length; i += 200) {
const chunk = ids.slice(i, i + 200);
const r = await dispatch.call<InventoryResponse>(
c,
"GET",
`/inventory?entityIds=${encodeURIComponent(chunk.join(","))}`,
);
for (const e of r.items) out.set(e.entityId, e);
}
return out;
};
route(app, Api.search.all, async ({ query, c }) => {
const t0 = performance.now();
const q = query as SearchQuery;
const want = (s: SearchQuery["scope"]) => q.scope === "all" || q.scope === s;
const loadInv = inventory(c);
const media = want("media")
? Promise.all([
(async () => {
const t = performance.now();
const hits = await searchLocalMedia(ctx, q.q, q.limit);
const inv = hits.length ? await loadInv(hits.map((h) => h.entity.id)) : new Map();
return { items: withInventory(hits, inv), tookMs: since(t) };
})(),
(async () => {
const t = performance.now();
const r = await tmdb.search(q.q, q.limit);
return { ...r, tookMs: since(t) };
})(),
]).then(([local, tm]) => ({ local, tmdb: tm }))
: Promise.resolve(null);
const releases = want("releases")
? (async () => {
const t = performance.now();
const hit = await fanout.search(q.q);
const sorted = sortReleases(hit.fanout.items, "default", "desc");
const items = await decorate(d.runtime(), sorted.slice(0, q.limit), loadInv);
const failed = hit.fanout.sites.filter((s) => s.status === "failed" || s.status === "timeout");
return {
items,
total: sorted.length,
partial: failed.length > 0,
sites: hit.fanout.sites,
failedSites: failed,
cached: hit.cached,
coalesced: hit.coalesced,
fetchedAt: hit.fanout.fetchedAt,
tookMs: since(t),
};
})()
: Promise.resolve(null);
const actions = want("actions")
? (() => {
const t = performance.now();
const items = matchActions(q.q, q.limit);
return { items, tookMs: since(t) };
})()
: null;
const [m, r] = await Promise.all([media, releases]);
const out: SearchResponse = { q: q.q, scope: q.scope, media: m, releases: r, actions, tookMs: since(t0) };
return out;
});
route(app, Api.search.releases, async ({ query, c }) => {
const t0 = performance.now();
const q = query as ReleaseSearchQuery;
const hit = await fanout.search(q.q, { refresh: q.refresh === "true" });
const all = hit.fanout.items;
const filtered = sortReleases(filterReleases(all, q), q.sort, q.order);
const offset = decodeCursor(q.cursor);
const pageItems = filtered.slice(offset, offset + q.limit);
const next = offset + q.limit < filtered.length ? encodeCursor(offset + q.limit) : null;
const failed = hit.fanout.sites.filter((s) => s.status === "failed" || s.status === "timeout");
const out: ReleaseSearchPage = {
q: q.q,
items: await decorate(d.runtime(), pageItems, inventory(c)),
nextCursor: next,
total: filtered.length,
unfilteredTotal: all.length,
facets: facets(all),
partial: failed.length > 0,
sites: hit.fanout.sites,
failedSites: failed,
cached: hit.cached,
coalesced: hit.coalesced,
fetchedAt: hit.fanout.fetchedAt,
tookMs: since(t0),
};
return out;
});
route(app, Api.search.plan, async ({ params, body, c }) => {
const p = params as SearchReleaseParams;
const rt = d.runtime();
const find = () =>
rt.ctx.db
.selectFrom("source_release")
.select(["id"])
.where("site_id", "=", p.siteId)
.where("site_release_id", "=", p.siteReleaseId)
.executeTakeFirst();
let row = await find();
if (!row) {
// 搜索时落库失败的兜底:按缓存里的站点结果补写
const cached = fanout.findCached(p.siteId, p.siteReleaseId);
const site = cached
? await rt.ctx.db.selectFrom("site").select(["id"]).where("id", "=", p.siteId).executeTakeFirst()
: undefined;
if (cached && site) {
await rt.ctx.db.transaction().execute((trx) => upsertSiteRelease(rt, trx, site, cached.raw));
row = await find();
}
}
if (!row) throw new ApiException("not_found", "资源不在搜索结果里,重新搜索后再下载");
return dispatch.call<ManualAcquisitionResult>(c, "POST", "/acquisition-plans", {
...(body as SearchReleasePlanRequest),
sourceReleaseId: row.id,
});
});
}
const encodeCursor = (offset: number) => Buffer.from(String(offset), "utf8").toString("base64url");
function decodeCursor(cursor: string | undefined): number {
if (!cursor) return 0;
const n = Number(Buffer.from(cursor, "base64url").toString("utf8"));
return Number.isSafeInteger(n) && n >= 0 ? n : 0;
}