- 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]>
225 lines
8.3 KiB
TypeScript
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;
|
|
}
|