merge: fix/m-search-filter

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-06 16:01:46 +08:00
commit 88f98386e8
21 files changed
+1955 -140

No files matched your search

+19 -7
View File
@@ -1,6 +1,6 @@
# 全局搜索运行手册(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` 为两阶段)。
实现:`apps/server/src/modules/search/`(`routes.ts` 端点、`releases.ts` 跨站搜索/缓存/筛选/叠加、`work-match.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)
@@ -30,15 +30,27 @@
## 跨站资源搜索
- **站点范围**:只查选中的站点(`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` 给出实际查询词。
- **查询词**:给了 `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 实例之间不共享。
- **短缓存**:按(站点, 查询词)缓存 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`,也不改站点健康状态。
### 按作品搜时的归属判定(对照 MP `TorrentHelper.match_torrent`)
站点按关键词搜,简介里提到作品名的专辑、别的作品也会被搜到(例:搜「药屋少女的呢喃」带出《迷宫饭》片尾曲专辑「Channel U … BDMV」)。按作品(`entityId` / `tmdb`)搜时,每条结果先判定是否属于这部作品(`work-match.ts`),关键词搜索不做这一步:
1. 已识别到本地作品(跟随合并):同一作品,或该作品与目标有相同的已接受 TMDB 身份 → 属于;识别到别的作品 → 不属于。
2. 未识别:站点分类是音乐 / 游戏 / 软件 / 书 → 不属于;剧集资源(有季集)或剧集分类而作品是电影 → 不属于;年份:电影上下 1 年且必须有年份,剧集与作品或任一季首播年份相差 1 年以内(没有季信息时只排除早于首播一年以上的);季集:资源的季必须在作品已知的季里,单季集号超过该季集数时按全部季总集数算(绝对集号)。
3. 名字:解析出的中 / 英文名(英文名去掉末尾 `Vol.N`)归一化后命中作品名、原名或别名(`title_alias` + TMDB 别名 / 译名);标题按 `空白 / 【】 . [ ] -` 拆出的非英文词命中作品名 / 原名;副标题 / 简介按 `空白 / 【】 | [ ]` 拆出的非英文词命中作品名、原名或别名。都没命中 → 不属于。
季信息(各季首播年份与集数)只取 TMDB 详情(按 TMDB 缓存时长复用,失败不缓存);本地季单元可能过期,不用来否决。与 MP 的差别:剧集年份允许相差 1 年(MP 要求等于某季年份,蓝光常晚一年发行);允许绝对集号;拆词时多按 `[` `]` 拆。没有做 MP 的「无年份别名命中再识别消歧」。
不属于的不参与筛选、分面与 `total` / `unfilteredTotal`,响应另给 `unmatched`(只在第一页,按当前排序,最多 200 条)与 `unmatchedTotal`;页面在列表最后折叠为「另有 N 条可能不是这部作品」,展开后与上面的行一样可下载。
### 作品关联与库存叠加
叠加每次请求时现算,不进缓存,所以识别完成后刷新一次就能看到。
@@ -61,11 +73,11 @@
`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`。
- 维度与档位:`seasonEpisode` 季集(MP `season_episode` 写法:`S01`、`S01-S02`、`S02 E14`、`S01 E25-E48`);`resolution` 2160p / 1080p(含 1080i)/ 720p / other;`videoCodec` H.265 / H.264 / AV1 / other;`releaseType` 按解析的来源:disc 原盘(BluRay-Disc;标题有 BDMV / ISO 等,或 Blu-ray + AVC / HEVC 且没有 x26x / H.26x,或副标题写「原盘」「BDMV」「自购自抓」「DIY」「Blu-ray Disc」,或带 LPCM / PCM 音轨又没有 x264 / x265 / BDRip / 压制说法)/ 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`(这两个是旧维度,筛选条上已换成 `seasonEpisode`,旧链接仍生效)。
- 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。
- 响应 `facets.<维度>` 为 `{value, label, count}`。计数按"其它维度的已选条件"统计:已选维度里的其它选项仍显示选上后能多出的条数,选项只列结果里实际出现、且在其它条件下计数不为 0 的值(对照 MP `initOptions`),固定档位按固定顺序;已选的值总会列出(当前没有资源时计数 0,可删除)。制作组、分类只列计数最多的 20 个(已选的补在后面);季集整季在前、单集在后,季号新的在前、同季集号大的在前(照 MP `sortSeasonOptions`);没有剧集资源时 `seasonEpisode` / `season` / `episode` 为空。
- 站点资源列表:先按 `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 站点资源一致),不显示分类分面,原来只筛已加载结果的「做种 / 大小」下拉换成分面(按全部本地资源)。
- 前端:`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,组件侧不要再加半透明底。
@@ -76,7 +88,7 @@ MP 搜索结果页筛选条(`src/composables/useTorrentFilter.ts` + `components/
| MP 维度(取值来源) | 本工具 | 解析字段来源 | 说明 |
|---|---|---|---|
| 站点 `site`(site_name) | 已有 `site` | 站点 | 单站资源列表不显示 |
| 季 `season`(season_episode,如 `S01 E03`) | 已有 `season` + `episode` | `season` / `seasonEnd` / `episodes` | MP 季集是一个字符串;这里季、集分两节,多季合集展开到每季,整季单列 |
| 季 `season`(season_episode,如 `S01 E03`) | `seasonEpisode`(旧的 `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」两个维度,组合更灵活 |