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:
rootandClaude Opus 5.5 committed 2026-09-28 15:05:58 +08:00
1 parent e0c0c4f51d
commit a0946bf480
12 files changed
+1869 -1

No files matched your search

+2
View File
@@ -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,
];
+67
View File
@@ -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;
}
}
+43
View File
@@ -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();
+243
View File
@@ -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);
},
);
});
}
+398
View File
@@ -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,
};
}
+224
View File
@@ -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;
}
+2 -1
View File
@@ -43,7 +43,7 @@ const pingModule: ServerModule = {
};
describe("ServerModule 骨架", () => {
it("清单里是四个 W2 模块 + 文件管理 + Pi 上游 + 定时任务(末尾),名字唯一", () => {
it("清单里是四个 W2 模块 + 文件管理 + Pi 上游 + 定时任务 + 全局搜索(末尾),名字唯一", () => {
expect(SERVER_MODULES.map((m) => m.name)).toEqual([
"auth-settings",
"sources-identity",
@@ -52,6 +52,7 @@ describe("ServerModule 骨架", () => {
"file-manager",
"pi",
"schedules",
"search",
]);
});
+455
View File
@@ -0,0 +1,455 @@
import { type DB, type Kysely, migrateToLatest, sql } from "@umt/db";
import { normalizeTitle } from "@umt/domain";
import {
AUDIENCES_FIXTURE_RELEASES,
createMockMteamApi,
createMockNexusPhpSite,
type MockMteamApi,
type MockNexusPhpSite,
MTEAM_FIXTURE_TORRENTS,
} from "@umt/testkit";
import { Kysely as K, PostgresDialect } from "kysely";
import pg from "pg";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { createOutboxPort } from "../../src/context.ts";
import { createApp } from "../../src/http/app.ts";
import { EventBus } from "../../src/http/sse.ts";
import { createAcquisitionOrganizeModule } from "../../src/modules/acquisition-organize/index.ts";
import { createAgentModule } from "../../src/modules/agent/index.ts";
import { createSearchModule } from "../../src/modules/search/index.ts";
import { type BaseRelease, filterReleases } from "../../src/modules/search/releases.ts";
import { createSourcesIdentityModule } from "../../src/modules/sources-identity/index.ts";
import type { ServerContext } from "../../src/modules/types.ts";
import { DEV_KEY, memoryLogger, memoryStore } from "../helpers.ts";
import { fakeEngine, fakeSecrets, PG_URL, pgAvailable, plainSettings } from "../sources-identity/harness.ts";
// W9-G 全局搜索:两个 mock 站点(NexusPHP 观众 + M-Team API)+ 一个挂起的站点(超时 → partial)、
// 并发相同查询合并、30s 缓存命中、库存叠加字段、筛选排序游标、从搜索结果下载转调 /acquisition-plans。
const up = await pgAvailable();
const READ_KEY = "w9g-read-key";
const SLOW_HOST = "slow-site.invalid";
const sample: BaseRelease = {
key: "k",
sourceReleaseId: null,
siteId: "00000000-0000-4000-8000-000000000000",
siteName: "s",
siteReleaseId: "1",
title: "t",
subtitle: null,
category: { id: null, name: null },
sizeBytes: null,
seeders: null,
leechers: null,
snatched: null,
promotion: { type: "none", downloadFactor: 1, uploadFactor: 1, expiresAt: null },
free: false,
hr: "unknown",
uploadedAt: null,
detailUrl: "http://x/",
observedAt: "2026-09-28T00:00:00.000Z",
parsed: {
titleCn: null,
titleEn: null,
year: null,
season: null,
episodes: null,
resolution: null,
source: null,
videoCodec: null,
hdr: [],
releaseGroup: null,
},
};
// biome-ignore lint/suspicious/noExplicitAny: 测试断言直接读 JSON
type Loose = any;
describe.skipIf(!up)("全局搜索", () => {
let db: Kysely<DB>;
let admin: K<unknown>;
let schema: string;
let nexus: MockNexusPhpSite;
let mteam: MockMteamApi;
let app: ReturnType<typeof createApp>;
let slowCalls = 0;
const sites: Record<"nexus" | "mteam" | "slow", string> = { nexus: "", mteam: "", slow: "" };
const req = async (path: string, init: { method?: string; body?: unknown; key?: string } = {}) => {
const res = await app.request(`/api/v1${path}`, {
method: init.method ?? (init.body !== undefined ? "POST" : "GET"),
headers: {
authorization: `Bearer ${init.key ?? DEV_KEY}`,
...(init.body !== undefined ? { "content-type": "application/json" } : {}),
},
...(init.body !== undefined ? { body: JSON.stringify(init.body) } : {}),
});
return { status: res.status, body: (await res.json()) as Loose };
};
const nexusSearches = () => nexus.requests.filter((r) => r.path.includes("search=")).length;
const mteamSearches = () => mteam.requests.filter((r) => r.path.includes("/api/torrent/search")).length;
beforeAll(async () => {
nexus = await createMockNexusPhpSite({
releases: [
...AUDIENCES_FIXTURE_RELEASES,
{
id: "900001",
title: "Dune.Part.Two.2024.1080p.WEB-DL.H264.AAC-GRP",
subtitle: "沙丘2",
catId: "401",
catName: "电影",
sizeText: "8.00 GB",
seeders: "300",
leechers: "5",
snatched: "900",
uploadedAt: "2026-09-20 10:00:00",
hr: true,
},
],
});
mteam = await createMockMteamApi({
torrents: [
...MTEAM_FIXTURE_TORRENTS,
{
id: "990001",
name: "Dune.Part.Two.2024.2160p.WEB-DL.DDP5.1.H.265-FLUX",
smallDescr: "沙丘2",
category: "419",
size: "25000000000",
createdDate: "2026-09-22 08:00:00",
status: { seeders: "40", leechers: "1", timesCompleted: "100", discount: "NORMAL" },
},
],
});
schema = `w9g_search_${process.pid.toString(36)}${Math.random().toString(36).slice(2, 7)}`;
admin = new K<unknown>({
dialect: new PostgresDialect({ pool: new pg.Pool({ connectionString: PG_URL, max: 1 }) }),
});
await sql.raw(`create schema ${schema}`).execute(admin);
await import("@umt/db");
db = new K<DB>({
dialect: new PostgresDialect({
pool: new pg.Pool({ connectionString: PG_URL, max: 8, options: `-c search_path=${schema},public` }),
}),
}) as unknown as Kysely<DB>;
await migrateToLatest(db, { migrationTableSchema: schema });
const { logger } = memoryLogger();
const secrets = fakeSecrets(() => db);
const ctx = {
role: "all",
db,
config: { NODE_ENV: "development", DEV_ADMIN_KEY: DEV_KEY } as never,
logger,
events: new EventBus(5),
engine: fakeEngine(),
outbox: createOutboxPort(),
secrets,
settings: plainSettings(() => db),
capabilities: {} as never,
} as ServerContext;
// 挂起的站点:请求一直不返回,直到被中止(单站超时)
const fetchImpl: typeof fetch = (input, init) => {
const url = new URL(input instanceof Request ? input.url : String(input));
if (url.hostname !== SLOW_HOST) return fetch(input, init);
slowCalls++;
return new Promise<Response>((_, reject) => {
const signal = init?.signal ?? (input instanceof Request ? input.signal : undefined);
signal?.addEventListener("abort", () => reject(signal.reason), { once: true });
});
};
const sources = createSourcesIdentityModule({ fetch: fetchImpl, tmdb: () => null });
const search = createSearchModule({ runtime: (c) => sources.runtime(c), siteTimeoutMs: 600 });
app = createApp({
config: { NODE_ENV: "development", DEV_ADMIN_KEY: DEV_KEY },
logger,
bus: new EventBus(5),
role: "all",
version: "0.0.0-test",
modules: [sources, createAcquisitionOrganizeModule(), createAgentModule(), search],
ctx,
apiKeyStore: memoryStore({ [READ_KEY]: { id: "reader", scopes: ["read"] } }),
});
const addSite = async (
key: string,
name: string,
adapter: "nexusphp" | "mteam_api",
baseUrl: string,
config = {},
) =>
(
await db
.insertInto("site")
.values({
key,
name,
adapter,
base_url: baseUrl,
rate_limit: JSON.stringify({ minIntervalMs: 0, concurrency: 5 }),
config: JSON.stringify(config),
})
.returning("id")
.executeTakeFirstOrThrow()
).id;
const addAccount = async (siteId: string, kind: "cookie" | "api_key", plaintext: string) => {
const ref = await secrets.create({ purpose: kind === "cookie" ? "site_cookie" : "site_api_key", plaintext });
await db
.insertInto("site_account")
.values({ site_id: siteId, label: "main", auth_kind: kind, secret_ref: ref })
.execute();
};
sites.nexus = await addSite("audiences", "观众", "nexusphp", `${nexus.baseUrl}/`);
await addAccount(sites.nexus, "cookie", nexus.cookie);
sites.mteam = await addSite("mteam", "馒头", "mteam_api", "https://kp.m-team.cc/", { apiBaseUrl: mteam.baseUrl });
await addAccount(sites.mteam, "api_key", mteam.apiKey);
sites.slow = await addSite("hdsky", "天空", "nexusphp", `http://${SLOW_HOST}/`);
await addAccount(sites.slow, "cookie", "uid=1; pass=x");
// 已关闭的站点不参与搜索
await db
.insertInto("site")
.values({ key: "hdhome", name: "家园", adapter: "nexusphp", base_url: "http://off.invalid/", enabled: false })
.execute();
}, 60_000);
afterAll(async () => {
await db?.destroy();
if (admin) {
await sql.raw(`drop schema if exists ${schema} cascade`).execute(admin);
await admin.destroy();
}
await nexus?.close();
await mteam?.close();
});
it("两站成功 + 一站超时 → partial;结果写入资源表;每组带 tookMs", async () => {
const r = await req("/search?q=Dune&scope=releases&limit=50");
expect(r.status).toBe(200);
const d = r.body.data;
expect(d.media).toBeNull();
expect(d.actions).toBeNull();
const rel = d.releases;
expect(rel.partial).toBe(true);
expect(rel.failedSites).toEqual([
expect.objectContaining({
siteId: sites.slow,
status: "timeout",
count: 0,
error: expect.stringContaining("秒"),
}),
]);
const byId = Object.fromEntries(rel.sites.map((s: Loose) => [s.siteId, s]));
expect(Object.keys(byId).sort()).toEqual([sites.nexus, sites.mteam, sites.slow].sort());
expect(byId[sites.nexus]).toMatchObject({ status: "ok", count: 2 });
expect(byId[sites.mteam]).toMatchObject({ status: "ok", count: 1 });
expect(slowCalls).toBeGreaterThan(0);
expect(rel.total).toBe(3);
expect(rel.cached).toBe(false);
expect(typeof rel.tookMs).toBe("number");
expect(typeof d.tookMs).toBe("number");
// 默认排序:做种数降序
expect(rel.items.map((i: Loose) => i.seeders)).toEqual([1204, 300, 40]);
const remux = rel.items[0];
expect(remux).toMatchObject({
siteId: sites.nexus,
siteName: "观众",
siteReleaseId: "812345",
free: true,
parsed: { resolution: "2160p", source: "REMUX" },
});
expect(remux.sourceReleaseId).toMatch(/^[0-9a-f-]{36}$/);
expect(rel.items[1]).toMatchObject({ free: false, parsed: { resolution: "1080p", source: "WEB-DL" } });
const rows = await db.selectFrom("source_release").select(["id", "site_id"]).execute();
expect(rows.length).toBe(3);
});
it("30s 内相同查询命中缓存,不再请求站点", async () => {
const before = [nexusSearches(), mteamSearches(), slowCalls];
const r = await req("/search?q=%20dune%20&scope=releases");
expect(r.body.data.releases).toMatchObject({ cached: true, total: 3, partial: true });
const page = await req("/search/releases?q=Dune");
expect(page.body.data).toMatchObject({ cached: true, total: 3 });
expect([nexusSearches(), mteamSearches(), slowCalls]).toEqual(before);
// refresh=true 跳过缓存
const fresh = await req("/search/releases?q=Dune&refresh=true");
expect(fresh.body.data.cached).toBe(false);
expect(nexusSearches()).toBe(before[0]! + 1);
});
it("并发的相同查询合并为一次站点请求", async () => {
const before = mteamSearches();
const [a, b] = await Promise.all([
req("/search/releases?q=Oppenheimer"),
req("/search?q=oppenheimer&scope=releases"),
]);
expect(a.status).toBe(200);
expect(b.status).toBe(200);
expect(mteamSearches() - before).toBe(1);
const flags = [a.body.data.coalesced, b.body.data.releases.coalesced];
expect(flags.filter(Boolean).length).toBe(1);
expect(a.body.data.items[0]).toMatchObject({ siteId: sites.mteam, siteReleaseId: "912234" });
});
it("库存叠加:已识别资源 → resolved + 订阅/版本目标缺口;未识别但标题命中唯一作品别名 → alias", async () => {
const entity = (
await db
.insertInto("media_entity")
.values({ kind: "movie", title: "沙丘2", original_title: "Dune: Part Two", year: 2024, status: "active" })
.returning("id")
.executeTakeFirstOrThrow()
).id;
for (const alias of ["沙丘2", "Dune Part Two"]) {
await db
.insertInto("title_alias")
.values({ media_entity_id: entity, alias, normalized: normalizeTitle(alias), kind: "original", source: "tmdb" })
.execute();
}
await db
.insertInto("subscription")
.values({ media_entity_id: entity, media_kind: "movie", mode: "once", status: "active" })
.execute();
const slot = (
await db
.insertInto("acquisition_slot")
.values({ media_entity_id: entity, name: "2160p Remux", status: "empty" })
.returning("id")
.executeTakeFirstOrThrow()
).id;
// 观众 REMUX 已识别到作品
await db
.updateTable("source_release")
.set({ media_entity_id: entity, resolution_status: "resolved" })
.where("site_id", "=", sites.nexus)
.where("site_release_id", "=", "812345")
.execute();
const r = await req("/search/releases?q=Dune");
const items = r.body.data.items as Loose[];
const remux = items.find((i) => i.siteReleaseId === "812345");
expect(remux).toMatchObject({
mediaEntityId: entity,
entityMatch: "resolved",
entity: { id: entity, title: "沙丘2", year: 2024, kind: "movie" },
inventory: {
entityId: entity,
state: "gap",
subscribed: true,
gaps: [{ slotId: slot, name: "2160p Remux", status: "empty" }],
versions: [],
downloading: [],
candidateReasons: [],
},
});
const mt = items.find((i) => i.siteReleaseId === "990001");
expect(mt).toMatchObject({ mediaEntityId: entity, entityMatch: "alias", inventory: { state: "gap" } });
// 本地作品组:别名精确命中 + 版本目标状态摘要
const all = await req("/search?q=沙丘2&scope=media");
const local = all.body.data.media.local.items;
expect(local[0]).toMatchObject({
entity: { id: entity, title: "沙丘2" },
matchedBy: "exact",
score: 1,
subscribed: true,
versionCount: 0,
slotCounts: { empty: 1, satisfied: 0 },
});
expect(all.body.data.media.tmdb).toMatchObject({ configured: false, items: [] });
expect(typeof all.body.data.media.local.tookMs).toBe("number");
const prefix = await req("/search?q=Dune%20Pa&scope=media");
expect(prefix.body.data.media.local.items[0]).toMatchObject({ entity: { id: entity }, matchedBy: "prefix" });
});
it("筛选 / 排序 / 游标 / 分面", async () => {
const q = (s: string) => req(`/search/releases?q=Dune&${s}`).then((r) => r.body.data);
let d = await q("resolution=2160p");
expect(d.items.map((i: Loose) => i.siteReleaseId).sort()).toEqual(["812345", "990001"]);
expect(d).toMatchObject({ total: 2, unfilteredTotal: 3 });
expect(d.facets.resolutions).toEqual([
{ value: "2160p", label: "2160p", count: 2 },
{ value: "1080p", label: "1080p", count: 1 },
]);
expect(d.facets.sites.find((f: Loose) => f.value === sites.nexus)).toMatchObject({ label: "观众", count: 2 });
d = await q("source=web-dl,REMUX&free=false");
expect(d.items.map((i: Loose) => i.siteReleaseId).sort()).toEqual(["900001", "990001"]);
d = await q(`sites=${sites.mteam}`);
expect(d.items.map((i: Loose) => i.siteReleaseId)).toEqual(["990001"]);
d = await q(`minSize=${10 * 1024 ** 3}&maxSize=${50 * 1024 ** 3}`);
expect(d.items.map((i: Loose) => i.siteReleaseId)).toEqual(["990001"]);
d = await q("sort=size&order=asc");
expect(d.items.map((i: Loose) => i.siteReleaseId)).toEqual(["900001", "990001", "812345"]);
d = await q("sort=uploadedAt");
expect(d.items.map((i: Loose) => i.siteReleaseId)).toEqual(["812345", "990001", "900001"]);
d = await q("sort=seeders&order=asc");
expect(d.items.map((i: Loose) => i.seeders)).toEqual([40, 300, 1204]);
const p1 = await q("limit=2");
expect(p1.items.length).toBe(2);
expect(p1.nextCursor).toBeTruthy();
const p2 = await q(`limit=2&cursor=${p1.nextCursor}`);
expect(p2.items.map((i: Loose) => i.seeders)).toEqual([40]);
expect(p2.nextCursor).toBeNull();
});
it("H&R 筛选:true 只要明确 H&R;false 只排除明确 H&R(unknown 保留)", () => {
const base = (id: string, hr: boolean | "unknown") => ({ ...sample, key: id, siteReleaseId: id, hr });
const items = [base("a", true), base("b", false), base("c", "unknown")];
const f = (hr: "true" | "false") =>
filterReleases(items, { q: "x", hr, sort: "default", order: "desc", limit: 50 }).map((i) => i.siteReleaseId);
expect(f("true")).toEqual(["a"]);
expect(f("false")).toEqual(["b", "c"]);
});
it("页面与操作跳转;scope=all 四组都在", async () => {
const r = await req("/search?q=设置&scope=actions");
expect(r.body.data.actions.items[0]).toMatchObject({
id: "page.settings",
kind: "page",
href: "/console/settings",
});
const sub = await req("/search?q=订阅&scope=actions");
expect(sub.body.data.actions.items.map((a: Loose) => a.id)).toContain("action.new-subscription");
const all = await req("/search?q=Dune");
expect(all.body.data.media).not.toBeNull();
expect(all.body.data.releases).not.toBeNull();
expect(all.body.data.actions).not.toBeNull();
});
it("从搜索结果下载:转调 /acquisition-plans(同一鉴权、同一拒绝原因)", async () => {
// 未识别的资源 → 与 POST /acquisition-plans 相同的 412
const unresolved = await req(`/search/releases/${sites.nexus}/900001/plan`, { body: {} });
expect(unresolved.status).toBe(412);
expect(unresolved.body.error.code).toBe("precondition_failed");
// 已识别但作品没有版本目标 → submitted=false + no_slot
const other = (
await db
.insertInto("media_entity")
.values({ kind: "movie", title: "奥本海默", year: 2023, status: "active" })
.returning("id")
.executeTakeFirstOrThrow()
).id;
await db
.updateTable("source_release")
.set({ media_entity_id: other })
.where("site_id", "=", sites.mteam)
.where("site_release_id", "=", "912234")
.execute();
const ok = await req(`/search/releases/${sites.mteam}/912234/plan`, { body: { note: "from search" } });
expect(ok.status).toBe(200);
expect(ok.body.data).toMatchObject({ submitted: false, plan: null, rejected: [{ code: "no_slot" }] });
// 不在搜索结果里
const missing = await req(`/search/releases/${sites.mteam}/nope/plan`, { body: {} });
expect(missing.status).toBe(404);
// read scope 不能下载;但可以搜索
const ro = await req(`/search/releases/${sites.mteam}/912234/plan`, { body: {}, key: READ_KEY });
expect(ro.status).toBe(403);
expect((await req("/search?q=Dune&scope=releases", { key: READ_KEY })).status).toBe(200);
});
});