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 extends z.ZodType ? z.infer : Record; interface Input { params: Infer; query: Infer; body: Infer; c: Context; } function route(app: ModuleApp, def: D, handler: (input: Input) => Promise) { 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; 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) => async (ids: string[]): Promise> => { const out = new Map(); for (let i = 0; i < ids.length; i += 200) { const chunk = ids.slice(i, i + 200); const r = await dispatch.call( 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(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; }