- domain release-facets: bucket resolution / codec / release type / site / promotion / HDR /
release group / season / episode; OR within a dimension, AND across; disjunctive counts
- GET /search/releases and GET /sites/{id}/releases accept qf and return facets; site list
facets come from local source_release rows only (no site requests), paging stays consistent
- web QuickFilterBar shared by search results and site resources tab: per-dimension popover
(bottom sheet on phones) with counts, removable tags, clear all, localStorage memory per page
- runbook: quick filter params and bucket definitions
Co-Authored-By: Claude Opus 5.5 <[email protected]>
12 KiB
12 KiB
全局搜索运行手册(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/(search2.test.ts 为两阶段)。
两阶段流程(SEARCH-2)
- 作品:
GET /search?scope=all|media只查本地账本 + TMDB,合并去重成media.works(同一 TMDB 身份只出现一次,本地在前),每部带库存对比inventory。不请求任何站点。 - 资源:用户在作品卡或作品详情页点「搜索资源」→ 选站点(默认 / 全部 / 指定,本机记住上次选择)→
GET /search/releases?entityId=|tmdb=&sites=,服务端生成查询词。 - 订阅只在作品详情页:本地作品
/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:<id>或tmdb:<type>:<id>;海报 / 简介本地缺时用 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,对照 MP)
GET /search/releases 与 GET /sites/{id}/releases 共用,口径在 packages/domain/src/release-facets/。
- 参数
qf=维度:值,值;维度:值,值经encodeURIComponent。同一维度多值为 OR,不同维度为 AND;与其它筛选参数叠加(AND),排序、游标照旧。 - 维度与档位:
resolution2160p / 1080p(含 1080i)/ 720p / other;videoCodecH.265 / H.264 / AV1 / other;releaseType按解析的来源:disc 原盘(BluRay-Disc)/ remux / encode 蓝光压制(BluRay)/ webdl(WEB-DL)/ hdtv / other(WEBRip、DVD 等);site站点 ID;promotionfree / 2x / 50% / other(30% 等)/ none,一条可同时属于 free 和 2x,促销已过期算 none;hdrDV / HDR10 / HDR10+ / other(HLG、HDR Vivid)/ SDR(没有 HDR 标记),一条可属于多个;releaseGroup制作组名(不分大小写);season季号(多季合集展开到每一季);episode集号,整季资源为pack。 - 响应
facets.<维度>为{value, label, count}。计数按"其它维度的已选条件"统计:已选维度里的其它选项仍显示选上后能多出的条数,0 表示在其它条件下没有结果。固定档位总会列出,other 只在有资源时列出;制作组只列计数最多的 20 个(已选的补在后面);没有剧集资源时season/episode为空;已选但当前没有资源的值计数 0。 - 站点资源列表:先按
q/ 分类 / 促销 / 识别状态从资源表取出全部候选的解析字段与促销,在内存里算分面和筛选,再只为当前页取完整行。只读本地资源表,qf不会触发实时补查;响应另有total(快速筛选后)与unfilteredTotal。 - 前端:
components/search/quick-filter-bar.tsx(桌面弹层、手机底部抽屉),状态与qf拼接在quick-filter-state.ts。本机记住上次选择(localStorageumt.quickFilters.search/umt.quickFilters.site),只记分辨率、编码、版本、促销、HDR;站点、制作组、季集随结果变化,不记。
从搜索结果下载
- 按 (siteId, siteReleaseId) 找资源行。找不到时,用缓存里的站点结果补写一行(对应搜索时落库失败的情况)。缓存里也没有就返回 404,需要重新搜索。
- 进程内转调
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:<id>/tmdb:<type>:<id>/q:<关键词>,in= 选中的站点 ID,t= 显示的作品名。站点选择弹层的初始值 = 本机上次选择(localStorageumt.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 文本)。 - 资源有结果但没有库存叠加:资源还没识别,而且标题没有精确命中唯一的作品别名。等识别流水线处理完,或者在工作台手动指定作品后刷新。