diff --git a/apps/server/src/modules/index.ts b/apps/server/src/modules/index.ts index 56542a07..d71f1b49 100644 --- a/apps/server/src/modules/index.ts +++ b/apps/server/src/modules/index.ts @@ -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, ]; diff --git a/apps/server/src/modules/search/actions.ts b/apps/server/src/modules/search/actions.ts new file mode 100644 index 00000000..f48ed524 --- /dev/null +++ b/apps/server/src/modules/search/actions.ts @@ -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); +} diff --git a/apps/server/src/modules/search/dispatch.ts b/apps/server/src/modules/search/dispatch.ts new file mode 100644 index 00000000..d8cb1939 --- /dev/null +++ b/apps/server/src/modules/search/dispatch.ts @@ -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 | null = null; + private readonly origins = new WeakMap(); + private readonly errors = new WeakMap(); + + constructor(private readonly api: ModuleApp) {} + + private app(): Hono { + if (this.inner) return this.inner; + const inner = new Hono(); + 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(from: Context, method: "GET" | "POST", path: string, body?: unknown): Promise { + 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; + } +} diff --git a/apps/server/src/modules/search/index.ts b/apps/server/src/modules/search/index.ts new file mode 100644 index 00000000..276c4eae --- /dev/null +++ b/apps/server/src/modules/search/index.ts @@ -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(); diff --git a/apps/server/src/modules/search/media.ts b/apps/server/src/modules/search/media.ts new file mode 100644 index 00000000..a9206da3 --- /dev/null +++ b/apps/server/src/modules/search/media.ts @@ -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[]> { + const n = normalizeTitle(q); + const like = `${q.trim().replace(/[\\%_]/g, (m) => `\\${m}`)}%`; + const rows: LocalRow[] = []; + if (n) { + const r = await sql` + 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` + 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(); + 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`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[], + inv: Map, +): 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[]; +} + +/** TMDB 候选:客户端自带请求缓存,这里再按查询词缓存结果;localEntityId 每次按外部身份现查 */ +export class TmdbSearch { + private readonly cache = new Map(); + private readonly inflight = new Map[]>>(); + + constructor( + private readonly rt: () => SourcesRuntime, + private readonly opts: { ttlMs: number; timeoutMs: number; now: () => number }, + ) {} + + async search(q: string, limit: number): Promise { + 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[]; + 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> { + const out = new Map(); + 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(p: Promise, signal: AbortSignal, message: string): Promise { + if (signal.aborted) return Promise.reject(new Error(message)); + return new Promise((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); + }, + ); + }); +} diff --git a/apps/server/src/modules/search/releases.ts b/apps/server/src/modules/search/releases.ts new file mode 100644 index 00000000..dc490606 --- /dev/null +++ b/apps/server/src/modules/search/releases.ts @@ -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; + +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(); + private readonly inflight = new Map>(); + + constructor( + private readonly rt: () => SourcesRuntime, + private readonly opts: FanoutOptions, + ) {} + + async search(q: string, opts: { refresh?: boolean } = {}): Promise { + 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(); + + 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 { + 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; + 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(); + found = found.filter((r) => !seen.has(r.siteReleaseId) && !!seen.add(r.siteReleaseId)); + const ids = new Map(); + 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, 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(items: readonly T[], limit: number, fn: (t: T) => Promise): Promise { + const out = new Array(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(); + 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>; + +export async function decorate( + rt: SourcesRuntime, + items: BaseRelease[], + loadInventory: InventoryLoader, +): Promise { + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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, + }; +} diff --git a/apps/server/src/modules/search/routes.ts b/apps/server/src/modules/search/routes.ts new file mode 100644 index 00000000..654f633a --- /dev/null +++ b/apps/server/src/modules/search/routes.ts @@ -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 extends z.ZodType ? z.infer : Record; +interface Input { + params: Infer; + query: Infer; + body: Infer; + c: Context; +} + +function route(app: ModuleApp, def: D, handler: (input: Input) => Promise) { + const request: RouteConfig["request"] = {}; + if (def.params) request.params = def.params as never; + if (def.query) request.query = def.query as never; + if (def.body) request.body = { content: { "application/json": { schema: def.body as never } }, required: true }; + const r = createRoute({ + method: def.method, + path: def.path, + summary: def.summary, + tags: ["search"], + middleware: [requireScope(def.scope as ApiScope)] as never, + request, + responses: { + 200: { description: def.summary, content: { "application/json": { schema: apiResponse(def.response) } } }, + }, + }); + // biome-ignore lint/suspicious/noExplicitAny: 路由泛型在封装里擦除,入参已由 zod-openapi 校验 + app.openapi(r, (async (c: any) => { + const input = { + params: def.params ? c.req.valid("param") : {}, + query: def.query ? c.req.valid("query") : {}, + body: def.body ? c.req.valid("json") : {}, + c, + } as Input; + const data = def.response.parse(await handler(input)); + return c.json({ ok: true, data }, 200); + // biome-ignore lint/suspicious/noExplicitAny: 同上 + }) as any); +} + +const since = (t0: number) => Math.round(performance.now() - t0); + +export function registerSearchRoutes(app: ModuleApp, ctx: ServerContext, d: SearchDeps) { + const fanout = new ReleaseFanout(d.runtime, { + timeoutMs: d.siteTimeoutMs, + concurrency: d.siteConcurrency, + ttlMs: d.cacheTtlMs, + now: d.now, + }); + const tmdb = new TmdbSearch(d.runtime, { ttlMs: d.tmdbCacheTtlMs, timeoutMs: d.siteTimeoutMs, now: d.now }); + const dispatch = new InternalDispatcher(app); + + /** 库存叠加:转调 GET /inventory(每次最多 200 个作品) */ + const inventory = + (c: Context) => + async (ids: string[]): Promise> => { + const out = new Map(); + for (let i = 0; i < ids.length; i += 200) { + const chunk = ids.slice(i, i + 200); + const r = await dispatch.call( + c, + "GET", + `/inventory?entityIds=${encodeURIComponent(chunk.join(","))}`, + ); + for (const e of r.items) out.set(e.entityId, e); + } + return out; + }; + + route(app, Api.search.all, async ({ query, c }) => { + const t0 = performance.now(); + const q = query as SearchQuery; + const want = (s: SearchQuery["scope"]) => q.scope === "all" || q.scope === s; + const loadInv = inventory(c); + + const media = want("media") + ? Promise.all([ + (async () => { + const t = performance.now(); + const hits = await searchLocalMedia(ctx, q.q, q.limit); + const inv = hits.length ? await loadInv(hits.map((h) => h.entity.id)) : new Map(); + return { items: withInventory(hits, inv), tookMs: since(t) }; + })(), + (async () => { + const t = performance.now(); + const r = await tmdb.search(q.q, q.limit); + return { ...r, tookMs: since(t) }; + })(), + ]).then(([local, tm]) => ({ local, tmdb: tm })) + : Promise.resolve(null); + + const releases = want("releases") + ? (async () => { + const t = performance.now(); + const hit = await fanout.search(q.q); + const sorted = sortReleases(hit.fanout.items, "default", "desc"); + const items = await decorate(d.runtime(), sorted.slice(0, q.limit), loadInv); + const failed = hit.fanout.sites.filter((s) => s.status === "failed" || s.status === "timeout"); + return { + items, + total: sorted.length, + partial: failed.length > 0, + sites: hit.fanout.sites, + failedSites: failed, + cached: hit.cached, + coalesced: hit.coalesced, + fetchedAt: hit.fanout.fetchedAt, + tookMs: since(t), + }; + })() + : Promise.resolve(null); + + const actions = want("actions") + ? (() => { + const t = performance.now(); + const items = matchActions(q.q, q.limit); + return { items, tookMs: since(t) }; + })() + : null; + + const [m, r] = await Promise.all([media, releases]); + const out: SearchResponse = { q: q.q, scope: q.scope, media: m, releases: r, actions, tookMs: since(t0) }; + return out; + }); + + route(app, Api.search.releases, async ({ query, c }) => { + const t0 = performance.now(); + const q = query as ReleaseSearchQuery; + const hit = await fanout.search(q.q, { refresh: q.refresh === "true" }); + const all = hit.fanout.items; + const filtered = sortReleases(filterReleases(all, q), q.sort, q.order); + const offset = decodeCursor(q.cursor); + const pageItems = filtered.slice(offset, offset + q.limit); + const next = offset + q.limit < filtered.length ? encodeCursor(offset + q.limit) : null; + const failed = hit.fanout.sites.filter((s) => s.status === "failed" || s.status === "timeout"); + const out: ReleaseSearchPage = { + q: q.q, + items: await decorate(d.runtime(), pageItems, inventory(c)), + nextCursor: next, + total: filtered.length, + unfilteredTotal: all.length, + facets: facets(all), + partial: failed.length > 0, + sites: hit.fanout.sites, + failedSites: failed, + cached: hit.cached, + coalesced: hit.coalesced, + fetchedAt: hit.fanout.fetchedAt, + tookMs: since(t0), + }; + return out; + }); + + route(app, Api.search.plan, async ({ params, body, c }) => { + const p = params as SearchReleaseParams; + const rt = d.runtime(); + const find = () => + rt.ctx.db + .selectFrom("source_release") + .select(["id"]) + .where("site_id", "=", p.siteId) + .where("site_release_id", "=", p.siteReleaseId) + .executeTakeFirst(); + let row = await find(); + if (!row) { + // 搜索时落库失败的兜底:按缓存里的站点结果补写 + const cached = fanout.findCached(p.siteId, p.siteReleaseId); + const site = cached + ? await rt.ctx.db.selectFrom("site").select(["id"]).where("id", "=", p.siteId).executeTakeFirst() + : undefined; + if (cached && site) { + await rt.ctx.db.transaction().execute((trx) => upsertSiteRelease(rt, trx, site, cached.raw)); + row = await find(); + } + } + if (!row) throw new ApiException("not_found", "资源不在搜索结果里,重新搜索后再下载"); + return dispatch.call(c, "POST", "/acquisition-plans", { + ...(body as SearchReleasePlanRequest), + sourceReleaseId: row.id, + }); + }); +} + +const encodeCursor = (offset: number) => Buffer.from(String(offset), "utf8").toString("base64url"); +function decodeCursor(cursor: string | undefined): number { + if (!cursor) return 0; + const n = Number(Buffer.from(cursor, "base64url").toString("utf8")); + return Number.isSafeInteger(n) && n >= 0 ? n : 0; +} diff --git a/apps/server/test/modules.test.ts b/apps/server/test/modules.test.ts index 91d95fad..0f2bc42d 100644 --- a/apps/server/test/modules.test.ts +++ b/apps/server/test/modules.test.ts @@ -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", ]); }); diff --git a/apps/server/test/search/search.test.ts b/apps/server/test/search/search.test.ts new file mode 100644 index 00000000..ddd2aaab --- /dev/null +++ b/apps/server/test/search/search.test.ts @@ -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; + let admin: K; + let schema: string; + let nexus: MockNexusPhpSite; + let mteam: MockMteamApi; + let app: ReturnType; + 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({ + 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({ + dialect: new PostgresDialect({ + pool: new pg.Pool({ connectionString: PG_URL, max: 8, options: `-c search_path=${schema},public` }), + }), + }) as unknown as Kysely; + 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((_, 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); + }); +}); diff --git a/docs/runbook/search.md b/docs/runbook/search.md new file mode 100644 index 00000000..45a49d3b --- /dev/null +++ b/docs/runbook/search.md @@ -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 文本)。 +- 资源有结果但没有库存叠加:资源还没识别,而且标题没有精确命中唯一的作品别名。等识别流水线处理完,或者在工作台手动指定作品后刷新。 diff --git a/packages/contracts/src/api.ts b/packages/contracts/src/api.ts index 5b54ba28..cc619364 100644 --- a/packages/contracts/src/api.ts +++ b/packages/contracts/src/api.ts @@ -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({ diff --git a/packages/contracts/src/search.ts b/packages/contracts/src/search.ts new file mode 100644 index 00000000..b78b344e --- /dev/null +++ b/packages/contracts/src/search.ts @@ -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; + +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; + +// ── 本地作品 ── + +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; + +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; + +// ── 跨站资源 ── + +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; + +/** + * 库存叠加状态(只在关联到作品时给出): + * downloading 有下载中任务 > gap 有未满足的版本目标 > owned 已有版本 > subscribed 只有订阅 > none 作品已登记但无库存/目标 + */ +export const SearchInventoryState = z.enum(["downloading", "gap", "owned", "subscribed", "none"]); +export type SearchInventoryState = z.infer; + +export const SearchReleaseInventory = InventoryEntry.extend({ + state: SearchInventoryState, +}); +export type SearchReleaseInventory = z.infer; + +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; + +export const SearchSiteStatus = z.enum(["ok", "failed", "timeout", "skipped"]); +export type SearchSiteStatus = z.infer; + +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; + +/** 跨站搜索的覆盖信息:部分站点失败不影响其它站点结果 */ +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; + +// ── 页面与操作跳转(命令面板) ── + +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; + +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; + +// ── 资源结果页 ── + +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; + +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; + +export const FacetCount = z.object({ value: z.string(), label: z.string(), count: NonNegInt }); +export type FacetCount = z.infer; + +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; + +export const SearchReleaseParams = z.object({ siteId: Id, siteReleaseId: z.string().min(1).max(200) }); +export type SearchReleaseParams = z.infer; + +/** 从搜索结果直接下载:与 POST /acquisition-plans 同一请求体,资源由路径定位 */ +export const SearchReleasePlanRequest = ManualAcquisitionRequest.omit({ sourceReleaseId: true }); +export type SearchReleasePlanRequest = z.infer;