# 全局搜索运行手册(W9-G、SEARCH-2) 实现:`apps/server/src/modules/search/`(`routes.ts` 端点、`releases.ts` 跨站搜索/缓存/筛选/叠加、`media.ts` 本地作品与 TMDB、`works.ts` 作品合并/库存对比/查询词、`actions.ts` 页面与操作清单、`dispatch.ts` 进程内转调);合同 `packages/contracts/src/search.ts`;测试 `apps/server/test/search/`(`search-results-ui.test.ts` 为两阶段)。 ## 两阶段流程(SEARCH-2) 1. **作品**:`GET /search?scope=all|media` 只查本地账本 + TMDB,合并去重成 `media.works`(同一 TMDB 身份只出现一次,本地在前),每部带库存对比 `inventory`。**不请求任何站点**。 2. **资源**:用户在作品卡或作品详情页点「搜索资源」→ 选站点(默认 / 全部 / 指定,本机记住上次选择)→ `GET /search/releases?entityId=|tmdb=&sites=`,服务端生成查询词。 3. **订阅**只在作品详情页:本地作品 `/console/library/:id`,仅 TMDB 的 `/console/media/tmdb/:type/:id`。搜索结果与 ⌘K 面板没有订阅按钮;⌘K 选中作品打开详情页。 ## 端点 | 端点 | scope | 说明 | |---|---|---| | `GET /search?q=&scope=all\|media\|releases\|actions&limit=` | read | 聚合搜索。`limit` 是每组条数(1–50,默认 10)。不查的组返回 `null`。`all` = 作品 + 操作,不查站点;资源只在 `scope=releases` 时查(站点按默认设置) | | `GET /search/releases?q=\|entityId=\|tmdb=&sites=&siteFilter=&resolution=&source=&qf=&minSize=&maxSize=&free=&hr=&sort=&order=&refresh=&cursor=&limit=` | read | 资源结果页。`q` / `entityId` / `tmdb`(`movie:157336`)至少给一个,都没有 422。与 `/search` 共用缓存 | | `GET /media/tmdb/{type}/{id}?include=mediaServers` | read | 仅 TMDB 作品详情:TMDB 信息与季列表、本地对比、是否已建档(`localEntityId`)、查询词;`include=mediaServers` 同时转调 Emby(最慢约 8s)。没配 TMDB 412,TMDB 没有该条目 404 | | `GET /inventory/media-servers?mediaEntityId=\|tmdbType=&tmdbId=` | read | Emby 对比。按 TMDB 条目查时本地已建档就按作品查,否则只用这一个 TMDB ID,`mediaEntityId = null` | | `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.works`:作品卡(SEARCH-2)。`key` 为 `local:` 或 `tmdb::`;海报 / 简介本地缺时用 TMDB 候选的。`inventory.state`:`not_collected` 未收录(本地没有)/ `tracked` 已建档但没有入库版本 / `owned` 已有版本 / `downloading` 正在下载;另有 `versionCount`、`slotTotal` / `slotSatisfied`、`missing`(未满足的版本目标)、`subscribed`。Emby 另查(未知 ≠ 缺失)。 - `media.tmdb`:TMDB 候选。`configured=false` 表示没配置 TMDB,不代表没有结果;查询失败时 `error` 有值、`items` 为空。已有本地作品接受了该 TMDB 身份时,`localEntityId` 给出本地作品 ID(合并过的作品跟到最终作品)。 - `releases`:跨站资源,只返回前 `limit` 条(按默认排序),`total` 是全部条数。覆盖信息见下文。 - `actions`:页面与操作跳转的静态清单,按 IA-3 定稿导航分组(定时任务在任务页"计划"Tab;运行状态页路由确定后补)。标题前缀命中排在前面,关键词或路径包含查询词也算命中。 - 每组各带 `tookMs`,顶层 `tookMs` 是整个请求的耗时。 ## 跨站资源搜索 - **站点范围**:只查选中的站点(`enabled=true` 且未删除)。`sites=` 给了就只查这些;没给按 `settings.search.defaultSiteMode`:`default` = 站点「默认参与搜索」(`site.search_default`,迁移 0048,缺省 true)的站点,`all` = 全部启用站点,`custom` = `settings.search.siteIds` 里仍启用的站点。`siteFilter=` 只筛选结果、不改查询的站点(前端站点分面用)。不支持搜索或没有适配器定义的站点记为 `skipped`。 - **查询词**:给了 `q` 只用 `q`;按作品(`entityId` / `tmdb`)时由服务端生成,最多 2 个:原名(电影且原名是拉丁字母时带年份,如 `Interstellar 2014`)+ 译名(归一化后与原名不同才加);剧集不带年份。每个站点每个词查一次,同站结果按站点资源 ID 去重,响应 `queries` 给出实际查询词。 - **限流**:沿用 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.sites` / `resolutions` / `sources` 统计的是未筛选的全部结果(旧字段)。`total` 是筛选后的条数,`unfilteredTotal` 是筛选前的条数。游标是不透明的偏移量。 ### 快速筛选(SEARCH-3,SEARCH-4 对齐 MP) `GET /search/releases` 与 `GET /sites/{id}/releases` 共用,口径在 `packages/domain/src/release-facets/`。 - 参数 `qf=维度:值,值;维度:值`,值经 `encodeURIComponent`。同一维度多值为 OR,不同维度为 AND;与其它筛选参数叠加(AND),排序、游标照旧。 - 维度与档位:`resolution` 2160p / 1080p(含 1080i)/ 720p / other;`videoCodec` H.265 / H.264 / AV1 / other;`releaseType` 按解析的来源:disc 原盘(BluRay-Disc)/ remux / encode 蓝光压制(BluRay)/ webdl(WEB-DL)/ hdtv / other(WEBRip、DVD 等);`site` 站点 ID;`promotion` free / 2x / 50% / other(30% 等)/ none,一条可同时属于 free 和 2x,促销已过期算 none;`hdr` DV / HDR10 / HDR10+ / other(HLG、HDR Vivid)/ SDR(没有 HDR 标记),一条可属于多个;`hr` yes / no / unknown;`releaseGroup` 制作组名(不分大小写);`season` 季号(多季合集展开到每一季);`episode` 集号,整季资源为 `pack`。 - SEARCH-4 新增:`audioCodec` Atmos / TrueHD / DTS:X / DTS-HD(MA、HR)/ DTS / DDP(DD+、E-AC3)/ DD(AC3)/ AAC / FLAC / LPCM(含 PCM)/ other / unknown(标题没写),一条可属于多个,声道和 `2Audios` 不算编码;`subtitle` zh(任何中文字幕)/ zh-Hans / zh-Hant / en / ja / ko / unknown;`dub` cmn 国语 / yue 粤语 / en 英语 / unknown(来自「国语」「国配」「国粤双语」「国英双语」等标记);`feature` 10bit / hfr(50 帧以上)/ cut(加长、导演剪辑等剪辑版),没有的不落任何档;`year` 年份,新的在前;`category` 站点分类名,计数最多的 20 个;`size` lt1 / 1-5 / 5-10 / 10-20 / 20-50 / 50+(GiB)/ unknown;`seeders` 0 / 1-9 / 10-49 / 50+ / unknown。 - 响应 `facets.<维度>` 为 `{value, label, count}`。计数按"其它维度的已选条件"统计:已选维度里的其它选项仍显示选上后能多出的条数,0 表示在其它条件下没有结果。固定档位总会列出,标了"只在有资源时列出"的档位(other、unknown、日文 / 韩文、特性、DTS:X、FLAC、LPCM 等)只在有资源时列出;制作组、分类只列计数最多的 20 个(已选的补在后面);没有剧集资源时 `season` / `episode` 为空;已选但当前没有资源的值计数 0。 - 站点资源列表:先按 `q` / 分类 / 促销 / 识别状态从资源表取出全部候选的解析字段、促销、分类名(列表快照 → RSS → 分类 ID)、大小、做种,在内存里算分面和筛选,再只为当前页取完整行。只读本地资源表,`qf` 不会触发实时补查;响应另有 `total`(快速筛选后)与 `unfilteredTotal`。配音语言是解析器 SEARCH-4 才补的字段,解析结果里没有时按标题 + 副标题现扫(不改库)。 - 前端:`components/search/quick-filter-bar.tsx`(桌面弹层、手机底部抽屉),状态与 `qf` 拼接在 `quick-filter-state.ts`。筛选条顺序照 MP:站点、季/集、促销、编码、质量、HDR、分辨率、制作组;其余收进「更多筛选」(桌面一个宽弹层分节、手机一个抽屉):音频、字幕、配音、特性、年份、分类、大小、做种、H&R。搜索结果页已有精确的大小区间(「大小与排序」),不显示大小分档;站点资源 Tab 已有站点分类下拉(与 MP 站点资源一致),不显示分类分面,原来只筛已加载结果的「做种 / 大小」下拉换成分面(按全部本地资源)。 - 本机记住上次选择(localStorage `umt.quickFilters.search` / `umt.quickFilters.site`),只记与结果无关的固定档位:分辨率、编码、质量、促销、HDR、H&R、音频、字幕、配音、特性、大小、做种;站点、制作组、季集、年份、分类随结果变化,不记。「清除」清全部,弹层 / 抽屉里的「清除」只清本节。 - 弹层材质:全站悬浮层(`pg-float`:Popover、Select、下拉菜单、对话框、抽屉)从 SEARCH-4 起是不透明实底(`--mat-float-bg` = `--popover`),不做背景模糊,只改 token,组件侧不要再加半透明底。 #### 与 MP 的对照(SEARCH-4,MP 前端 `17b9d51`) MP 搜索结果页筛选条(`src/composables/useTorrentFilter.ts` + `components/filter/TorrentFilterBar.vue`)是 7 个维度,取值是结果里出现过的原始字符串,没有计数;排序 默认 / 站点 / 大小 / 做种 / 发布时间。MP 站点资源弹窗(`components/dialog/SiteResourceDialog.vue`)只有关键词 + 站点分类,表头排序时间 / 大小 / 做种 / 下载。音频、中字、国语、60 帧等在 MP 里是「过滤规则」(`api/constants.ts` `innerFilterRules`:CNSUB、SPECSUB、CNVOI、HKVOI、GZ、FREE、ATMOS、DOLBY、60FPS、3D、UHD、H265、REMUX…),用于订阅 / 搜索优先级,不在结果筛选条上;这里把其中有解析字段支撑的做成分面。 | MP 维度(取值来源) | 本工具 | 解析字段来源 | 说明 | |---|---|---|---| | 站点 `site`(site_name) | 已有 `site` | 站点 | 单站资源列表不显示 | | 季 `season`(season_episode,如 `S01 E03`) | 已有 `season` + `episode` | `season` / `seasonEnd` / `episodes` | MP 季集是一个字符串;这里季、集分两节,多季合集展开到每季,整季单列 | | 促销 `freeState`(普通 / 免费 / 2X / 2X免费 / 50% / 2X 50% / 30%…) | 已有 `promotion` | 站点促销 + 到期 | 归到 免费 / 2x / 50% / 其他 / 无,2X免费同时算免费和 2x;过期算无 | | 视频编码 `videoCode`(video_encode) | 已有 `videoCodec` | `videoCodec` | H.265 / H.264 / AV1 / 其他 | | 质量 `edition`(resource_type + resource_effect,如 `BluRay DoVi`) | 已有 `releaseType`(质量)+ `hdr` | `source`、`hdr` | MP 把来源和特效拼成一个字符串;这里拆成「质量」和「HDR」两个维度,组合更灵活 | | 分辨率 `resolution`(resource_pix) | 已有 `resolution` | `resolution` | | | 制作组 `releaseGroup`(resource_team) | 已有 `releaseGroup` | `releaseGroup` | 不分大小写合并,列前 20 | | 规则 ATMOS / DOLBY / 音频格式 | **新增** `audioCodec` | `audio` | 分面放「更多筛选」 | | 规则 CNSUB 中字 | **新增** `subtitle` | `subtitles`(CHS/CHT、「简繁」「中字」「中英」等) | 另列简体 / 繁体 / 英文 / 日文 / 韩文 | | 规则 CNVOI 国语 / HKVOI 粤语 | **新增** `dub` | **解析器新增 `audioLangs`** | 之前「国语」「粤语」只从标题里去掉,不记录 | | 规则 60FPS | **新增** `feature` 的「50 帧以上」 | `fps` | 同一维度另有 10bit(`bitDepth`)、特别剪辑版(`cut`) | | (MP 无)年份 | **新增** `year` | `year` | 用户要求 | | 站点资源:分类 | **新增** `category`(搜索结果页) | 站点分类名 | 站点资源 Tab 沿用原来的分类下拉,不重复给分面 | | 排序:大小 | 已有(排序 + 搜索页大小区间);**新增** `size` 分档 | `sizeBytes` | 搜索结果页不显示分档(已有精确区间) | | 排序:做种 | 已有(排序);**新增** `seeders` 分档 | `seeders` | 「无人做种」可直接排除死种 | | 规则 SPECSUB 特效字幕 | 不做 | — | 解析器没有可靠标记,标题里很少写 | | 规则 GZ 官种 | 不做 | — | 需要站点官组表(MP 从站点配置取),本工具还没有这份数据 | | 规则 3D | 不做 | — | 解析器只把 3D 当停止词,没有字段;3D 资源极少 | | 规则 UHD | 不做单独维度 | `uhd` | 等价于「2160p」+「原盘 / Remux」的组合,用现有维度即可 | | 规则 BITRATE / HIRES / LOSSLESS(音乐) | 不做 | — | 音乐资源不在本工具范围 | | (MP 无)H&R | 已有 `hr` | 列表快照 | 放「更多筛选」 | ## 从搜索结果下载 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 导出和站点运行时,库存叠加和下载都转调对方端点,不复制业务逻辑。 ## 前端要点 - 搜索页 `/console/search?q=` 第一阶段:作品卡(海报 / 年份 / 类型 / 简介 + 库内状态;有媒体服务器实例时卡上另查 Emby)与操作。「按关键词搜资源」用原关键词搜站点(资源标题类查询)。 - 第二阶段 `/console/search?q=&work=&in=&t=`:`work` = `local:` / `tmdb::` / `q:<关键词>`,`in` = 选中的站点 ID,`t` = 显示的作品名。站点选择弹层的初始值 = 本机上次选择(localStorage `umt.search.sitePick`)> `settings.search`;站点页「搜索站点」改全局默认,站点卡 / 编辑里改「默认参与搜索」。 - 命令面板用 `scope=media` 和 `scope=actions` 快速出结果(毫秒级,TMDB 首次查询约 1 秒);选中作品打开详情页,最后一项进搜索页。面板不查站点、不订阅。 - 作品详情页有「订阅」(订阅表单预填作品;TMDB 条目保存时隐式建档)、「搜索资源」、「建档不订阅」(仅 TMDB 的作品)。 - 资源行的操作和站点工作台一致。`sourceReleaseId` 可以直接给工作台详情和委托 Agent 用;下载用 `POST /search/releases/{siteId}/{siteReleaseId}/plan`。 - `partial=true` 时列出 `failedSites`,提示哪些站点没有结果。`cached` / `fetchedAt` 用来标注数据时间,"刷新"按钮带 `refresh=true`。 ## 排障 - 某个站点一直 `timeout`:先在站点页做健康检测;限流太保守时,令牌等待也会占用 15s 预算,可以调整站点的 `rate_limit`。 - 某个站点一直 `skipped`:可能是定义不支持搜索,也可能没有随产品发布的适配器定义(看 error 文本)。 - 资源有结果但没有库存叠加:资源还没识别,而且标题没有精确命中唯一的作品别名。等识别流水线处理完,或者在工作台手动指定作品后刷新。