Files
UHD_Media_Tool/docs/runbook/search.md
T

20 KiB

全局搜索运行手册(W9-G、SEARCH-2)

实现: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)

  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:<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,也不改站点健康状态。

按作品搜时的归属判定(对照 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 条可能不是这部作品」,展开后与上面的行一样可下载。

作品关联与库存叠加

叠加每次请求时现算,不进缓存,所以识别完成后刷新一次就能看到。

  • 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),排序、游标照旧。
  • 维度与档位: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 的值(对照 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 站点资源一致),不显示分类分面,原来只筛已加载结果的「做种 / 大小」下拉换成分面(按全部本地资源)。
  • 本机记住上次选择(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) 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」两个维度,组合更灵活
分辨率 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:<id> / tmdb:<type>:<id> / 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 文本)。
  • 资源有结果但没有库存叠加:资源还没识别,而且标题没有精确命中唯一的作品别名。等识别流水线处理完,或者在工作台手动指定作品后刷新。