diff --git a/docs/design/naming-template.md b/docs/design/naming-template.md new file mode 100644 index 00000000..66cfbe2d --- /dev/null +++ b/docs/design/naming-template.md @@ -0,0 +1,196 @@ +# 整理命名模板(对标 MoviePilot 重命名格式) + +> 需求来源:`docs/plan/00-first-version-plan.md` §8 第 5 条。行为对照:`docs/porting/moviepilot-transfer.md` §2.4。上游 `jxxghp/MoviePilot` v3 @ `e6820a8`,只记录行为,不复制源码(GPLv3)。 +> 现状:`packages/domain/src/path/{template,binding,sanitize}.ts`。模板语法是自研的 `{var}` + `[[可选段]]`;模板集 `DEFAULT_TEMPLATES` 在 `services/organize.ts:434` 写死,没有设置项、没有预览,变量只有 16 个,名字和 MP 对不上。 + +## 1. MP 怎么做(结论) + +| 项 | MP 行为 | 位置 | +| --- | --- | --- | +| 模板 | 电影、剧集、音乐各一条**全路径** Jinja2 字符串,`/` 就是目录层级;全局一份,存 `system/env` | `app/runtime/config.py:521-542` | +| 电影默认 | `{{title}}{% if year %} ({{year}}){% endif %}/{{title}}{% if year %} ({{year}}){% endif %}{% if part %}-{{part}}{% endif %}{% if videoFormat %} - {{videoFormat}}{% endif %}{{fileExt}}` | :522 | +| 剧集默认 | `{{title}}{% if year %} ({{year}}){% endif %}/Season {{season}}/{{title}} - {{season_episode}}{% if part %}-{{part}}{% endif %}{% if episode %} - 第 {{episode}} 集{% endif %}{{fileExt}}` | :528 | +| 渲染 | `jinja2.Template` 默认环境:不开 sandbox,未定义变量渲染成空串;值为 None 的键先被删掉,所以 `{% if x %}` 为假 | `transhandler.py:1991` | +| 后处理 | 没有:不 strip、不合并空格、不限长度。模板本身先把 `\` 换成 `/`、合并连续 `/`、去首尾 `/` | `config.py:1347-1363` | +| 非法字符 | `\/:*?"<>|` 转全角,**只作用于** title/en_title/original_title/episode_title;releaseGroup、name 等字段里的 `/` 会被当成目录 | `message.py:520-534` | +| 目录拼装 | `库目录/[类型]/[二级分类]/<渲染结果>`;类型、分类两层由目录配置里的两个开关控制,分类来自 `category.yaml` | `transhandler.py:1757-1793` | +| 媒体根 | 从后往前找"含 title、不含 season"的最后一层作为作品根目录 | `directory.py:194-250` | +| 多集 | E01-E02 保留区间;**3 集及以上截成首集** | `transhandler.py:384-389` | +| 前端 | 设定 → 目录:三个 Ace 编辑器(jinja2 高亮),只有 Jinja 文档链接;没有变量清单、没有预览;不能按目录覆盖格式(目录只有 `renaming` 开关) | `AccountSettingDirectory.vue:64-118`、`DirectoryCard.vue:300-417` | + +## 2. 语法:Jinja 风格的安全子集 + +采用与 MP 相同的写法,MP 用户的模板可以直接粘贴。自写解析器,不引入 Jinja 运行时,不执行任意代码。 + +| 语法 | 含义 | 例 | +| --- | --- | --- | +| `{{ var }}` | 输出变量;缺失/空串 → 空串(同 MP) | `{{title}}` | +| `{{ a or b or 'x' }}` | 取第一个非空值;字面量只允许单/双引号字符串与整数 | `{{en_title or title}}` | +| `{{ var \| filter(args) }}` | 过滤器链,白名单见下 | `{{season \| pad(2)}}` | +| `{% if cond %}…{% elif cond %}…{% else %}…{% endif %}` | 条件;可嵌套,深度 ≤ 4 | `{% if year %} ({{year}}){% endif %}` | +| cond | `var`、`not var`、`var == 'x'`、`var != 'x'`、`var > 1`(整数比较)、`and`/`or`/括号 | `{% if total_discs and total_discs > 1 %}` | +| `{# 注释 #}` | 丢弃 | | + +过滤器白名单:`default('x')`、`pad(n)`(数字左补零)、`upper`、`lower`、`trim`、`replace('a','b')`、`truncate(n)`(按字符)、`date('YYYY-MM-DD'|'YYYY.MM.DD'|'YYYYMMDD')`。 +不支持:`{% for %}`、`{% set %}`、宏、属性/下标访问(`a.b`、`a[0]`)、函数调用、`~` 拼接、过滤器外的任何表达式。遇到直接报编译错误,错误带行列号。 + +兼容旧语法:`{var}`、`{var:02}`、`[[ … ]]` 在读取 v1 模板时自动改写成 `{{var | pad(2)}}`、`{% if … %}`,保存时落成新语法(一次性迁移,见 §9)。 + +限制:模板 ≤ 1024 字符;编译后节点 ≤ 500;单次渲染输出 ≤ 4096 字节;超出即报错。 + +## 3. 变量全表 + +名称与 MP 保持一致(驼峰/下划线照抄 MP,不统一改名)。"值格式"是渲染出的字符串。 + +| 变量 | 含义 | 值格式(例) | 来源(我们) | +| --- | --- | --- | --- | +| title | 作品标题(本地实体名) | `绝命毒师` | media_entity.title | +| en_title | 英文标题 | `Breaking Bad` | 外部身份 TMDB en-US | +| original_title | 原语种标题 | `Breaking Bad` | media_entity.original_title | +| name | 文件名识别出的标题 | 同发布 | parsed.title | +| year | 年份 | `2008` | 实体年份,缺失取 parsed.year | +| title_year | `标题 (年份)`,无年份只有标题 | `绝命毒师 (2008)` | 派生 | +| season | 季号,**不补零**;剧集缺季号为 `1` | `1`、`0` | parsed.season / 季单元 | +| season_fmt | `S01`,多季 `S01-S02` | `S01` | 派生 | +| season_year | 该季首播年份 | `2008` | 季单元 metadata | +| episode | 集号,**不补零**;多集 `1-3` | `1`、`1-3` | parsed.episodes | +| season_episode | `S01E01`,多集 `S01E01-E03` | `S01E01-E03` | 派生 | +| total_episodes | 本季总集数,未知为 `0` | `12` | 季单元 episode_total | +| episode_title | 单集标题 | `Pilot` | 集单元 metadata | +| episode_date | 单集播出日期 | `2008-01-20` | 集单元 metadata | +| part | 分段 | `CD1`、`Part2` | parsed.part | +| videoFormat | 分辨率,小写 | `2160p`、`1080p` | parsed.resolution | +| resourceType | 来源 | `BluRay`、`WEB-DL`、`REMUX` | parsed.source | +| effect | 特效 | `HDR10 DV` | parsed.hdr 以空格连接 | +| edition | `resourceType effect`(去首尾空格) | `BluRay DV` | 派生(同 MP) | +| resource_term | `resourceType effect videoFormat` | `BluRay DV 2160p` | 派生 | +| releaseGroup | 发布组 | `CMCT` | parsed.releaseGroup.name | +| videoCodec | 视频编码 | `H265` | parsed.videoCodec | +| videoBit | 位深 | `10bit` | parsed.bitDepth | +| audioCodec | 音频编码 | `DTS-HD MA` | parsed.audio 第一项 | +| webSource | 流媒体来源 | `NF` | parsed.webSource | +| fps | 帧率 | `60` | parsed.fps | +| customization | 自定义占位符 | | 识别词(未实现前恒空) | +| fileExt | 扩展名,**带点**;我们统一小写(MP 保留原大小写) | `.mkv` | 源文件 | +| type | `电影` / `电视剧` / `综艺` | | media_kind | +| category | 二级分类 | `欧美剧` | 实体 category(没有则空) | +| tmdbid / imdbid / doubanid | 外部 ID | `1396` | 外部身份 | +| **cut** | 剪辑版本(我们特有) | `Director's Cut` | parsed.cut | +| **contentType** | 内容类型 | `main`、`extra` | parsed.contentType | +| **segment** | 正片外片段 | `NCOP` | parsed.segment | +| **airDate** | 综艺播出日期 `YYYYMMDD`,用 `date()` 格式化 | `20260927` | parsed.airDate | +| **issue** | 综艺期号 | `12` | parsed.issue | +| **slotName** | 版本目标名 | `4K HDR` | acquisition_slot.name | +| **libraryRoot** | 库根名 | `电视剧-A` | library_root.name | + +MP 在命名场景剔除 `media_source/media_id`,我们同样不暴露内部 id。MP 的音乐变量(artist/album/track…)不做(无音乐需求)。 + +## 4. 默认模板 + +| 类型 | 默认(与 MP 一致,除标注) | +| --- | --- | +| 电影 | MP 电影默认原样 | +| 剧集 | MP 剧集默认原样 | +| 综艺 | `{{title}}{% if year %} ({{year}}){% endif %}/Season {{season}}/{{title}} - {% if season_episode and episode %}{{season_episode}}{% else %}{{airDate \| date('YYYY-MM-DD')}}{% endif %}{% if issue %} - 第 {{issue}} 期{% endif %}{{fileExt}}`(我们特有:无集号按播出日期) | +| 附属文件 | 同主视频主干 + 语言后缀(字幕 `.zh-cn`/`.zh-tw`/`.eng`,规则见 transfer 账本 §2.4 ⑦),不单独配模板 | + +目录层级:模板里的 `/` 切段。解析时给每段定角色:**作品根** = 从后往前第一段含 `title/title_year/en_title/original_title` 且不含季变量的段(同 MP `get_media_root_path`);**季目录** = 作品根之后、含 `season/season_fmt` 的段;最后一段 = **文件名**,必须以 `{{fileExt}}` 结尾(否则编译报错"文件名缺少 {{fileExt}}")。作品根之前的段(如 `{{type}}/{{category}}`)算**分类前缀**。 + +分类前缀:不做 MP 的"类型/二级分类"两个开关,直接写进模板(例 `{{type}}/{{category}}/{{title}}…`);category 为空的段整段丢弃,不产生空目录。 + +## 5. 非法字符与长度 + +| 规则 | 行为 | 与 MP | +| --- | --- | --- | +| 变量值里的 `\/:*?"<>|` | 转全角,**所有变量**都转 | 偏离:MP 只转标题类,releaseGroup 带 `/` 会多一层目录;我们不让值生成目录 | +| 模板字面量里的 `/` | 目录分隔 | 同 | +| 控制字符 | 删除 | MP 不处理 | +| 段首尾空白与点、连续空白 | 去首尾、合并为一个空格 | 偏离:MP 保留;只在变量为空导致 `A - B` 时出现差异 | +| 空段 | 丢弃;作品根或文件名段为空 → 报错 `empty_segment` | MP 交给 Path 规范化 | +| 保留名 CON/NUL… | 加 `_` | MP 无 | +| 长度 | 每段 ≤ 255 字节(UTF-8),文件名段保留扩展名截主干;全路径 ≤ 4096 字节 | MP 无限制 | + +复用现有 `sanitizeSegment`/`sanitizeFileName`,在渲染每个变量值时调用,字面量不经过它。 + +## 6. 覆盖层级与路径绑定 + +- 生效顺序:**作品级覆盖 > 库根级 > 全局默认**,按类型(movie/tv/variety)分别覆盖;未覆盖的类型往上回落。 +- 存储:全局在 `settings.naming {version, movie, tv, variety}`;库根在 `library_root.naming`(JSONB,同结构不含 version);作品在 `media_entity.naming_override`。每次保存全局或库根模板 `version += 1`(库根用 `root::`)。 +- **路径绑定不变**(REQ §7.1,已由 `resolveTargetDirectory` 实现):作品根目录与季目录只在**首次建立 `path_binding` 时**按模板渲染;之后入库直接用绑定路径,模板、译名变化都不改已有目录。`path_binding.template_version` 记录生成时的模板版本。 +- 文件名每次入库按当前模板渲染;已入库文件不回溯改名。要统一改名走文件管理的"按模板重命名"(预览 → 确认 → 批量任务),不在本期。 +- 渲染入口改成:整条模板 → 段列表 → 若已有作品/季绑定,用绑定路径替换对应前缀,只渲染剩余段。`NamingTemplateSet` 由 6 条子模板改为 3 条全路径模板;`resolveTargetDirectory` 的"首次绑定"分支改为取作品根段/季段的渲染结果。 + +## 7. 设置页表单 + +位置:设置 → 媒体库与目录 → 新节 `#naming`「命名模板」;库根抽屉加"覆盖命名模板"折叠区;作品详情 → 维护菜单加"命名覆盖"。 + +| 元素 | 行为 | +| --- | --- | +| 类型切换 | 电影 / 剧集 / 综艺 三个 Tab,各一个等宽多行编辑框(CodeMirror 轻量模式或 textarea + 行号),`{{ }}` `{% %}` 着色 | +| 变量清单 | 右侧(窄屏下方)按组列出:标题、季集、技术、ID、我们特有;每项显示名称、说明、当前示例值;点击在光标处插入 `{{var}}`;条件片段按钮插入 `{% if var %}…{% endif %}` | +| 示例文件 | 下拉选样本:内置 6 条(电影/电影 part/剧集单集/多集/S00/综艺按日期)+ "从最近入库挑一条" + 手填文件名(调用解析器) | +| 实时预览 | 输入停止 300 ms 调预览接口,显示完整相对路径,段角色着色(分类前缀/作品根/季/文件);同时列出"已有绑定的作品不受影响"一句 | +| 错误 | 编译错误显示行列号与原因("未知变量 videoformat,是否指 videoFormat");缺少 `{{fileExt}}`、作品根识别不出、空段逐条列出;有错不能保存 | +| 操作 | 保存(版本 +1)/ 还原为 MP 默认 / 从 MP 粘贴(原样接受) | + +## 8. 后端接口 + +| 方法 路径 | 入参 | 出参 | +| --- | --- | --- | +| `GET /naming/variables` | — | `[{name, group, label, example, ours: boolean}]` | +| `POST /naming/validate` | `{template, kind}` | `{ok, errors:[{line,col,message}], roles:[{segment, role}]}` | +| `POST /naming/preview` | `{template?, kind, sample: {fileName} \| {sourceReleaseId} \| {assetId} \| {builtin: n}, libraryRootId?, entityId?}` | `{ok, relativePath, segments:[{text, role}], missing:[], warnings:[], usesBinding: boolean}` | +| `GET/PUT settings.naming` | 走现有 settings 读写 | 保存前服务端再编译一次 | +| `PUT /library-roots/{id}` | `naming` 字段 | 同上 | +| `PUT /media-entities/{id}/naming-override` | `{movie?, tv?, variety?} \| null` | | + +contracts:`settings.ts` 加 `NamingSettings`、`SETTINGS_SECTIONS.library` 加 `naming` 节;`instances`/library root 加 `naming`;domain 新增 `path/naming-engine.ts`(lexer → parser → AST → render,纯函数)与 `buildNamingContext` 的 MP 变量映射,`template.ts` 保留为 v1 兼容读取。 + +## 9. 迁移 + +1. 新迁移:`library_root.naming jsonb null`、`media_entity.naming_override jsonb null`;`settings.naming` 缺省 = MP 默认,`version = 2`(v1 = 现有 `DEFAULT_TEMPLATES`)。 +2. 已有 `path_binding` 不动(template_version=1 照旧),只影响之后新建的绑定与文件名。 +3. `organize.ts` 与 `organize/plan.ts` 改为按"作品 → 库根 → 全局"取模板集。 + +## 10. 差分测试样例(与 MP 输出比对) + +放 `packages/testkit/fixtures/naming-cases.json`;MP 侧输出用上游 `jinja2.Template` + `TemplateContextBuilder` 在 dev 容器离线生成一次入库,之后只跑我们的实现。L = 库根。未注明模板的用 §4 默认。 + +| # | 输入 | 期望(相对 L) | 与 MP | +| --- | --- | --- | --- | +| 1 | 电影 Inception 2010,2160p,.mkv | `Inception (2010)/Inception (2010) - 2160p.mkv` | 同 | +| 2 | 同上无分辨率 | `Inception (2010)/Inception (2010).mkv` | 同 | +| 3 | 电影 part=CD1,1080p | `Inception (2010)/Inception (2010)-CD1 - 1080p.mkv` | 同 | +| 4 | 电影无年份,title=`A/B:C` | `A/B:C/A/B:C.mkv` | 同 | +| 5 | 剧集 绝命毒师 2008 S01E01 | `绝命毒师 (2008)/Season 1/绝命毒师 - S01E01 - 第 1 集.mkv` | 同 | +| 6 | S01E01-E02 合集 | `…/Season 1/绝命毒师 - S01E01-E02 - 第 1-2 集.mkv` | 同 | +| 7 | S01E01-E03 合集 | `…/Season 1/绝命毒师 - S01E01-E03 - 第 1-3 集.mkv` | **有意偏离**:MP 截成 `S01E01 - 第 1 集` | +| 8 | S00E03 | `绝命毒师 (2008)/Season 0/绝命毒师 - S00E03 - 第 3 集.mkv` | 同 | +| 9 | 剧集无年份无季号 E05,.ass | `某剧/Season 1/某剧 - S01E05 - 第 5 集.ass`(再加语言后缀) | 同 | +| 10 | S12E100 | `…/Season 12/X - S12E100 - 第 100 集.mkv` | 同 | +| 11 | 扩展名 `.MKV` | 结尾 `.mkv` | **有意偏离**:MP 保留原大小写 `.MKV`(`transhandler.py:391-395`);我们沿用 `sanitizeFileName` 小写 | +| 12 | 模板 `{{title_year}}/{{title}} {{season_fmt}}{{fileExt}}`,S02E01 | `绝命毒师 (2008)/绝命毒师 S02.mkv` | 同 | +| 13 | 模板 `{{title}}/{{edition}}/{{title}}{{fileExt}}`,BluRay + DV | `X/BluRay DV/X.mkv` | 同 | +| 14 | 模板含 `{{resource_term}}`,WEB-DL 1080p 无特效 | 段内 `WEB-DL 1080p` | 同(MP 前导空格被我们 trim,目录段中间无差异) | +| 15 | releaseGroup=`A/B`,模板 `…- {{releaseGroup}}{{fileExt}}` | `… - A/B.mkv` | **有意偏离**:MP 多出目录 `A` | +| 16 | `{{en_title or title}}`,无英文名 | 用 title | 同 | +| 17 | `{{season \| pad(2)}}`,S3 | `03` | MP 无此过滤器;MP 写法 `{{ '%02d' % season\|int }}` 我们拒绝并提示用 pad | +| 18 | year 缺失,模板 `{{title}} ({{year}})` | `X ()` → 段 `X ()` | 同(MP 未定义渲染空);校验给 warning "year 可能为空,建议 {% if year %}" | +| 19 | title 过长 300 字节 | 截到 255 字节,文件名保留 `.mkv` | 偏离:MP 不截,文件系统报错 | +| 20 | 综艺 快乐大本营 airDate=20260927 issue=12 无集号 | `快乐大本营/Season 1/快乐大本营 - 2026-09-27 - 第 12 期.mp4` | 我们特有 | +| 21 | 模板 `{{type}}/{{category}}/{{title}}…`,category 空 | `电视剧/X (2008)/…`(空段丢弃) | 同(MP Path 规范化) | +| 22 | 模板 `{% for x in y %}` | 编译错误 `不支持 for` | 偏离(安全子集) | +| 23 | 模板 `{{title.__class__}}` | 编译错误 `不支持属性访问` | 偏离(安全子集) | +| 24 | 已有作品绑定 `旧名 (2008)`,新模板渲染出 `新名 (2008)` | 目录用 `旧名 (2008)/Season 1/…`,文件名用新模板 | 偏离(路径绑定) | +| 25 | 模板末段不含 `{{fileExt}}` | 编译错误 | 偏离(MP 会生成无扩展名文件) | + +## 11. 实施拆分 + +| 包 | 内容 | 估 | +| --- | --- | --- | +| N1 domain | `naming-engine.ts`(词法/语法/渲染/段角色/错误定位)+ v1 语法迁移 + 变量映射 + §10 全部样例单测 | M(1.5 天) | +| N2 server | settings/库根/作品覆盖存储、三级回落、`/naming/*` 三接口、organize 与 plan 改用新引擎、迁移 | M(1 天) | +| N3 web | 设置 `#naming` 节(编辑器、变量清单插入、样本、实时预览、错误)、库根抽屉覆盖、作品"命名覆盖" | M(1.5 天) | +| N4 testkit | MP 侧离线生成对照输出,差分脚本进 CI | S | + +依赖:N1 → N2 → N3;N4 与 N1 并行。 diff --git a/docs/design/subscriptions.md b/docs/design/subscriptions.md new file mode 100644 index 00000000..39b0e3c1 --- /dev/null +++ b/docs/design/subscriptions.md @@ -0,0 +1,236 @@ +# 订阅重做 + 规则页(对标 MoviePilot v3) + +> 需求来源:`docs/plan/00-first-version-plan.md` §8 第 3、4 条(订阅重做、收录策略 + 偏好标签合一)。命名模板另见 `naming-template.md`。 +> 上游行为账本:`docs/porting/moviepilot-subscribe.md`;差距清单:`docs/plan/05-subscription-gap.md`;规则关键字核对:`docs/porting/mp-rust-logic-parity.md` §6。上游 `jxxghp/MoviePilot` v3 @ `e6820a8`,只记行为,不复制源码。 + +## 0. MP 要点与我们的现状 + +**MP 订阅页**(`pages/subscribe.vue`、`views/subscribe/SubscribeListView.vue`、`components/cards/SubscribeCard.vue`) +- 按类型分路由:电影 / 电视剧 / 音乐;每类 Tab「我的订阅 / 热门 / 分享」。 +- 筛选(剧集):全部 / 洗版 / 未开始 / 订阅中 / 待定 / 暂停 / 已完成。归类顺序:洗版 → P 待定 → S 暂停 → `lack_episode==0` 已完成 → `completed_episode>0` 订阅中 → 未开始。没有分组,只有筛选。 +- 排序:自定义拖拽 / 上次更新 / 添加时间 / 缺集数(剧集),按类型记在本地。批量:全选、启用、暂停、删除。 +- 卡片:背景图 + 海报、年份、名称 + 季(S0 显示"特别篇")、进度 `total−lack / total`、洗版进度条(已洗版/已下载双段)、洗版角标、状态角标(只显示 暂停 / 待定)、用户名、上次更新(相对时间)。**不显示**站点、下载器、规则组。 +- 菜单:编辑 / 搜索 / 暂停·启用 / 重置 / 分享 / 媒体详情 / 文件统计 / 取消订阅。 +- 编辑对话框两 Tab。基础:搜索关键词、总集数、开始集数、质量/分辨率/特效、订阅站点、下载器、保存路径、洗版、全集洗版、用 ImdbID 搜索。进阶:包含、排除、优先级规则组、剧集组、指定季、自定义类别、识别词。"默认订阅规则"用同一对话框,按类型存 `Default{Movie|Tv}SubscribeConfig`。 +- 文件统计对话框:按集两 Tab「下载文件 / 媒体库文件」。选季对话框:每季显示集数、缺失/已订阅,可选 普通 / 分集洗版 / 全集洗版。日历:月视图,事件按 `air_date` 聚合,入库状态 无 / 部分 / 完整。 +- 进度口径:`lack_episode` 存库(普通 = 媒体库缺集;洗版 = 范围内 note 与 pri 都没有的集),`completed_episode` 响应时派生(洗版按 pri==100 计)。 + +**MP 规则**(`app/application/rules.py:510-656`、`modules/filter/__init__.py`、`views/setting/AccountSettingRule.vue`) +- 内置规则 = 正则原子,默认匹配 `标题+副标题+标签`,可限定字段;可带 `tmdb` 条件(命中即放行,CNSUB/CNVOI 用 `original_language`)。自定义规则字段:id、name、include、exclude、size_range(MB,剧集按每集)、seeders、publish_time,同 id 覆盖内置。 +- 规则组 = `name + rule_string + media_type + category`;`>` 分层,层内 `! > & > |` 与括号;第 1 层 pri=100 逐层 −1,一层不中即过滤。多组按 media_type/category 取适用组后**串联 AND**,pri 取**最后一组**。 +- 默认组:订阅默认、洗版默认、搜索默认,各为组名列表;订阅 `filter_groups` 非空则覆盖默认。 +- UI:三张卡片(自定义规则 / 优先级规则组 / 下载优先规则);规则组编辑 = 组名 + 媒体类型 + 分类 + 可拖拽"优先级 N"层卡片,每层多选 chips(内置 + 自定义 + "排除:X"),保存时层内 `&`、层间 `>`;`|` 与括号只能靠导入字符串。JSON 导入/分享。 + +**我们** +- 订阅 = 本地作品 + 季单元 + `season_scope` + `params`;条件在版本目标(Slot);状态 active/paused/pending/completed/cancelled;已有 `/progress`、`/search`、`/episode-total`、历史。订阅页是表格 + 详情抽屉,进度只算**第一个**版本目标(`subscriptionProgress` 取 `slots[0]`)。 +- 版本目标已有:硬条件(`SlotHardConditions`,含 include/exclude、站点、做种、免费)、优先级阶梯 `soft.tiers[]`(层内字段 AND)、三种洗版模式、分集质量账本 `slot_unit`。 +- 收录策略(`policy_revision kind=acquisition`,草案→发布→回滚,含 newTitle / defaultSlots)与偏好标签(`preference_tag` + 抑制记录)是两页;**偏好标签没接入评估**(差距 #17)。 +- 阶梯层内只能 AND 结构化字段;MP 的 SPECSUB/CNVOI/HKVOI/GZ/ATMOS/60FPS/3D/UHD、跨字段 OR 表达不了(parity §6:43 条样例 ✔22 ~6 ✗15)。 + +## 1. 订阅总览 + +路由 `/console/subscriptions`,页头 Tab:**剧集 / 电影 / 综艺 / 日历(P2)**,选中项写 URL(`?kind=`)。视图切换 卡片 / 列表(记本地)。 + +### 1.1 分组(派生,不新增状态值) + +| 分组 | 条件(按顺序判定) | 对应 MP | +| --- | --- | --- | +| 暂停 | `status=paused` | S | +| 待定 | `status=pending`(未建档 / 无 TMDB 身份),或任一范围内季 `totalKnown=false` | P | +| 已完成 | `status=completed`,或 continuous 订阅所有版本目标在范围内全覆盖且 total 已知(卡片仍追下一季时显示"等待新季") | lack=0 | +| 追更中 | active 且至少一个单元已入库或在途 | 订阅中 | +| 待搜索 | active 且没有任何已入库/在途单元 | 未开始 | + +"洗版中"不单列分组,作为徽标(任一版本目标开升级且未达顶档)与筛选项。`cancelled` 不在总览,只在"订阅历史"。 + +默认按分组折叠展示(待定、追更中、待搜索、已完成、暂停),组内按**上次更新**倒序。也可切成平铺 + 排序:上次更新 / 下次搜索 / 缺集数 / 添加时间 / 名称。筛选:分组、洗版中、站点、规则组、来源(用户/Agent/策略)、关键词;全部进 URL query(`use-query-state.ts`)。 + +**上次更新** = `subscription.last_progress_at`(新列):任一关联版本目标的单元转为 已入库 / 在途,或总集数变化时写入;为空取 `created_at`。与 MP `last_update` 同义(MP 在下载添加时更新)。 + +### 1.2 卡片字段 + +| 区 | 字段 | 数据 | +| --- | --- | --- | +| 封面 | 占位块:类型图标 + 标题首字(暂无海报;有 TMDB poster_path 后再接) | — | +| 标题行 | 名称、年份、类型标签;剧集加季(`S1`,S0 显示"特别篇",following_seasons 显示"S2 起") | 实体、季单元 | +| 进度 | `已入库/总`,总未知显示 `?`;下方细条:已入库、在途两段;多版本时显示主版本数字,悬浮列出每个版本 | overview.progress | +| 版本徽标 | 每个版本目标一个:名称 + 状态色(已齐 / 缺 N / 在途 N / 洗版中 pri a→目标 b) | overview.slots | +| 元信息 | 上次更新(相对时间)、下次搜索(或"搜索已关")、站点范围(≤2 个名 + "+N";空 = 全部站点)、规则组(同样折叠) | | +| 状态角标 | 仅 暂停 / 待定 / 洗版中(同 MP 只标异常态) | | +| 操作菜单 | 立即搜索、查看对比、编辑、暂停/启用、重置(P2)、删除;点卡片 = 查看对比 | | + +批量模式:勾选后 立即搜索 / 暂停 / 启用 / 删除(删除走确认,写审计)。空态:"还没有订阅。从媒体库或 Agent 添加。" + +## 2. 对比视图(订阅详情) + +路由 `/console/subscriptions/:id`(取代抽屉;抽屉保留为列表页快速预览)。页头:标题、状态、进度、操作按钮;下方三块:**覆盖矩阵**、**版本目标**、**记录**(搜索记录、候选评估、订阅历史)。 + +### 2.1 覆盖矩阵 + +- 列 = 集,按季分段(季标题显示 `已入库/总 · 总集数来源 · 锁定`,可点"设置总集数");行 = 版本目标,每个一行;首行"库内任一版本"汇总。 +- 电影:每个版本目标一格。 + +| 格状态 | 显示 | 来源 | +| --- | --- | --- | +| 已入库·达标 | 实心,文字 `2160p REMUX` | slot_unit + asset | +| 已入库·可升级 | 实心 + 角点,悬浮 `pri 97 → 目标 100` | slot_unit.pri < targetPri | +| 在途 | 斜纹,计划状态(已预留/下载中/核验中) | 在途计划 unit_keys | +| 延后 | 空心 + 时钟 | slot_deferral | +| 缺失 | 空心红框 | computeUnitGaps | +| 疑似缺失 | 虚线红框 | total 未知时的集号空洞 | +| 未播出 | 灰,显示播出日期 | content_unit.air_date > 今天 | +| 范围外 | 淡灰 | episodeRange / season_scope | + +点格打开侧栏:资产位置(链到文件管理)、质量与 pri、来源资源与下载任务、该单元的候选评估(接受/拒绝理由 trace)、操作"搜索此集""为此版本补全"。 + +### 2.2 多版本与自动补全 + +- 每个版本目标一行,行尾:缺失数、在途数、"补全此版本"按钮、自动补全状态。 +- **自动补全其他版本**:订阅级开关 `params.autoFill: "inherit" | "on" | "off"`,缺省 inherit → 取引用规则组/订阅默认值的 `autoFill`(系统缺省 on)。 + - on:非主版本目标像主版本一样独立评估、搜索、下载缺失单元。 + - off:非主版本目标只登记缺失,不自动建计划;RSS 来的合格资源记为"延后(未开自动补全)";手动"补全此版本"或 Agent 调 `download.plan` 仍可下。 + - 主版本 = 版本目标排序第一个(可在版本目标区拖动)。 +- **补全预算** `params.fillBudget {bytesPerDay?, unitsPerDay?}`:只约束非主版本的自动补全计划,计入全局每日预算;超出记延后,次日由 `slots.reevaluate` 重评。计划优先级用 `upgrade` 档(低于追更)。 + +## 3. 新建 / 编辑订阅表单 + +对话框两 Tab,同 MP 分"基础 / 进阶"。"订阅默认值"(按类型)用同一表单的默认模式,存规则页 §6.4。 + +| 区 | 字段(界面) | MP 字段 | 我们存放 | 默认 / 显示条件 | +| --- | --- | --- | --- | --- | +| 基础 | 作品(搜索本地作品或 TMDB) | media_id / name | `mediaEntityId` / `titleQuery` | 必填 | +| | 类型 | type | `mediaKind` | 由作品带出 | +| | 季 / 季范围 | season | `seasonUnitId` + `seasonScope` | 剧集;缺省最新季 + 接续后续季(Agent 来源缺省仅本季) | +| | 开始集 / 结束集 | start_episode | `params.episodeRange {start,end}` | 剧集 | +| | 总集数(可锁定) | total_episode / manual_total_episode | `POST /episode-total` | 剧集;显示来源 | +| | 方式 | — | `mode` once / continuous | 电影缺省 once | +| | 优先级 | — | `priority` | tracking | +| | 站点范围 | sites | `params.siteIds` | 空 = 全部启用站点 | +| | 下载器 | downloader | `params.downloaderInstanceId` | 空 = 默认实例 | +| | 库根 | save_path(整理去向部分) | `params.libraryRootId`(新) | 空 = 按类型默认库根 | +| | 版本目标 | best_version / best_version_full | 子表:名称 + 规则组 + 升级(关 / 按集 / 整季 / 电影)+ 目标 pri | 缺省取订阅默认值 | +| 进阶 | 规则组 | filter_groups | `params.ruleGroupIds`(作用于全部版本目标)+ 版本目标 `rule_group_ids` | 空 = 订阅默认组 | +| | 包含 / 排除 | include / exclude | 订阅私有规则组(`scope.subscriptionId`,不在列表页显示) | 正则白名单校验 | +| | 分辨率 / 来源 / 特效 | resolution / quality / effect | 同上,写进私有规则组 hard | | +| | 搜索关键词 | keyword | `params.keywords[]`(新,追加到标题/别名检索词) | | +| | 主动搜索间隔 | —(全局) | `params.searchIntervalHours` | 24;0 = 关 | +| | 自动补全 / 补全预算 | — | `params.autoFill`、`params.fillBudget` | inherit | +| | 截止时间 | — | `params.stopAfter` | | +| | 下载保存路径 | save_path | `params.downloadSavePath`(新,P2) | 空 = 实例默认 | +| 不做 | 剧集组 episode_group | | P2(需 TMDB episode group 适配) | | +| | ImdbID 搜索 search_imdbid | | P2 `params.searchByImdb` | | +| | 自定义类别 media_category | | 用命名模板 `category` | | +| | 识别词 custom_words | | 放身份层(实体别名/集偏移),表单给链接 | | + +保存:创建时同实体同季已有 active 订阅 → 返回 409 并给"打开已有订阅"(差距 #18)。 + +## 4. 日历(P2) + +`/console/subscriptions?kind=calendar`:月视图,事件 = 追踪中订阅范围内集单元的 `air_date`(同日同剧聚合为 `E03–E04`),电影取上映日期;颜色 = 该日事件 已入库 / 部分 / 未入库 / 未播出。数据来自 `refreshMetadata` 写入的集单元 `air_date`(需补:目前只写季单元总集数,不建集单元)。接口 `GET /subscriptions/calendar?from&to`。 + +## 5. 后端与 contracts + +| 端点 | 用途 | 新/改 | +| --- | --- | --- | +| `GET /subscriptions/overview?kind&group&sort&siteId&ruleGroupId&q&cursor` | 卡片数据:分组、进度、每版本摘要、上次更新、下次搜索、站点、规则组 | 新 | +| `GET /subscriptions/{id}/coverage` | 覆盖矩阵:季 × 集 × 版本目标 | 新(取代 `/progress`,后者保留一版兼容) | +| `POST /subscriptions/batch` | `{ids, action: search\|pause\|resume\|delete}` | 新 | +| `POST /subscriptions/{id}/slots/{slotId}/fill` | 手动补全某版本(可带 unitKeys) | 新 | +| `POST /subscriptions/{id}/reset` | 清在途预留、重评(保留资产) | 新(差距 #24) | +| `GET /subscriptions/calendar` | 日历 | 新,P2 | +| `PATCH /subscriptions/{id}` | params 新键:`ruleGroupIds keywords libraryRootId autoFill fillBudget downloadSavePath` | 改 | + +contracts(`policy.ts`):`SubscriptionOverviewItem`、`SubscriptionCoverage {seasons[{seasonNumber,total,totalKnown,source,locked,episodes[{episode,airDate}]}], rows[{slotId|null,name,primary,autoFill,cells[{unitKey,state,quality,pri,planId,assetId}]}]}`、`SubscriptionBatch`、`SubscriptionParams` 扩键;规则页见 §6.6。 + +**覆盖矩阵查询**:一次取 作品全部集单元 → 左连 `asset_unit_coverage`(库内任一版本)、`slot_unit`(按版本目标)、在途计划 `unit_keys`、`slot_deferral`;在服务里用 `computeUnitGaps` 逐版本目标算状态。总览用一条聚合 SQL 出每订阅 × 版本目标的 已入库/在途/总 计数,避免 N+1;超 200 条订阅走游标分页。 + +DB:`subscription.last_progress_at`;`acquisition_slot.rule_group_ids uuid[]`、`acquisition_slot.position int`(主版本排序);`rule_group`、`rule_atom` 两表(§6);偏好标签迁移。 + +定时任务(沿用注册表):`subscriptions.search`、`subscriptions.metadata`(补:建集单元与 air_date;完成前强制刷新 = 差距 #8)、`slots.reevaluate`(补:按 `autoFill` 与 `fillBudget` 过滤非主版本)。不新增任务。 + +## 6. 规则页(收录策略 + 偏好标签合一) + +路由 `/console/rules`,页名"规则"。Tab:**规则组 / 自定义规则 / 默认值 / 收录策略 / 抑制记录**。`/console/preferences` 重定向到 `/console/rules?tab=groups`(带 `origin` 筛选时透传);术语表加"规则组",弃用"偏好标签"(对话与 Agent 产生的规则组以"来源"区分)。 + +### 6.1 规则组结构 + +| 字段 | 含义 | +| --- | --- | +| name、description | 名称必填唯一;说明一句 | +| hard | 硬条件(必须 / 排除),即 `SlotHardConditions`,再加 `atoms {require[], exclude[]}` 引用原子 | +| ladder | 优先级阶梯:有序层,每层一个布尔表达式 `expr`(原子 + `! & |` + 括号);第 k 层(0 起)命中 pri = 100 − k | +| ladderRequired | 一层不中即拒绝(MP 语义,缺省 true;从 MP 串导入时固定 true) | +| scope | `mediaKinds[]`、`entityIds[]`、`siteIds[]`、`subscriptionId`(私有组);空 = 全部 | +| origin | manual / conversation / agent_inferred(沿用偏好标签来源) | +| status、revision、fingerprint、evidence | 同偏好标签;每次修改 revision+1 并写审计,评估 trace 记 `组id@revision` | +| autoFill | 引用本组的订阅缺省是否自动补全其他版本 | + +**原子**(`rule_atom`):内置(只读)+ 自定义。自定义原子字段对齐 MP 自定义规则:`id`(大写字母数字)、`name`、`include[]`、`exclude[]`、`matchFields`(title/subtitle/labels,缺省全部)、`unitSizeMB {min,max}`(剧集按每集)、`minSeeders`、`publishDelayMinutes {min,max}`、`free`。同 id 不允许覆盖内置(偏离 MP,避免静默改义)。 + +### 6.2 内置原子(对齐 MP 关键字) + +| 原子 | 语义 | 我们的判定 | +| --- | --- | --- | +| 4K / 1080P / 720P | 分辨率 | `resolution` 字段 | +| BLU | 蓝光原盘 | `source = BluRay-Disc`(先修 parity our_bug:Blu-ray+AVC/HEVC 无 x26x 判原盘) | +| BLURAY | 标题含 Blu-ray | 来源属蓝光族(原盘/REMUX/压制) | +| REMUX / WEBDL | Remux;WEB-DL 或 WEBRip | `source` 字段 | +| UHD | 标题含 UHD/UltraHD | `parsed.uhd` | +| H265 / H264 | 编码 | `videoCodec` | +| HDR / DOLBY / SDR | HDR 类;杜比视界;SDR | `hdr` 字段(HDR = 任一非 DV HDR,同 MP 正则口径做差分) | +| ATMOS | 全景声 | `audio` 含 Atmos | +| 60FPS / 3D | 帧率;3D | `fps>=60`;正则 `3D` | +| CNSUB | 中字 | `subtitles` 含 zh(补 CHS/CHT/GB/BIG5);作品原语言 zh 时直接满足(MP tmdb 放行) | +| SPECSUB | 特效字幕 | 正则 `特效`(标题+副标题+标签) | +| CNVOI / HKVOI | 国语配音 / 粤语 | 正则;CNVOI 作品原语言 zh 时满足 | +| GZ | 官种 | 只看站点标签 `官方/官种/官组` | +| FREE | 免费 | `downloadVolumeFactor = 0` | +| 音乐 13 条(HIRES/FLAC/…) | — | 不做 | + +### 6.3 MP 规则串 ↔ 结构化 + +- 规则组编辑页有"规则串"输入框(高级):输入 `SPECSUB & CNSUB > CNSUB & 4K > 1080P & !BLU` 实时解析,下方同步画出层卡片;反过来编辑层卡片时规则串随之更新(规范化:原子大写、层内按 `|` 优先级加最少括号)。 +- 解析错误逐条给位置:未知原子(给相近名)、括号不配对、空层。音乐原子报"不支持"。 +- 层卡片编辑:每层是 chips 行,chip = 原子或取反原子;层内 chips 之间可切换 `且/或`,支持"加括号组";拖动层即改优先级(同 MP),层标题显示 `优先级 1 · pri 100`。 +- 测试:"用最近资源测试"对 200 条近期资源跑本组,显示每条命中层/被拒原因,和 MP trace 同粒度;也可粘贴标题测试。 +- JSON 导入/导出兼容 MP 格式(`name/rule_string/media_type`,自定义规则 7 字段),media_type 映射到 `mediaKinds`,category 丢弃并提示。 + +### 6.4 引用关系与默认值 + +- 版本目标 `rule_group_ids`(有序)+ 订阅 `params.ruleGroupIds`;评估时编译为 SlotSpec:hard 取交集;各组按 scope 过滤后**全部须命中某层**,pri 取**最后一个**带阶梯的组(同 MP),界面在该组上标"决定优先级"。 +- 版本目标自身的 hard/soft 字段保留,视为一个匿名组排在最前。 +- 默认值 Tab:按类型设 **订阅默认规则组**、**洗版默认规则组**(版本目标开升级时额外叠加)、**搜索默认规则组**(手动搜索与 Agent `release.search`)、订阅默认表单(§3 默认模式:站点、下载器、库根、版本目标模板、自动补全)。 +- 收录策略 Tab:现有策略工作区原样搬入(草案→预览→发布→回滚、newTitle、defaultSlots);`SlotTemplate` 增加 `ruleGroupIds`。 +- Agent:能力 `rules.list` 读规则组;新增/修改走 `rules.propose`(origin=agent_inferred,生成待处理事项,批准后生效);命中有效抑制记录的指纹直接拒绝。对话中用户明确说的偏好 origin=conversation,直接生效并可撤销。 + +### 6.5 偏好标签迁移 + +| 旧 kind | 新规则组 | +| --- | --- | +| hard | hard 条件,ladder 空 | +| soft | ladder 单层(或按 rule 顺序多层),`ladderRequired=false` | +| version | 规则组 + 在"默认值"里挂到对应版本目标模板 | +| target | 转收录策略 newTitle scope,原标签留一条只读规则组作说明 | + +指纹、来源、证据、修订历史、抑制记录原样迁移(抑制按新组指纹重算一次,旧指纹保留作别名)。迁移后偏好标签接入评估,关闭差距 #17。 + +### 6.6 规则接口 + +`GET/POST /rule-groups`、`GET/PATCH/DELETE /rule-groups/{id}`、`GET /rule-groups/{id}/revisions`、`POST /rule-groups/parse {ruleString}` → `{ladder, errors[]}`、`POST /rule-groups/render {ladder}` → `{ruleString}`、`POST /rule-groups/{id}/test {sourceReleaseIds?|titles?}`、`GET/POST/PATCH/DELETE /rule-atoms`、`GET/PUT /rule-defaults`、`POST /rule-groups/import`、`GET /rule-groups/export`。抑制记录接口沿用 `/preference-suppressions`(改名 alias `/rule-suppressions`)。 + +domain:`slot/rule-expr.ts`(词法/解析/求值/规范化输出)、`slot/atoms.ts`(内置原子表)、`SlotTierRule.expr?`;`mapping/slot.ts` 编译规则组 → SlotSpec。差分:`filter-cases.json#rules` 43 条全部重跑,目标 ✗ 从 15 降到 1(只剩音乐)。 + +## 7. 实施拆分与估算 + +| 包 | 内容 | 估 | 依赖 | +| --- | --- | --- | --- | +| R1 domain | rule-expr 解析/求值/串互转、内置原子(含 BLU/CHS 修正)、多组编译、差分样例 | M 2 天 | — | +| R2 server 规则 | 迁移(rule_group、rule_atom、slot.rule_group_ids)、CRUD/parse/render/test/import、偏好标签迁移、评估接入、Agent `rules.propose` | L 3 天 | R1 | +| R3 web 规则页 | 5 个 Tab、层卡片编辑 + 规则串双向、测试面板、`/preferences` 重定向、术语表 | L 3 天 | R2 | +| S1 server 订阅 | last_progress_at、overview 聚合、coverage 矩阵、batch、fill、reset、params 新键、autoFill/fillBudget 调度、查重 409、集单元与 air_date | L 3 天 | R2(ruleGroupIds) | +| S2 web 总览 | 类型 Tab、分组/排序/筛选、卡片、批量 | M 1.5 天 | S1 | +| S3 web 详情 | 覆盖矩阵 + 格侧栏、版本目标区(排序、补全、自动补全)、编辑表单(两 Tab、默认模式) | L 2.5 天 | S1 | +| S4 日历(P2) | 接口 + 月视图 | M 1 天 | S1 | + +并行:R1 与 S1 的非规则部分可同时开工;R3 与 S2/S3 可并行。合计约 15 人日(不含 P2 约 14)。 +验收:`subscription.test.ts` 加 覆盖矩阵、自动补全 off 不建计划、补全预算延后、多组 pri 取最后组;e2e 加"双版本订阅一版齐一版缺 → 矩阵正确 → 手动补全"。