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]>
This commit is contained in:
1 parent
e0c0c4f51d
commit
a0946bf480
12 files changed
+1869
-1
No files matched your search
@@ -4,6 +4,7 @@ import { authSettingsModule } from "./auth-settings/index.ts";
|
||||
import { fileManagerModule } from "./file-manager/index.ts";
|
||||
import { piModule } from "./pi/index.ts";
|
||||
import { schedulesModule } from "./schedules/index.ts";
|
||||
import { searchModule } from "./search/index.ts";
|
||||
import { sourcesIdentityModule } from "./sources-identity/index.ts";
|
||||
import type { ServerModule } from "./types.ts";
|
||||
|
||||
@@ -21,4 +22,5 @@ export const SERVER_MODULES: readonly ServerModule[] = [
|
||||
fileManagerModule,
|
||||
piModule,
|
||||
schedulesModule,
|
||||
searchModule,
|
||||
];
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { SearchAction } from "@umt/contracts";
|
||||
|
||||
// 页面与操作跳转(命令面板静态清单),按 IA-3 定稿导航:概览 / 资源 / 订阅 / 整理 / 系统(定时任务在任务页"计划"Tab)。
|
||||
// href 是控制台内路径;action 类带查询参数,由前端页面识别后打开对应表单。
|
||||
|
||||
const page = (id: string, group: string, title: string, href: string, keywords: string[] = []): SearchAction => ({
|
||||
id: `page.${id}`,
|
||||
kind: "page",
|
||||
group,
|
||||
title,
|
||||
href,
|
||||
keywords,
|
||||
});
|
||||
const action = (id: string, group: string, title: string, href: string, keywords: string[] = []): SearchAction => ({
|
||||
id: `action.${id}`,
|
||||
kind: "action",
|
||||
group,
|
||||
title,
|
||||
href,
|
||||
keywords,
|
||||
});
|
||||
|
||||
export const SEARCH_ACTIONS: readonly SearchAction[] = [
|
||||
page("home", "概览", "首页", "/console", ["home", "dashboard", "概览"]),
|
||||
page("tasks", "概览", "任务", "/console/tasks", ["task", "运行", "历史"]),
|
||||
page("agent", "概览", "Agent 会话", "/console/agent", ["agent", "会话"]),
|
||||
page("inbox", "概览", "待处理", "/console/inbox", ["inbox", "收件箱", "decision"]),
|
||||
page("sites", "资源", "站点", "/console/sites", ["site", "pt", "站点账号"]),
|
||||
page("sources", "资源", "RSS 订阅", "/console/sources", ["rss", "feed"]),
|
||||
page("subscriptions", "订阅", "订阅", "/console/subscriptions", ["subscription", "追剧"]),
|
||||
page("rules", "订阅", "规则", "/console/rules", ["rules", "收录策略", "policy"]),
|
||||
page("downloads", "整理", "下载面板", "/console/downloads", ["download", "qb", "下载任务"]),
|
||||
page("download-plans", "整理", "下载计划", "/console/downloads/plans", ["plan", "计划"]),
|
||||
page("organize", "整理", "整理记录", "/console/organize", ["organize", "入库", "历史"]),
|
||||
page("files", "整理", "文件管理", "/console/files", ["files", "文件"]),
|
||||
page("trash", "整理", "回收站", "/console/files/trash", ["trash", "删除"]),
|
||||
page("library", "整理", "媒体概览", "/console/library", ["library", "媒体库", "作品"]),
|
||||
page("settings", "系统", "设置", "/console/settings", ["settings", "配置", "tmdb", "下载器"]),
|
||||
page("schedules", "概览", "定时任务", "/console/tasks?tab=plan", ["schedule", "cron", "巡检", "计划"]),
|
||||
page("audit", "系统", "审计", "/console/audit", ["audit", "日志"]),
|
||||
page("preferences", "系统", "偏好", "/console/preferences", ["preferences", "主题"]),
|
||||
action("new-subscription", "订阅", "新建订阅", "/console/subscriptions?new=1", ["subscribe", "订阅"]),
|
||||
action("new-agent-run", "概览", "新建 Agent 会话", "/console/agent?new=1", ["agent", "委托"]),
|
||||
action("add-site", "资源", "添加站点", "/console/sites?new=1", ["site", "站点"]),
|
||||
action("add-feed", "资源", "添加 RSS 订阅", "/console/sources?new=1", ["rss", "feed"]),
|
||||
action("new-schedule", "概览", "新建巡检", "/console/tasks?tab=plan&new=1", ["巡检", "agent_check"]),
|
||||
];
|
||||
|
||||
/** 标题 / 关键词 / 路径包含查询词(不区分大小写);标题前缀命中排前 */
|
||||
export function matchActions(q: string, limit: number): SearchAction[] {
|
||||
const needle = q.trim().toLowerCase();
|
||||
if (!needle) return [];
|
||||
const scored: { a: SearchAction; score: number }[] = [];
|
||||
for (const a of SEARCH_ACTIONS) {
|
||||
const title = a.title.toLowerCase();
|
||||
let score = 0;
|
||||
if (title.startsWith(needle)) score = 3;
|
||||
else if (title.includes(needle)) score = 2;
|
||||
else if (a.keywords.some((k) => k.toLowerCase().includes(needle)) || a.href.toLowerCase().includes(needle))
|
||||
score = 1;
|
||||
if (score) scored.push({ a, score });
|
||||
}
|
||||
return scored
|
||||
.sort((x, y) => y.score - x.score)
|
||||
.slice(0, limit)
|
||||
.map((x) => x.a);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { Context } from "hono";
|
||||
import { Hono } from "hono";
|
||||
import type { AuthEnv } from "../../http/auth.ts";
|
||||
import { ApiException } from "../../http/errors.ts";
|
||||
import type { ModuleApp } from "../types.ts";
|
||||
|
||||
// 进程内转调其它模块的 HTTP 端点(库存叠加 GET /inventory、下载 POST /acquisition-plans):
|
||||
// 走对方真实的路由(scope 校验 / 入参校验 / 审计 / 业务逻辑都不复制),调用者身份沿用本次请求已通过鉴权的 principal。
|
||||
// 所有模块路由注册完后第一次调用时才组装(route() 按调用时刻复制路由表)。
|
||||
|
||||
interface Origin {
|
||||
principal: AuthEnv["Variables"]["principal"];
|
||||
actor: AuthEnv["Variables"]["actor"];
|
||||
session: AuthEnv["Variables"]["session"];
|
||||
requestId: string | undefined;
|
||||
}
|
||||
|
||||
export class InternalDispatcher {
|
||||
private inner: Hono<AuthEnv> | null = null;
|
||||
private readonly origins = new WeakMap<Request, Origin>();
|
||||
private readonly errors = new WeakMap<Request, unknown>();
|
||||
|
||||
constructor(private readonly api: ModuleApp) {}
|
||||
|
||||
private app(): Hono<AuthEnv> {
|
||||
if (this.inner) return this.inner;
|
||||
const inner = new Hono<AuthEnv>();
|
||||
inner.use("*", async (c, next) => {
|
||||
const o = this.origins.get(c.req.raw);
|
||||
if (!o) throw new ApiException("unauthorized", "内部调用缺少调用者");
|
||||
c.set("principal", o.principal);
|
||||
c.set("actor", o.actor);
|
||||
c.set("session", o.session);
|
||||
if (o.requestId) c.set("requestId" as never, o.requestId as never);
|
||||
await next();
|
||||
});
|
||||
inner.route("/", this.api);
|
||||
// 原样抛回原始错误(ApiException 由外层 app.onError 转信封)
|
||||
inner.onError((err, c) => {
|
||||
this.errors.set(c.req.raw, err);
|
||||
return c.body(null, 500);
|
||||
});
|
||||
inner.notFound((c) => {
|
||||
this.errors.set(c.req.raw, new ApiException("internal", `内部端点不存在:${c.req.method} ${c.req.path}`));
|
||||
return c.body(null, 404);
|
||||
});
|
||||
this.inner = inner;
|
||||
return inner;
|
||||
}
|
||||
|
||||
async call<T>(from: Context<AuthEnv>, method: "GET" | "POST", path: string, body?: unknown): Promise<T> {
|
||||
const req = new Request(`http://internal${path}`, {
|
||||
method,
|
||||
signal: from.req.raw.signal,
|
||||
...(body !== undefined ? { headers: { "content-type": "application/json" }, body: JSON.stringify(body) } : {}),
|
||||
});
|
||||
this.origins.set(req, {
|
||||
principal: from.get("principal"),
|
||||
actor: from.get("actor"),
|
||||
session: from.get("session"),
|
||||
requestId: from.get("requestId" as never) as string | undefined,
|
||||
});
|
||||
const res = await this.app().fetch(req);
|
||||
const err = this.errors.get(req);
|
||||
if (err) throw err;
|
||||
const json = (await res.json()) as { ok: boolean; data?: T; error?: { code: string; message: string } };
|
||||
if (!json.ok) {
|
||||
throw new ApiException(
|
||||
(json.error?.code ?? "internal") as never,
|
||||
json.error?.message ?? `内部调用失败(${res.status})`,
|
||||
);
|
||||
}
|
||||
return json.data as T;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { sourcesIdentityModule } from "../sources-identity/index.ts";
|
||||
import type { SourcesRuntime } from "../sources-identity/runtime.ts";
|
||||
import type { ServerContext, ServerModule } from "../types.ts";
|
||||
import { registerSearchRoutes } from "./routes.ts";
|
||||
|
||||
/**
|
||||
* W9-G 全局搜索:GET /search 聚合(本地作品 / TMDB 候选 / 跨站资源 / 页面与操作)、GET /search/releases 结果页、
|
||||
* POST /search/releases/{siteId}/{siteReleaseId}/plan 从搜索结果下载。
|
||||
* 只读其它模块的 service 导出与站点运行时(共享每站限流器);库存叠加与下载转调对方的 HTTP 端点(不复制逻辑)。
|
||||
*/
|
||||
export interface SearchModuleOptions {
|
||||
/** 站点适配器 / TMDB 所在的运行时;缺省取 sources-identity 模块的(共享每站限流器) */
|
||||
runtime?: (ctx: ServerContext) => SourcesRuntime;
|
||||
/** 单站超时,缺省 15s */
|
||||
siteTimeoutMs?: number;
|
||||
/** 同时搜索的站点数上限,缺省 4 */
|
||||
siteConcurrency?: number;
|
||||
/** 跨站结果缓存,缺省 30s */
|
||||
cacheTtlMs?: number;
|
||||
/** TMDB 候选缓存,缺省 10 分钟 */
|
||||
tmdbCacheTtlMs?: number;
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
export function createSearchModule(opts: SearchModuleOptions = {}): ServerModule {
|
||||
const runtime = opts.runtime ?? ((ctx: ServerContext) => sourcesIdentityModule.runtime(ctx));
|
||||
return {
|
||||
name: "search",
|
||||
registerRoutes(app, ctx) {
|
||||
registerSearchRoutes(app, ctx, {
|
||||
runtime: () => runtime(ctx),
|
||||
siteTimeoutMs: opts.siteTimeoutMs ?? 15_000,
|
||||
siteConcurrency: opts.siteConcurrency ?? 4,
|
||||
cacheTtlMs: opts.cacheTtlMs ?? 30_000,
|
||||
tmdbCacheTtlMs: opts.tmdbCacheTtlMs ?? 600_000,
|
||||
now: opts.now ?? Date.now,
|
||||
});
|
||||
},
|
||||
registerTasks() {},
|
||||
};
|
||||
}
|
||||
|
||||
export const searchModule = createSearchModule();
|
||||
@@ -0,0 +1,243 @@
|
||||
import type { InventoryEntry, LocalMediaHit, SlotStatusCounts, TmdbCandidate } from "@umt/contracts";
|
||||
import { sql } from "@umt/db";
|
||||
import { normalizeTitle } from "@umt/domain";
|
||||
import type { SourcesRuntime } from "../sources-identity/runtime.ts";
|
||||
import type { ServerContext } from "../types.ts";
|
||||
|
||||
// 本地作品(title_alias trgm + 名称前缀)与 TMDB 候选。
|
||||
|
||||
const emptyCounts = (): SlotStatusCounts => ({
|
||||
empty: 0,
|
||||
reserved: 0,
|
||||
downloading: 0,
|
||||
verifying: 0,
|
||||
satisfied: 0,
|
||||
upgrade_pending: 0,
|
||||
attention: 0,
|
||||
});
|
||||
|
||||
interface LocalRow {
|
||||
id: string;
|
||||
kind: LocalMediaHit["entity"]["kind"];
|
||||
title: string;
|
||||
original_title: string | null;
|
||||
year: number | null;
|
||||
status: LocalMediaHit["entity"]["status"];
|
||||
score: number;
|
||||
matched_by: LocalMediaHit["matchedBy"];
|
||||
matched: string | null;
|
||||
}
|
||||
|
||||
/** 召回:别名归一化精确 / 前缀 / trgm 相似(≥0.3),另加作品名/原名前缀(无别名的手建作品) */
|
||||
export async function searchLocalMedia(
|
||||
ctx: ServerContext,
|
||||
q: string,
|
||||
limit: number,
|
||||
): Promise<Omit<LocalMediaHit, "versionCount" | "downloadingCount" | "subscribed">[]> {
|
||||
const n = normalizeTitle(q);
|
||||
const like = `${q.trim().replace(/[\\%_]/g, (m) => `\\${m}`)}%`;
|
||||
const rows: LocalRow[] = [];
|
||||
if (n) {
|
||||
const r = await sql<LocalRow>`
|
||||
select distinct on (e.id) e.id, e.kind, e.title, e.original_title, e.year, e.status,
|
||||
greatest(similarity(a.normalized, ${n}),
|
||||
case when a.normalized = ${n} then 1 when a.normalized like ${`${n}%`} then 0.9 else 0 end) as score,
|
||||
case when a.normalized = ${n} then 'exact' when a.normalized like ${`${n}%`} then 'prefix' else 'similar' end
|
||||
as matched_by,
|
||||
a.alias as matched
|
||||
from title_alias a join media_entity e on e.id = a.media_entity_id
|
||||
where e.status <> 'merged'
|
||||
and (a.normalized = ${n} or a.normalized like ${`${n}%`} or similarity(a.normalized, ${n}) >= 0.3)
|
||||
order by e.id, score desc
|
||||
limit 200`.execute(ctx.db);
|
||||
rows.push(...r.rows);
|
||||
}
|
||||
const byName = await sql<LocalRow>`
|
||||
select e.id, e.kind, e.title, e.original_title, e.year, e.status, 0.9::float8 as score, 'prefix' as matched_by,
|
||||
null as matched
|
||||
from media_entity e
|
||||
where e.status <> 'merged' and (e.title ilike ${like} or e.original_title ilike ${like})
|
||||
order by e.updated_at desc
|
||||
limit 50`.execute(ctx.db);
|
||||
rows.push(...byName.rows);
|
||||
|
||||
const best = new Map<string, LocalRow>();
|
||||
for (const r of rows) {
|
||||
const cur = best.get(r.id);
|
||||
if (!cur || Number(r.score) > Number(cur.score)) best.set(r.id, r);
|
||||
}
|
||||
const top = [...best.values()]
|
||||
.sort((a, b) => Number(b.score) - Number(a.score) || a.title.localeCompare(b.title))
|
||||
.slice(0, limit);
|
||||
if (top.length === 0) return [];
|
||||
|
||||
const counts = await ctx.db
|
||||
.selectFrom("acquisition_slot")
|
||||
.select(["media_entity_id", "status"])
|
||||
.select(sql<number>`count(*)::int`.as("n"))
|
||||
.where(
|
||||
"media_entity_id",
|
||||
"in",
|
||||
top.map((t) => t.id),
|
||||
)
|
||||
.groupBy(["media_entity_id", "status"])
|
||||
.execute();
|
||||
return top.map((r) => {
|
||||
const slotCounts = emptyCounts();
|
||||
for (const c of counts) if (c.media_entity_id === r.id) slotCounts[c.status as keyof SlotStatusCounts] = c.n;
|
||||
return {
|
||||
entity: {
|
||||
id: r.id,
|
||||
kind: r.kind,
|
||||
title: r.title,
|
||||
originalTitle: r.original_title,
|
||||
year: r.year,
|
||||
status: r.status,
|
||||
},
|
||||
score: Math.min(1, Math.round(Number(r.score) * 1000) / 1000),
|
||||
matchedBy: r.matched_by,
|
||||
matchedAlias: r.matched,
|
||||
slotCounts,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** 本地作品叠加库存摘要(inventory 由 GET /inventory 转调得到) */
|
||||
export function withInventory(
|
||||
hits: Omit<LocalMediaHit, "versionCount" | "downloadingCount" | "subscribed">[],
|
||||
inv: Map<string, InventoryEntry>,
|
||||
): LocalMediaHit[] {
|
||||
return hits.map((h) => {
|
||||
const e = inv.get(h.entity.id);
|
||||
return {
|
||||
...h,
|
||||
versionCount: e?.versions.length ?? 0,
|
||||
downloadingCount: e?.downloading.length ?? 0,
|
||||
subscribed: e?.subscribed ?? false,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export interface TmdbSearchResult {
|
||||
items: TmdbCandidate[];
|
||||
configured: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
interface TmdbCacheEntry {
|
||||
expires: number;
|
||||
items: Omit<TmdbCandidate, "localEntityId">[];
|
||||
}
|
||||
|
||||
/** TMDB 候选:客户端自带请求缓存,这里再按查询词缓存结果;localEntityId 每次按外部身份现查 */
|
||||
export class TmdbSearch {
|
||||
private readonly cache = new Map<string, TmdbCacheEntry>();
|
||||
private readonly inflight = new Map<string, Promise<Omit<TmdbCandidate, "localEntityId">[]>>();
|
||||
|
||||
constructor(
|
||||
private readonly rt: () => SourcesRuntime,
|
||||
private readonly opts: { ttlMs: number; timeoutMs: number; now: () => number },
|
||||
) {}
|
||||
|
||||
async search(q: string, limit: number): Promise<TmdbSearchResult> {
|
||||
const rt = this.rt();
|
||||
const client = await rt.tmdb();
|
||||
if (!client) return { items: [], configured: false, error: null };
|
||||
const key = q.trim().toLowerCase();
|
||||
let items: Omit<TmdbCandidate, "localEntityId">[];
|
||||
try {
|
||||
const hit = this.cache.get(key);
|
||||
if (hit && hit.expires > this.opts.now()) items = hit.items;
|
||||
else {
|
||||
let p = this.inflight.get(key);
|
||||
if (!p) {
|
||||
const signal = AbortSignal.timeout(this.opts.timeoutMs);
|
||||
p = raceAbort(client.searchAll(q.trim()), signal, "TMDB 查询超时")
|
||||
.then((list) =>
|
||||
list.slice(0, 50).map((m) => ({
|
||||
provider: "tmdb" as const,
|
||||
entityType: m.entityType,
|
||||
externalId: m.externalId,
|
||||
title: m.title,
|
||||
originalTitle: m.originalTitle,
|
||||
year: m.year,
|
||||
overview: m.overview ? (m.overview.length > 300 ? `${m.overview.slice(0, 300)}…` : m.overview) : null,
|
||||
popularity: m.popularity,
|
||||
})),
|
||||
)
|
||||
.then((list) => {
|
||||
this.prune();
|
||||
this.cache.set(key, { expires: this.opts.now() + this.opts.ttlMs, items: list });
|
||||
return list;
|
||||
})
|
||||
.finally(() => this.inflight.delete(key));
|
||||
this.inflight.set(key, p);
|
||||
}
|
||||
items = await p;
|
||||
}
|
||||
} catch (e) {
|
||||
return { items: [], configured: true, error: (e as Error).message || "TMDB 查询失败" };
|
||||
}
|
||||
const top = items.slice(0, limit);
|
||||
const local = await localEntities(
|
||||
rt.ctx,
|
||||
top.map((t) => ({ entityType: t.entityType, externalId: t.externalId })),
|
||||
);
|
||||
return {
|
||||
items: top.map((t) => ({ ...t, localEntityId: local.get(`${t.entityType}:${t.externalId}`) ?? null })),
|
||||
configured: true,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
private prune() {
|
||||
const now = this.opts.now();
|
||||
for (const [k, v] of this.cache) if (v.expires <= now) this.cache.delete(k);
|
||||
while (this.cache.size > 500) this.cache.delete(this.cache.keys().next().value as string);
|
||||
}
|
||||
}
|
||||
|
||||
/** 已接受的 TMDB 身份 → 本地作品(合并过的跟到最终作品) */
|
||||
async function localEntities(
|
||||
ctx: ServerContext,
|
||||
refs: { entityType: string; externalId: string }[],
|
||||
): Promise<Map<string, string>> {
|
||||
const out = new Map<string, string>();
|
||||
if (refs.length === 0) return out;
|
||||
const rows = await ctx.db
|
||||
.selectFrom("external_identity as i")
|
||||
.innerJoin("media_entity as e", "e.id", "i.media_entity_id")
|
||||
.select(["i.entity_type", "i.external_id", "e.id", "e.status", "e.merged_into_id"])
|
||||
.where("i.provider", "=", "tmdb")
|
||||
.where("i.status", "=", "accepted")
|
||||
.where(
|
||||
"i.external_id",
|
||||
"in",
|
||||
refs.map((r) => r.externalId),
|
||||
)
|
||||
.execute();
|
||||
for (const r of rows) {
|
||||
const id = r.status === "merged" && r.merged_into_id ? r.merged_into_id : r.id;
|
||||
out.set(`${r.entity_type}:${r.external_id}`, id);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** signal 中止时以 message 拒绝(适配器/客户端不认 signal 时兜底) */
|
||||
export function raceAbort<T>(p: Promise<T>, signal: AbortSignal, message: string): Promise<T> {
|
||||
if (signal.aborted) return Promise.reject(new Error(message));
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const onAbort = () => reject(new Error(message));
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
p.then(
|
||||
(v) => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
resolve(v);
|
||||
},
|
||||
(e) => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
reject(e);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
import type { SiteRelease as AdapterRelease } from "@umt/adapters";
|
||||
import { SiteError } from "@umt/adapters";
|
||||
import type {
|
||||
FacetCount,
|
||||
InventoryEntry,
|
||||
ReleaseSearchQuery,
|
||||
SearchInventoryState,
|
||||
SearchRelease,
|
||||
SearchReleaseInventory,
|
||||
SearchSiteCoverage,
|
||||
} from "@umt/contracts";
|
||||
import { type SiteRow, sql } from "@umt/db";
|
||||
import { normalizeTitle, parseRelease } from "@umt/domain";
|
||||
import type { SourcesRuntime } from "../sources-identity/runtime.ts";
|
||||
import { upsertSiteRelease } from "../sources-identity/service/snapshot.ts";
|
||||
import { raceAbort } from "./media.ts";
|
||||
|
||||
// 跨站资源搜索:已启用站点逐站调适配器 search(每站共享限流器 + 单站超时 + 站点级并发上限);
|
||||
// 同一查询词并发合并、结果短缓存;结果 upsert 为资源(source_release,新资源照常发 release.observed 进识别流水线)。
|
||||
// 作品关联与库存叠加在每次请求时现算(不进缓存),识别完成后刷新即可看到。
|
||||
|
||||
/** 缓存里的资源条目(不含作品关联与库存叠加) */
|
||||
export type BaseRelease = Omit<SearchRelease, "mediaEntityId" | "entityMatch" | "entity" | "inventory">;
|
||||
|
||||
export interface Fanout {
|
||||
items: BaseRelease[];
|
||||
sites: SearchSiteCoverage[];
|
||||
fetchedAt: string;
|
||||
}
|
||||
|
||||
export interface FanoutOptions {
|
||||
timeoutMs: number;
|
||||
concurrency: number;
|
||||
ttlMs: number;
|
||||
now: () => number;
|
||||
}
|
||||
|
||||
export interface FanoutHit {
|
||||
fanout: Fanout;
|
||||
cached: boolean;
|
||||
coalesced: boolean;
|
||||
}
|
||||
|
||||
const cacheKey = (q: string) => q.trim().toLowerCase().replace(/\s+/g, " ");
|
||||
|
||||
export class ReleaseFanout {
|
||||
private readonly cache = new Map<string, { expires: number; fanout: Fanout }>();
|
||||
private readonly inflight = new Map<string, Promise<Fanout>>();
|
||||
|
||||
constructor(
|
||||
private readonly rt: () => SourcesRuntime,
|
||||
private readonly opts: FanoutOptions,
|
||||
) {}
|
||||
|
||||
async search(q: string, opts: { refresh?: boolean } = {}): Promise<FanoutHit> {
|
||||
const key = cacheKey(q);
|
||||
const hit = this.cache.get(key);
|
||||
if (!opts.refresh && hit && hit.expires > this.opts.now())
|
||||
return { fanout: hit.fanout, cached: true, coalesced: false };
|
||||
const running = this.inflight.get(key);
|
||||
if (running) return { fanout: await running, cached: false, coalesced: true };
|
||||
const p = this.run(q.trim())
|
||||
.then((fanout) => {
|
||||
// 全部站点失败不缓存(下次直接重试)
|
||||
if (fanout.sites.length === 0 || fanout.sites.some((s) => s.status === "ok")) {
|
||||
this.prune();
|
||||
this.cache.set(key, { expires: this.opts.now() + this.opts.ttlMs, fanout });
|
||||
}
|
||||
return fanout;
|
||||
})
|
||||
.finally(() => this.inflight.delete(key));
|
||||
this.inflight.set(key, p);
|
||||
return { fanout: await p, cached: false, coalesced: false };
|
||||
}
|
||||
|
||||
/** 缓存里找一条站点资源(下载时资源行还没落库的兜底) */
|
||||
findCached(siteId: string, siteReleaseId: string): { release: BaseRelease; raw: AdapterRelease } | null {
|
||||
for (const { fanout } of this.cache.values()) {
|
||||
const r = fanout.items.find((i) => i.siteId === siteId && i.siteReleaseId === siteReleaseId);
|
||||
const raw = r ? this.raws.get(r.key) : undefined;
|
||||
if (r && raw) return { release: r, raw };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
private readonly raws = new Map<string, AdapterRelease>();
|
||||
|
||||
private prune() {
|
||||
const now = this.opts.now();
|
||||
for (const [k, v] of this.cache) if (v.expires <= now) this.cache.delete(k);
|
||||
while (this.cache.size > 200) this.cache.delete(this.cache.keys().next().value as string);
|
||||
if (this.raws.size > 20_000) this.raws.clear();
|
||||
}
|
||||
|
||||
private async run(q: string): Promise<Fanout> {
|
||||
const rt = this.rt();
|
||||
const sites = await rt.ctx.db
|
||||
.selectFrom("site")
|
||||
.selectAll()
|
||||
.where("enabled", "=", true)
|
||||
.where("deleted_at", "is", null)
|
||||
.orderBy("name")
|
||||
.execute();
|
||||
const results = await mapLimit(sites, Math.max(1, this.opts.concurrency), (s) => this.searchSite(rt, s, q));
|
||||
return {
|
||||
items: results.flatMap((r) => r.items),
|
||||
sites: results.map((r) => r.coverage),
|
||||
fetchedAt: new Date(this.opts.now()).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
private async searchSite(
|
||||
rt: SourcesRuntime,
|
||||
site: SiteRow,
|
||||
q: string,
|
||||
): Promise<{ items: BaseRelease[]; coverage: SearchSiteCoverage }> {
|
||||
const t0 = performance.now();
|
||||
const cov = (status: SearchSiteCoverage["status"], count: number, error: string | null): SearchSiteCoverage => ({
|
||||
siteId: site.id,
|
||||
siteName: site.name,
|
||||
status,
|
||||
count,
|
||||
tookMs: Math.round(performance.now() - t0),
|
||||
error,
|
||||
});
|
||||
let adapter: ReturnType<SourcesRuntime["siteAdapter"]>;
|
||||
try {
|
||||
adapter = rt.siteAdapter(site);
|
||||
} catch (e) {
|
||||
return { items: [], coverage: cov("skipped", 0, (e as Error).message) };
|
||||
}
|
||||
if (!adapter.capabilities.search) return { items: [], coverage: cov("skipped", 0, "站点不支持搜索") };
|
||||
const signal = AbortSignal.timeout(this.opts.timeoutMs);
|
||||
const timeoutMsg = `${Math.round(this.opts.timeoutMs / 1000)} 秒内没有响应`;
|
||||
let found: AdapterRelease[];
|
||||
try {
|
||||
found = (await raceAbort(adapter.search(q, { page: 1 }, { signal }), signal, timeoutMsg)).items;
|
||||
} catch (e) {
|
||||
if (signal.aborted) return { items: [], coverage: cov("timeout", 0, timeoutMsg) };
|
||||
const msg = e instanceof SiteError ? `${e.kind}: ${e.message}` : (e as Error).message || "搜索失败";
|
||||
return { items: [], coverage: cov("failed", 0, msg) };
|
||||
}
|
||||
// 同站同 ID 去重(翻页边界可能重复)
|
||||
const seen = new Set<string>();
|
||||
found = found.filter((r) => !seen.has(r.siteReleaseId) && !!seen.add(r.siteReleaseId));
|
||||
const ids = new Map<string, string>();
|
||||
try {
|
||||
await rt.ctx.db.transaction().execute(async (trx) => {
|
||||
for (const r of found) ids.set(r.siteReleaseId, (await upsertSiteRelease(rt, trx, site, r)).id);
|
||||
});
|
||||
} catch (e) {
|
||||
// 落库失败不影响展示;下载时再按缓存补写
|
||||
ids.clear();
|
||||
rt.ctx.logger.warn({ err: { message: (e as Error).message }, siteId: site.id }, "搜索结果写入资源表失败");
|
||||
}
|
||||
const items = found.map((r) => {
|
||||
const base = toBase(site, r, ids.get(r.siteReleaseId) ?? null);
|
||||
this.raws.set(base.key, r);
|
||||
return base;
|
||||
});
|
||||
return { items, coverage: cov("ok", items.length, null) };
|
||||
}
|
||||
}
|
||||
|
||||
function toBase(site: Pick<SiteRow, "id" | "name">, r: AdapterRelease, sourceReleaseId: string | null): BaseRelease {
|
||||
const p = parseRelease(r.title, r.subtitle ? { subtitle: r.subtitle } : {});
|
||||
const pr = r.promotion;
|
||||
return {
|
||||
key: `${site.id}:${r.siteReleaseId}`,
|
||||
sourceReleaseId,
|
||||
siteId: site.id,
|
||||
siteName: site.name,
|
||||
siteReleaseId: r.siteReleaseId,
|
||||
title: r.title,
|
||||
subtitle: r.subtitle ?? null,
|
||||
category: { id: r.category.id, name: r.category.name },
|
||||
sizeBytes: r.sizeBytes !== null && r.sizeBytes >= 0 ? Math.round(r.sizeBytes) : null,
|
||||
seeders: r.seeders,
|
||||
leechers: r.leechers,
|
||||
snatched: r.snatched,
|
||||
promotion: {
|
||||
type: pr.type,
|
||||
downloadFactor: pr.downloadFactor,
|
||||
uploadFactor: pr.uploadFactor,
|
||||
expiresAt: pr.expiresAt,
|
||||
},
|
||||
free: pr.downloadFactor === 0 || pr.type === "free" || pr.type === "2xfree",
|
||||
hr: r.hr,
|
||||
uploadedAt: r.uploadedAt,
|
||||
detailUrl: r.detailUrl,
|
||||
observedAt: r.observedAt,
|
||||
parsed: {
|
||||
titleCn: p.titleCn ?? null,
|
||||
titleEn: p.titleEn ?? null,
|
||||
year: p.year ?? null,
|
||||
season: p.season ?? null,
|
||||
episodes: p.episodes ? { start: p.episodes.start, end: p.episodes.end } : null,
|
||||
resolution: p.resolution ?? null,
|
||||
source: p.source ?? null,
|
||||
videoCodec: p.videoCodec ?? null,
|
||||
hdr: [...p.hdr],
|
||||
releaseGroup: p.releaseGroup?.name ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 固定并发上限的 map(保持输入顺序) */
|
||||
export async function mapLimit<T, R>(items: readonly T[], limit: number, fn: (t: T) => Promise<R>): Promise<R[]> {
|
||||
const out = new Array<R>(items.length);
|
||||
let next = 0;
|
||||
const worker = async () => {
|
||||
while (next < items.length) {
|
||||
const i = next++;
|
||||
out[i] = await fn(items[i]!);
|
||||
}
|
||||
};
|
||||
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── 筛选 / 排序 / 分面 ──
|
||||
|
||||
const list = (s: string | undefined) =>
|
||||
(s ?? "")
|
||||
.split(",")
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
export function filterReleases(items: BaseRelease[], q: ReleaseSearchQuery): BaseRelease[] {
|
||||
const sites = new Set(list(q.sites));
|
||||
const res = new Set(list(q.resolution).map((x) => x.toLowerCase()));
|
||||
const src = new Set(list(q.source).map((x) => x.toLowerCase()));
|
||||
return items.filter((r) => {
|
||||
if (sites.size && !sites.has(r.siteId)) return false;
|
||||
if (res.size && !res.has((r.parsed.resolution ?? "unknown").toLowerCase())) return false;
|
||||
if (src.size && !src.has((r.parsed.source ?? "unknown").toLowerCase())) return false;
|
||||
if (q.minSize !== undefined && (r.sizeBytes === null || r.sizeBytes < q.minSize)) return false;
|
||||
if (q.maxSize !== undefined && (r.sizeBytes === null || r.sizeBytes > q.maxSize)) return false;
|
||||
if (q.free === "true" && !r.free) return false;
|
||||
if (q.free === "false" && r.free) return false;
|
||||
if (q.hr === "true" && r.hr !== true) return false;
|
||||
if (q.hr === "false" && r.hr === true) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
const time = (s: string | null) => (s ? Date.parse(s) : null);
|
||||
|
||||
/** 空值恒排最后;default = 做种数降序,同分发布时间新→旧 */
|
||||
export function sortReleases(
|
||||
items: BaseRelease[],
|
||||
sort: ReleaseSearchQuery["sort"],
|
||||
order: ReleaseSearchQuery["order"],
|
||||
): BaseRelease[] {
|
||||
const dir = order === "asc" ? 1 : -1;
|
||||
const cmpNum = (a: number | null, b: number | null, d: number) =>
|
||||
a === null && b === null ? 0 : a === null ? 1 : b === null ? -1 : (a - b) * d;
|
||||
const bySeedThenTime = (a: BaseRelease, b: BaseRelease, d: number) =>
|
||||
cmpNum(a.seeders, b.seeders, d) || cmpNum(time(a.uploadedAt), time(b.uploadedAt), -1);
|
||||
const cmp = (a: BaseRelease, b: BaseRelease): number => {
|
||||
switch (sort) {
|
||||
case "size":
|
||||
return cmpNum(a.sizeBytes, b.sizeBytes, dir) || bySeedThenTime(a, b, -1);
|
||||
case "uploadedAt":
|
||||
return cmpNum(time(a.uploadedAt), time(b.uploadedAt), dir) || bySeedThenTime(a, b, -1);
|
||||
case "site":
|
||||
return a.siteName.localeCompare(b.siteName) * (dir === 1 ? 1 : -1) || bySeedThenTime(a, b, -1);
|
||||
case "seeders":
|
||||
return bySeedThenTime(a, b, dir);
|
||||
default:
|
||||
return bySeedThenTime(a, b, dir);
|
||||
}
|
||||
};
|
||||
return [...items].sort((a, b) => cmp(a, b) || a.key.localeCompare(b.key));
|
||||
}
|
||||
|
||||
export function facets(items: BaseRelease[]): {
|
||||
sites: FacetCount[];
|
||||
resolutions: FacetCount[];
|
||||
sources: FacetCount[];
|
||||
} {
|
||||
const count = (pick: (r: BaseRelease) => [string, string]) => {
|
||||
const m = new Map<string, FacetCount>();
|
||||
for (const r of items) {
|
||||
const [value, label] = pick(r);
|
||||
const f = m.get(value) ?? { value, label, count: 0 };
|
||||
f.count++;
|
||||
m.set(value, f);
|
||||
}
|
||||
return [...m.values()].sort((a, b) => b.count - a.count || a.label.localeCompare(b.label));
|
||||
};
|
||||
return {
|
||||
sites: count((r) => [r.siteId, r.siteName]),
|
||||
resolutions: count((r) => [r.parsed.resolution ?? "unknown", r.parsed.resolution ?? "未知"]),
|
||||
sources: count((r) => [r.parsed.source ?? "unknown", r.parsed.source ?? "未知"]),
|
||||
};
|
||||
}
|
||||
|
||||
// ── 作品关联 + 库存叠加(每次请求现算)──
|
||||
|
||||
export type InventoryLoader = (entityIds: string[]) => Promise<Map<string, InventoryEntry>>;
|
||||
|
||||
export async function decorate(
|
||||
rt: SourcesRuntime,
|
||||
items: BaseRelease[],
|
||||
loadInventory: InventoryLoader,
|
||||
): Promise<SearchRelease[]> {
|
||||
if (items.length === 0) return [];
|
||||
const { db } = rt.ctx;
|
||||
const srIds = items.map((i) => i.sourceReleaseId).filter((x): x is string => !!x);
|
||||
const resolved = new Map<string, string>();
|
||||
if (srIds.length) {
|
||||
const rows = await db
|
||||
.selectFrom("source_release")
|
||||
.select(["id", "media_entity_id"])
|
||||
.where("id", "in", srIds)
|
||||
.where("media_entity_id", "is not", null)
|
||||
.execute();
|
||||
for (const r of rows) resolved.set(r.id, r.media_entity_id!);
|
||||
}
|
||||
// 未识别的:解析出的中/英文标题归一化后精确命中别名,且只命中一个作品(年份相差 ≤1)时作为未确认关联
|
||||
const titleKeys = (r: BaseRelease) =>
|
||||
[r.parsed.titleCn, r.parsed.titleEn]
|
||||
.filter((t): t is string => !!t)
|
||||
.map(normalizeTitle)
|
||||
.filter(Boolean);
|
||||
const pending = items.filter((i) => !(i.sourceReleaseId && resolved.has(i.sourceReleaseId)));
|
||||
const norms = [...new Set(pending.flatMap(titleKeys))];
|
||||
const aliasHits = new Map<string, { id: string; year: number | null }[]>();
|
||||
if (norms.length) {
|
||||
const rows = await sql<{ normalized: string; id: string; year: number | null }>`
|
||||
select distinct a.normalized, e.id, e.year
|
||||
from title_alias a join media_entity e on e.id = a.media_entity_id
|
||||
where e.status <> 'merged' and a.normalized = any(${norms})`.execute(db);
|
||||
for (const r of rows.rows) {
|
||||
const l = aliasHits.get(r.normalized) ?? [];
|
||||
l.push({ id: r.id, year: r.year });
|
||||
aliasHits.set(r.normalized, l);
|
||||
}
|
||||
}
|
||||
const links = new Map<string, { id: string; match: "resolved" | "alias" }>();
|
||||
for (const it of items) {
|
||||
const rid = it.sourceReleaseId ? resolved.get(it.sourceReleaseId) : undefined;
|
||||
if (rid) {
|
||||
links.set(it.key, { id: rid, match: "resolved" });
|
||||
continue;
|
||||
}
|
||||
const cands = new Set<string>();
|
||||
for (const k of titleKeys(it)) {
|
||||
for (const h of aliasHits.get(k) ?? []) {
|
||||
if (it.parsed.year && h.year && Math.abs(it.parsed.year - h.year) > 1) continue;
|
||||
cands.add(h.id);
|
||||
}
|
||||
}
|
||||
if (cands.size === 1) links.set(it.key, { id: [...cands][0]!, match: "alias" });
|
||||
}
|
||||
const entityIds = [...new Set([...links.values()].map((l) => l.id))];
|
||||
const entities = new Map<string, { id: string; title: string; year: number | null; kind: string }>();
|
||||
if (entityIds.length) {
|
||||
for (const e of await db
|
||||
.selectFrom("media_entity")
|
||||
.select(["id", "title", "year", "kind"])
|
||||
.where("id", "in", entityIds)
|
||||
.execute())
|
||||
entities.set(e.id, e);
|
||||
}
|
||||
const inv = entityIds.length ? await loadInventory(entityIds) : new Map<string, InventoryEntry>();
|
||||
return items.map((it) => {
|
||||
const link = links.get(it.key);
|
||||
const e = link ? entities.get(link.id) : undefined;
|
||||
const entry = link ? inv.get(link.id) : undefined;
|
||||
return {
|
||||
...it,
|
||||
mediaEntityId: e ? e.id : null,
|
||||
entityMatch: e ? link!.match : null,
|
||||
entity: e ? { id: e.id, title: e.title, year: e.year, kind: e.kind as never } : null,
|
||||
inventory: entry ? overlay(entry, it.sourceReleaseId) : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function overlay(entry: InventoryEntry, sourceReleaseId: string | null): SearchReleaseInventory {
|
||||
const state: SearchInventoryState = entry.downloading.length
|
||||
? "downloading"
|
||||
: entry.gaps.length
|
||||
? "gap"
|
||||
: entry.versions.length
|
||||
? "owned"
|
||||
: entry.subscribed
|
||||
? "subscribed"
|
||||
: "none";
|
||||
return {
|
||||
...entry,
|
||||
candidateReasons: sourceReleaseId
|
||||
? entry.candidateReasons.filter((c) => c.sourceReleaseId === sourceReleaseId)
|
||||
: [],
|
||||
state,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
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;
|
||||
}
|
||||
Reference in new issue
Block a user