110 lines
12 KiB
Markdown
110 lines
12 KiB
Markdown
# 规则运行手册(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` 下)。
|