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);
});
});
+68
View File
@@ -0,0 +1,68 @@
# 全局搜索运行手册(W9-G)
实现:`apps/server/src/modules/search/`(`routes.ts` 端点、`releases.ts` 跨站搜索/缓存/筛选/叠加、`media.ts` 本地作品与 TMDB、`actions.ts` 页面与操作清单、`dispatch.ts` 进程内转调);合同 `packages/contracts/src/search.ts`;测试 `apps/server/test/search/`。
## 端点
| 端点 | scope | 说明 |
|---|---|---|
| `GET /search?q=&scope=all\|media\|releases\|actions&limit=` | read | 聚合搜索。`limit` 是每组条数(1–50,默认 10)。不查的组返回 `null` |
| `GET /search/releases?q=&sites=&resolution=&source=&minSize=&maxSize=&free=&hr=&sort=&order=&refresh=&cursor=&limit=` | read | 资源结果页。与 `/search` 共用缓存 |
| `POST /search/releases/{siteId}/{siteReleaseId}/plan {slotId?, entityId?, note?}` | organize.execute | 从搜索结果下载,走 `POST /acquisition-plans` 同一流程 |
### `/search` 响应
- `media.local`:本地作品。召回方式有三种:`title_alias.normalized` 精确命中(score 1)、前缀命中(0.9)、trgm 相似度 ≥ 0.3;另外按作品名/原名前缀补召回没有别名的手建作品。每条带 `slotCounts`(版本目标各状态计数)、`versionCount`、`downloadingCount`、`subscribed`。
- `media.tmdb`:TMDB 候选。`configured=false` 表示没配置 TMDB,不代表没有结果;查询失败时 `error` 有值、`items` 为空。已有本地作品接受了该 TMDB 身份时,`localEntityId` 给出本地作品 ID(合并过的作品跟到最终作品)。
- `releases`:跨站资源,只返回前 `limit` 条(按默认排序),`total` 是全部条数。覆盖信息见下文。
- `actions`:页面与操作跳转的静态清单,按 IA-3 定稿导航分组(定时任务在任务页"计划"Tab;运行状态页路由确定后补)。标题前缀命中排在前面,关键词或路径包含查询词也算命中。
- 每组各带 `tookMs`,顶层 `tookMs` 是整个请求的耗时。
## 跨站资源搜索
- **站点范围**:`enabled=true` 且未删除的站点,全部参与,`sites=` 筛选在拿到结果之后才做。不支持搜索或没有适配器定义的站点记为 `skipped`。
- **限流**:沿用 sources-identity 运行时里每个站点的共享限流器,和快照、工作台、详情、下载用的是同一个令牌桶。
- **超时**:单站 `AbortSignal.timeout(15s)`,限流等待也算在内;超时记为 `timeout`。
- **站点级并发**:同时最多搜索 4 个站点(`createSearchModule({ siteConcurrency })`)。
- **并发合并**:相同查询词(去首尾空白、小写、合并空格)同时只会发一轮站点请求,后到的请求等同一个结果,`coalesced=true`。
- **短缓存**:结果缓存 30s,命中时 `cached=true`。只要有一个站点成功就缓存;全部失败不缓存。`refresh=true` 跳过缓存,但仍然参与合并。进程内缓存,多个 api 实例之间不共享。
- **部分失败**:任一站点失败或超时,`partial=true`,`failedSites` 列出这些站点(status 为 `failed` 或 `timeout`,附已脱敏的错误)。其它站点的结果照常返回。`sites` 是每个站点的覆盖情况:status、count、tookMs、error。
- **落库**:搜索结果按 site + 站点资源 ID upsert 到资源表(`source_release`),新资源照常发 `release.observed`,进入识别流水线,和工作台实时补查是同一条路径。`sourceReleaseId` 给站点工作台、下载、委托 Agent 用。落库失败只记警告,仍然返回结果,此时 `sourceReleaseId=null`。搜索不写 `site_snapshot`,也不改站点健康状态。
### 作品关联与库存叠加
叠加每次请求时现算,不进缓存,所以识别完成后刷新一次就能看到。
- `entityMatch=resolved`:资源已经识别到作品。
- `entityMatch=alias`:资源还没识别,但解析出的中文或英文标题归一化后精确命中别名,且只命中一个作品(年份相差不超过 1)。这个关联没有确认,只用来显示库存。
- `inventory`:转调 `GET /inventory` 得到,字段同工作台叠加(versions / gaps / downloading / subscribed / candidateReasons),其中 `candidateReasons` 只保留针对本条资源的评估。另有 `state` 字段,取值按优先级依次为 `downloading` > `gap`(有未满足的版本目标)> `owned` > `subscribed` > `none`。
### 结果页筛选与排序
- `sites`、`resolution`、`source` 都是逗号分隔,大小写不敏感;解析不出的值记为 `unknown`,可以用来筛选。
- `minSize`、`maxSize` 单位是字节,大小未知的资源会被排除。
- `free=true` 只保留下载计量为 0 的资源,`free=false` 排除它们。
- `hr=true` 只保留明确标了 H&R 的资源;`hr=false` 只排除明确 H&R 的,`unknown` 保留。目前观众等站点的定义没有 H&R 选择器,结果一律是 `unknown`。
- `sort=default|seeders|size|uploadedAt|site`,配合 `order=asc|desc`。`default` 按做种数降序,做种数相同时按发布时间从新到旧;空值始终排在最后。
- `facets` 统计的是未筛选的全部结果,包括站点、分辨率、来源三项。`total` 是筛选后的条数,`unfilteredTotal` 是筛选前的条数。游标是不透明的偏移量。
## 从搜索结果下载
1. 按 (siteId, siteReleaseId) 找资源行。找不到时,用缓存里的站点结果补写一行(对应搜索时落库失败的情况)。缓存里也没有就返回 404,需要重新搜索。
2. 进程内转调 `POST /acquisition-plans`,把 `{...body, sourceReleaseId}` 发给它。调用者身份沿用本次请求的身份,scope、入参校验、`download.plan → download.submit`、审计和拒绝原因都和直接调用那个端点一样,比如资源没关联作品时返回 412,作品没有版本目标时返回 `no_slot`。
## 进程内转调(`dispatch.ts`)
第一次调用时,把 `/api/v1` 下的模块路由表复制到一个内部 Hono 实例上,并注入当前请求已经通过鉴权的 principal / actor / session。对方端点抛出的 ApiException 原样抛回,由外层统一转成错误信封。搜索模块只读其它模块的 service 导出和站点运行时,库存叠加和下载都转调对方端点,不复制业务逻辑。
## 前端要点
- 命令面板用 `scope=media` 和 `scope=actions` 快速出结果(毫秒级,TMDB 首次查询约 1 秒)。资源组单独请求 `scope=releases`,或者直接跳到结果页 `/console/search?q=`。最慢的站点可能要等到 15s 超时。
- 资源行的操作和站点工作台一致。`sourceReleaseId` 可以直接给工作台详情和委托 Agent 用;下载用 `POST /search/releases/{siteId}/{siteReleaseId}/plan`。
- `partial=true` 时列出 `failedSites`,提示哪些站点没有结果。`cached` / `fetchedAt` 用来标注数据时间,"刷新"按钮带 `refresh=true`。
## 排障
- 某个站点一直 `timeout`:先在站点页做健康检测;限流太保守时,令牌等待也会占用 15s 预算,可以调整站点的 `rate_limit`。
- 某个站点一直 `skipped`:可能是定义不支持搜索,也可能没有随产品发布的适配器定义(看 error 文本)。
- 资源有结果但没有库存叠加:资源还没识别,而且标题没有精确命中唯一的作品别名。等识别流水线处理完,或者在工作台手动指定作品后刷新。
+33
View File
@@ -15,9 +15,12 @@ import * as organization from "./organization.ts";
import * as pi from "./pi.ts";
import * as policy from "./policy.ts";
import * as schedules from "./schedules.ts";
import * as search from "./search.ts";
import { GlobalSettings, GlobalSettingsUpdate } from "./settings.ts";
import * as sources from "./sources.ts";
export * from "./search.ts";
// 所有 REST 端点的路径常量与请求/响应 schema 汇总。
// path 相对 API_PREFIX,参数用 OpenAPI 风格 `{id}`(server 的 createRoute 直接使用);
// web 用 buildPath() 填参数。响应体统一套 apiResponse() 信封,这里的 response 是 data 部分。
@@ -1599,6 +1602,36 @@ export const Api = {
}),
},
// ── 全局搜索(W9-G):顶栏搜索 / 命令面板 / 资源结果页 ──
search: {
all: ep({
method: "get",
path: "/search",
scope: "read",
summary:
"全局搜索聚合:本地作品 / TMDB 候选 / 跨站资源(15s 超时、并发合并、30s 缓存、库存叠加)/ 页面与操作;每组带 tookMs",
query: search.SearchQuery,
response: search.SearchResponse,
}),
releases: ep({
method: "get",
path: "/search/releases",
scope: "read",
summary: "跨站资源结果页:站点/分辨率/来源/大小/免费/H&R 筛选、排序、游标;与 /search 共用缓存",
query: search.ReleaseSearchQuery,
response: search.ReleaseSearchPage,
}),
plan: ep({
method: "post",
path: "/search/releases/{siteId}/{siteReleaseId}/plan",
scope: "organize.execute",
summary: "从搜索结果直接下载:定位资源后走 POST /acquisition-plans 同一流程",
params: search.SearchReleaseParams,
body: search.SearchReleasePlanRequest,
response: execution.ManualAcquisitionResult,
}),
},
// ── 定时任务看板(W7-C):统一注册表 + Agent 巡检;全部只接受控制台会话 ──
schedules: {
list: ep({
+259
View File
@@ -0,0 +1,259 @@
import { z } from "zod";
import { Id, NonNegInt, Timestamp } from "./common.ts";
import { MediaEntityStatus, MediaKind } from "./content.ts";
import { ManualAcquisitionRequest } from "./execution.ts";
import { InventoryEntry, SlotStatusCounts } from "./organization.ts";
// 全局搜索(W9-G,对标 MP 顶栏搜索 + 资源搜索页):
// GET /search 聚合本地作品 / TMDB 候选 / 跨站资源 / 页面与操作;GET /search/releases 是资源结果页(筛选/排序/游标,复用同一缓存)。
export const SearchScope = z.enum(["all", "media", "releases", "actions"]);
export type SearchScope = z.infer<typeof SearchScope>;
export const SearchQuery = z.object({
q: z.string().trim().min(1).max(200),
/** all = 四组都查;命令面板先查 media/actions(快),资源组单独查(最慢一站可达 15s) */
scope: SearchScope.default("all"),
/** 每组最多返回条数 */
limit: z.coerce.number().int().min(1).max(50).default(10),
});
export type SearchQuery = z.infer<typeof SearchQuery>;
// ── 本地作品 ──
export const LocalMediaHit = z.object({
entity: z.object({
id: Id,
kind: MediaKind,
title: z.string(),
originalTitle: z.string().nullable(),
year: z.number().int().nullable(),
status: MediaEntityStatus,
}),
/** 0–1;精确命中 1,名称前缀 ≥ 0.9,其余为 trgm 相似度 */
score: z.number(),
matchedBy: z.enum(["exact", "prefix", "similar"]),
/** 命中的别名原文(可能是译名/原名/别名) */
matchedAlias: z.string().nullable(),
/** 版本目标状态摘要 + 库存 */
slotCounts: SlotStatusCounts,
versionCount: NonNegInt,
downloadingCount: NonNegInt,
subscribed: z.boolean(),
});
export type LocalMediaHit = z.infer<typeof LocalMediaHit>;
export const TmdbCandidate = z.object({
provider: z.literal("tmdb"),
entityType: z.enum(["movie", "tv"]),
externalId: z.string(),
title: z.string(),
originalTitle: z.string().nullable(),
year: z.number().int().nullable(),
overview: z.string().nullable(),
popularity: z.number().nullable(),
/** 已有本地作品接受了这个 TMDB 身份时给出(跟随合并跳转),前端显示"查看"而不是"订阅" */
localEntityId: Id.nullable(),
});
export type TmdbCandidate = z.infer<typeof TmdbCandidate>;
// ── 跨站资源 ──
export const SearchReleaseParsed = z.object({
titleCn: z.string().nullable(),
titleEn: z.string().nullable(),
year: z.number().int().nullable(),
season: z.number().int().nullable(),
episodes: z.object({ start: z.number().int(), end: z.number().int() }).nullable(),
resolution: z.string().nullable(),
source: z.string().nullable(),
videoCodec: z.string().nullable(),
hdr: z.array(z.string()),
releaseGroup: z.string().nullable(),
});
export type SearchReleaseParsed = z.infer<typeof SearchReleaseParsed>;
/**
* 库存叠加状态(只在关联到作品时给出):
* downloading 有下载中任务 > gap 有未满足的版本目标 > owned 已有版本 > subscribed 只有订阅 > none 作品已登记但无库存/目标
*/
export const SearchInventoryState = z.enum(["downloading", "gap", "owned", "subscribed", "none"]);
export type SearchInventoryState = z.infer<typeof SearchInventoryState>;
export const SearchReleaseInventory = InventoryEntry.extend({
state: SearchInventoryState,
});
export type SearchReleaseInventory = z.infer<typeof SearchReleaseInventory>;
export const SearchRelease = z.object({
/** `${siteId}:${siteReleaseId}`,前端列表 key */
key: z.string(),
/** 结果已写入资源表(source_release)的 ID;站点工作台 / 下载 / 委托 Agent 用它 */
sourceReleaseId: Id.nullable(),
siteId: Id,
siteName: z.string(),
siteReleaseId: z.string(),
title: z.string(),
subtitle: z.string().nullable(),
category: z.object({ id: z.string().nullable(), name: z.string().nullable() }),
sizeBytes: NonNegInt.nullable(),
seeders: z.number().int().nullable(),
leechers: z.number().int().nullable(),
snatched: z.number().int().nullable(),
promotion: z.object({
type: z.string(),
downloadFactor: z.number().nullable(),
uploadFactor: z.number().nullable(),
expiresAt: Timestamp.nullable(),
}),
/** 下载计量为 0(免费 / 2x 免费) */
free: z.boolean(),
/** H&R 三态;unknown 在清理判定中按有义务处理 */
hr: z.union([z.boolean(), z.literal("unknown")]),
uploadedAt: Timestamp.nullable(),
/** 不含凭证的详情页 */
detailUrl: z.string(),
observedAt: Timestamp,
parsed: SearchReleaseParsed,
mediaEntityId: Id.nullable(),
/** resolved = 资源已识别到作品;alias = 解析出的标题精确命中唯一作品别名(未确认) */
entityMatch: z.enum(["resolved", "alias"]).nullable(),
entity: z.object({ id: Id, title: z.string(), year: z.number().int().nullable(), kind: MediaKind }).nullable(),
/** 库存叠加;candidateReasons 只保留针对本条资源的评估 */
inventory: SearchReleaseInventory.nullable(),
});
export type SearchRelease = z.infer<typeof SearchRelease>;
export const SearchSiteStatus = z.enum(["ok", "failed", "timeout", "skipped"]);
export type SearchSiteStatus = z.infer<typeof SearchSiteStatus>;
export const SearchSiteCoverage = z.object({
siteId: Id,
siteName: z.string(),
status: SearchSiteStatus,
count: NonNegInt,
tookMs: NonNegInt,
/** 失败/超时/跳过原因(已脱敏) */
error: z.string().nullable(),
});
export type SearchSiteCoverage = z.infer<typeof SearchSiteCoverage>;
/** 跨站搜索的覆盖信息:部分站点失败不影响其它站点结果 */
export const SearchReleaseCoverage = z.object({
/** 有站点失败/超时 */
partial: z.boolean(),
sites: z.array(SearchSiteCoverage),
failedSites: z.array(SearchSiteCoverage),
/** 命中 30s 短缓存 */
cached: z.boolean(),
/** 与同时进行的相同搜索合并(没有重复请求站点) */
coalesced: z.boolean(),
/** 站点结果抓取时间 */
fetchedAt: Timestamp,
});
export type SearchReleaseCoverage = z.infer<typeof SearchReleaseCoverage>;
// ── 页面与操作跳转(命令面板) ──
export const SearchAction = z.object({
id: z.string(),
kind: z.enum(["page", "action"]),
/** 分组(导航一级分类):概览 / 资源 / 订阅 / 整理 / 系统 */
group: z.string(),
title: z.string(),
/** 控制台内相对路径 */
href: z.string(),
keywords: z.array(z.string()),
});
export type SearchAction = z.infer<typeof SearchAction>;
export const SearchGroupBase = z.object({ tookMs: NonNegInt });
export const SearchResponse = z.object({
q: z.string(),
scope: SearchScope,
media: z
.object({
local: SearchGroupBase.extend({ items: z.array(LocalMediaHit) }),
tmdb: SearchGroupBase.extend({
items: z.array(TmdbCandidate),
/** 未配置 TMDB 时为 false(不是"没有结果") */
configured: z.boolean(),
error: z.string().nullable(),
}),
})
.nullable(),
releases: SearchGroupBase.extend({
items: z.array(SearchRelease),
/** 全部站点结果条数(items 只取前 limit 条,完整列表走 /search/releases) */
total: NonNegInt,
})
.extend(SearchReleaseCoverage.shape)
.nullable(),
actions: SearchGroupBase.extend({ items: z.array(SearchAction) }).nullable(),
tookMs: NonNegInt,
});
export type SearchResponse = z.infer<typeof SearchResponse>;
// ── 资源结果页 ──
const csv = z.string().max(4000).optional();
const bool = z.enum(["true", "false"]).optional();
export const ReleaseSearchSort = z.enum(["default", "seeders", "size", "uploadedAt", "site"]);
export type ReleaseSearchSort = z.infer<typeof ReleaseSearchSort>;
export const ReleaseSearchQuery = z.object({
q: z.string().trim().min(1).max(200),
/** 逗号分隔的站点 ID */
sites: csv,
/** 逗号分隔:2160p,1080p… */
resolution: csv,
/** 逗号分隔:REMUX,WEB-DL,BluRay… */
source: csv,
/** 字节 */
minSize: z.coerce.number().int().nonnegative().optional(),
maxSize: z.coerce.number().int().nonnegative().optional(),
/** true 只要免费;false 排除免费 */
free: bool,
/** true 只要 H&R;false 排除明确 H&R 的(unknown 保留) */
hr: bool,
/** default = 做种数降序、同分按发布时间新→旧 */
sort: ReleaseSearchSort.default("default"),
order: z.enum(["asc", "desc"]).default("desc"),
/** true 跳过 30s 缓存重新查站点(仍会合并并发) */
refresh: bool,
cursor: z.string().min(1).optional(),
limit: z.coerce.number().int().min(1).max(200).default(50),
});
export type ReleaseSearchQuery = z.infer<typeof ReleaseSearchQuery>;
export const FacetCount = z.object({ value: z.string(), label: z.string(), count: NonNegInt });
export type FacetCount = z.infer<typeof FacetCount>;
export const ReleaseSearchPage = z
.object({
q: z.string(),
items: z.array(SearchRelease),
nextCursor: z.string().nullable(),
/** 筛选后的条数 */
total: NonNegInt,
/** 未筛选的全部条数 */
unfilteredTotal: NonNegInt,
/** 筛选项计数(基于未筛选的全部结果) */
facets: z.object({
sites: z.array(FacetCount),
resolutions: z.array(FacetCount),
sources: z.array(FacetCount),
}),
tookMs: NonNegInt,
})
.extend(SearchReleaseCoverage.shape);
export type ReleaseSearchPage = z.infer<typeof ReleaseSearchPage>;
export const SearchReleaseParams = z.object({ siteId: Id, siteReleaseId: z.string().min(1).max(200) });
export type SearchReleaseParams = z.infer<typeof SearchReleaseParams>;
/** 从搜索结果直接下载:与 POST /acquisition-plans 同一请求体,资源由路径定位 */
export const SearchReleasePlanRequest = ManualAcquisitionRequest.omit({ sourceReleaseId: true });
export type SearchReleasePlanRequest = z.infer<typeof SearchReleasePlanRequest>;