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

+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>;