Files
UHD_Media_Tool/docs/runbook/rules.md
T

110 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 规则运行手册(W9-D)
实现:`packages/domain/src/rules/`(规则串解析/序列化、内置关键字、规则组链求值)、`packages/domain/src/slot/evaluate.ts`(接入版本目标评估)、
`apps/server/src/modules/acquisition-organize/{routes/rule-groups.ts, routes/rules.ts, services/rules.ts, services/acquisition.ts}`、迁移 `0033_rule_groups`。
设计:`docs/design/subscriptions.md` §6;MP 对照:`docs/porting/mp-rust-logic-parity.md` §6、`docs/porting/moviepilot-subscribe.md` §6。
## 模型
**规则组**(`rule_group`)= 名称 + 类型 + 适用范围 + 结构化层 + 硬排除。规则串(`SPECSUB & CNSUB > 4K`)只是层的文本表示,存库的是结构化表达式,`rule_string` 是规范化后的缓存。
| 字段 | 含义 |
| --- | --- |
| `kind` | `acquisition` 订阅 / `upgrade` 洗版 / `search` 搜索 |
| `scope` | `mediaTypes[]`(movie/series/variety)、`entityIds[]`、`siteIds[]`、`subscriptionId`(订阅私有组);空 = 全部 |
| `layers[]` | 每层 `{name?, expr}`,`expr` 是 `{op:atom,id}` / `{op:not,arg}` / `{op:and\|or,args[]}` 树;第 k 层(0 起)命中 pri = 100 − k |
| `hardExcludes[]` | 表达式列表,任一命中即拒绝 |
| `ladderRequired` | 一层都不中即拒绝(MP 语义,缺省 true);false = 软偏好,不中只是 pri=0 |
| `source` | `manual` 手动 / `chat` 对话中用户明确说的 / `agent` Agent 推断 |
| `enabled`、`revision`、`fingerprint`、`suppressedAt`、`deletedAt` | 每次修改 revision+1 并写审计 `rule_group.*`;指纹 = hash(kind+scope+规范化层+硬排除+ladderRequired) |
| `defaultOrder` | 非 null = 属于该类默认链 |
| `legacyKind` | 由偏好标签迁来或经旧 `/preference-tags` 写入 |
**自定义条件**(`rule_atom`):对齐 MP 自定义规则 7 字段 —— `key`(大写)、`include[]`/`exclude[]`(正则,MP 语法,`(?i)` 会去掉,统一不区分大小写;过防灾难回溯白名单)、`matchFields`、`unitSizeMb {min,max}`(剧集按每集)、`minSeeders`、`publishDelayMinutes {min,max}`、`free`。**不允许与内置关键字同名**(偏离 MP 的"同 id 覆盖",避免静默改义)。
## 求值语义(对齐 MP `FilterModule`)
1. 组内:硬排除任一命中 → 拒绝(`rule.hard_exclude`);逐层匹配,首个命中层给 pri;有层且 `ladderRequired` 而一层不中 → 拒绝(`rule.no_layer_matched`)。
2. 多组串联取交集:任一组拒绝即拒绝;**pri 取链上最后一个带层的组**(没有层的组只起过滤作用)。
3. 层内 `!` > `&` > `|`,可加括号;表达式短路求值。未知条件视为不匹配(只可能出现在迁移前的旧数据里,新写入会被拒)。
4. 版本目标自身的 `hardConditions` / `softPreferences.tiers` 保留,视为链首的匿名组;规则组有层时 pri 以规则组为准,升级比较口径自动切到 pri(对照 MP 洗版 `pri_order > current_priority`)。
**引用链**(`services/rules.ts resolveRuleChain`):订阅私有组 → 版本目标 `ruleGroupIds`(非空时)|| 订阅 `params.ruleGroupIds` || 订阅默认链 ;补齐阶段只使用该链。洗版阶段优先使用版本目标 `upgradeThreshold.ruleGroupIds`,未单独指定时继承收录链并追加洗版默认链;逐个按 scope 过滤,跳过停用/已删。搜索用 `kind: "search"` 取搜索默认链(给 `release.search` / 手动搜索接入)。
候选评估记录(artifact `candidate_evaluation`)里:`pri` 为最终 pri;`reasons` 带 `rule_group:<id>@<revision>`;`trace` 逐组逐层写明命中/未命中及每个原子的依据,如
`规则组「片源」@r1 第1层 4K:未命中(4K✗ 分辨率 1080p)`、`规则组「片源」@r1 硬排除 REMUX 命中(REMUX✓ 来源 REMUX)`。
## 分类、保留版本与洗版
在「规则 → 收录策略」中沿用现有 movie / tv 二级分类选择适用范围。新订阅按「明确选择 → 分类默认 → 作品类型默认 → 全局默认」选择已发布策略,同层按发布时间选择;未匹配时创建默认版本。分类设置页展示当前关联。策略模板创建订阅自己的保留版本,已建版本的规则独立维护,修改默认模板不会覆盖已有版本。
订阅表单只选择新建时的收录策略,质量、组合规则和洗版在订阅详情的「收录版本规则」逐版本调整。顺序与已有订阅的自动补全也统一在详情维护;包含、排除仍作为订阅共同过滤。旧 API 的统一质量预设继续兼容,普通表单保存保留旧值。自动补全选择「跟随主版本规则」时,按主版本实际收录链取最后一个设置了补全开关的组;没有设置则关闭,显式开/关优先。
- **单版本**:一个保留版本的允许收录范围选择 1080p、2160p,组合优先级例如 `4K & H265 > 4K & H264 > 1080P & H265 > 1080P & H264`。范围内先收低档,再洗到高档;720p 即使其他条件更好也不进入范围。
- **双版本**:建立两个保留版本,分别只允许 1080p 和 2160p,各自引用组合规则和洗版设置,例如各自用 `H265 > H264` 让本分辨率的最高档为目标档。第一个为主版本,需要自动收齐第二个时开启订阅的「自动补全其他版本」。统一质量预设不会改写策略模板的分辨率边界。
- **组合优先级**:复用规则组的 AND / OR / NOT 与有序档位,发布组可使用结构化阶梯或现有自定义条件;编码偏好应写入档位,软偏好用于同档候选排序。多组串联仍取最后一个带档位的组,界面显示引用顺序。
- **阶段**:追更、补齐允许不同集采用不同版本;开启「整季同组同套」后,洗版向当季选定规格收敛,多个单集可以组成一套。规格包含分辨率、来源、编码、发布组、HDR 和剪辑版,不包含集号或种子 ID。已在途的洗版规格保持冻结,结束后才选择更优的一套;同档对齐不降低来源或分辨率等级。
- **完成与旧文件**:范围内覆盖齐且选定同套资源达到目标才算洗版完成。新文件核验后,仅回收同保留版本被完整覆盖的旧文件;旧合并集要等同套新单集全部入库,另一分辨率和异组文件不能凑覆盖。处置沿用回收站与替换失败恢复机制。
组合条件所依赖的分辨率、编码等未知时,不把未知当作否定条件成立,也不悄悄降到低档。资源属性事项交给现有调查通路:Agent 用 `release.inspect` 查看详情与 MediaInfo,有针对 `release.apply_facts` 的持续授权时只补仍缺失且有出处的分辨率、编码、来源或发布组,服务保存事实与出处并触发原规则重评。站点大小、做种、促销等通过详情刷新补齐。没有授权或证据不足时提供人机协同建议;Agent 不改变允许范围、保留目标或组合排序。
手动下载的选择只影响本次计划,沿用独立的下载与一次性替换入口。
## 内置关键字
`GET /rules/keywords` 返回全表(含 MP 上游正则/条件原文与我们的判定依据 `basis`)。
| 关键字 | 我们的判定 | basis |
| --- | --- | --- |
| 4K / 1080P / 720P | 解析出的分辨率(1080P 含 1080i) | field |
| BLU / REMUX / WEBDL | 解析出的来源(原盘 / Remux / WEB-DL+WEBRip) | field |
| BLURAY | 来源属蓝光族(原盘、压制、标题带 Blu-ray 的 Remux) | field |
| UHD | 标题 UHD / UltraHD | field |
| H265 / H264 | 视频编码 | field |
| HDR / DOLBY | HDR10/HDR10+/HDR Vivid;杜比视界 | field |
| ATMOS / 60FPS | 音轨 Atmos;帧率 ≥ 60(均兼容 MP 正则) | field |
| CNSUB | 字幕含中文;作品原语言 zh/cn 直接满足(MP tmdb 条件) | field |
| CNVOI / HKVOI / SPECSUB / SDR / 3D | MP 正则;CNVOI 原语言 zh 直接满足 | regex |
| GZ / FREE | 站点标签 官方/官种/官组;下载系数 = 0 | site |
| 音乐 13 条(HIRES、FLAC、AAC…) | 不支持,规则串里出现报 `unsupported_atom` | — |
## 端点
| 端点 | 说明 |
| --- | --- |
| `GET /rules/keywords` | 内置 + 自定义条件 |
| `POST /rules/parse {ruleString}` | → `{layers, ruleString(规范化,有错为 null), errors[{code, message, position, layer, atom?, suggestions?}]}`;code:`empty` `empty_layer` `unknown_atom` `unsupported_atom` `unbalanced_paren` `unexpected_token` `invalid_char` |
| `POST /rules/serialize {layers}` | → `{ruleString}`(原子大写,只加必要括号) |
| `GET / PUT /rules/defaults` | `{acquisition[], upgrade[], search[]}`,按数组顺序串联;类型不符 400 |
| `GET/POST /rules`、`GET/PATCH/DELETE /rules/{id}` | 列表筛选 `kind source enabled includeDeleted q`;写入可给 `ruleString`(优先)或 `layers`,`hardExcludes` 元素可以是表达式或单层规则串;PATCH 可带 `expectedRevision`(不匹配 409);同名 409;条件无效 422;DELETE 被版本目标(含洗版)、订阅、有效策略模板引用 409 |
| `GET /rules/{id}/revisions` | 审计回放(含迁移前的 `preference_tag.*`) |
| `POST /rules/{id}/preview` | `{limit?, sourceReleaseIds?, titles?[{title, subtitle?}], draft?}`;缺省最近 50 条已识别资源;`draft` 试算未保存的层/硬排除 → `{sampleSize, passed, rejected, priHistogram[{pri,count}], items[{title, passed, pri, level, excludedBy, reasons, trace}]}` |
| `GET/POST /rules/atoms`、`GET/PATCH/DELETE /rules/atoms/{id}` | 自定义条件;与内置同名 409;被规则组引用不能删 409 |
| `/rules/policies*` | 收录策略版本(草案→预览→发布→回滚),同 `/policy-revisions` |
| `/rules/suppressions*` | 抑制记录,同 `/preference-suppressions`;行多了 `ruleGroupId` |
| 版本目标 `ruleGroupIds` | `POST/PATCH /acquisition-slots` 可写,引用不存在的组 400;`SlotTemplate.ruleGroupIds` 建目标时写入 |
| 订阅 `params.ruleGroupIds` | `PATCH /subscriptions/{id}` 的 params 键 |
## 抑制记录(偏好标签语义保留)
- 停用(`enabled: false`)或删除规则组 → 写抑制记录(指纹 = 停用前的指纹),`suppressedAt` 置位。
- 对话/Agent 来源的组被用户改掉语义 → 旧指纹也写抑制("edited");手动组的编辑不写。
- `POST /rules` `source=agent` 命中有效抑制 → **409**;`manual`/`chat` 新建同指纹 → 解除抑制。非手动来源的同指纹重复新建 409。
## 旧端点(弃用,保留到 v0.3)
| 旧 | 新 | 备注 |
| --- | --- | --- |
| `/preference-tags*` | `/rules*` | 别名直接读写 `rule_group`(`legacy_kind` 非空的行);`rule.ruleString` 会编译:hard → 硬排除,soft → 单层软偏好;其余 rule 键只作记录、不参与评估。指纹沿用旧口径 hash(kind+scope+rule),旧抑制记录照常命中 |
| `/policy-revisions*` | `/rules/policies*` | 同一张表、同一套处理器 |
| `/preference-suppressions*` | `/rules/suppressions*` | 同上;`preferenceTagId` 对新建规则组回填为 `ruleGroupId` |
`preference_tag` 表在 0033 后不再写入(数据已按原 id 迁到 `rule_group`,抑制记录按 `preference_tag_id` 回填 `rule_group_id`),回滚迁移前保留。
## 差分测试
- 生成:`MP_ROOT=/opt/moviepilot-plus/upstream/MoviePilot $MP_ROOT/.venv/bin/python scripts/mp-sync/gen-rules-differential.py`,写 `packages/domain/test/differential/rules-cases.json`(登记的 deviations 重跑保留)。
- 覆盖:66 条规则链(旧 filter-cases 的 42 条 + 19 条新规则串 + 5 条多组串联)× 32 个种子 = 2112 case;64 个规则串的 pyparsing 结构。
- 结果:解析结构 64/64 一致、序列化往返一致;pri 2090/2112 一致(98.96%),22 处全是 **mp_bug**:无来源 2160p HEVC 被 BLU 正则误判原盘(t08)、副标题"国语/国配"被 CNSUB 当中字(t26/t32)、`.HDR-` 不被 HDR 正则命中(t02)。原 filter-cases 里 15 条"表达不了"的规则现在全部可表达,只剩音乐条件不支持。
- 临时看差异:`UMT_RULES_DUMP=1 deno run -A npm:vitest run test/differential/rules.test.ts`(在 `packages/domain` 下)。