From 8b90d396b2bb58591973d5609b19460af0b77f5e Mon Sep 17 00:00:00 2001 From: root Date: Sun, 27 Sep 2026 17:37:14 +0800 Subject: [PATCH] docs(porting): MoviePilot/MovieClaw porting ledger (W0-C) Co-Authored-By: Claude Fable 5.1 --- docs/porting/license-and-provenance.md | 107 ++++ .../moviepilot-downloader-and-sites.md | 547 ++++++++++++++++++ .../moviepilot-metadata-recognition.md | 458 +++++++++++++++ docs/porting/moviepilot-transfer.md | 421 ++++++++++++++ 4 files changed, 1533 insertions(+) create mode 100644 docs/porting/license-and-provenance.md create mode 100644 docs/porting/moviepilot-downloader-and-sites.md create mode 100644 docs/porting/moviepilot-metadata-recognition.md create mode 100644 docs/porting/moviepilot-transfer.md diff --git a/docs/porting/license-and-provenance.md b/docs/porting/license-and-provenance.md new file mode 100644 index 00000000..a5f3b0e7 --- /dev/null +++ b/docs/porting/license-and-provenance.md @@ -0,0 +1,107 @@ +# 许可证核对与来源登记(MoviePilot / MovieClaw) + +> 本文依据 REQ §14.2 和任务书 §1"参考源码"一栏,属于工程合规边界的说明,**不是法律意见**。在对外分发或商业化之前,必须针对具体要发行的产品另行做法律审查(REQ §14.2 末段)。 + +## 1. 核对结论 + +| 项 | MoviePilot | MovieClaw | +| --- | --- | --- | +| 仓库 | `https://github.com/jxxghp/MoviePilot`(分支 `v3`) | `https://github.com/movieclaw/movieclaw`(`main`) | +| 本次核对的提交 | `e6820a83ab05e8a00eb0e14adf0f357faa965c7e`(2026-08-30,`version.py = v3.0.0`)。本地路径 `/opt/moviepilot-plus/upstream/MoviePilot`,是单提交浅克隆 | `fc9eb7248dd9c4f7cd8f3fbd704a357b0b766b90`(REQ 附录 [S16])。**本机没有源码**(`find /opt -maxdepth 3 -iname "*claw*"` 没有结果),是通过 GitHub API 和 raw 取到该提交的 `LICENSE`、`pyproject.toml` 以及站点配置原文(见 `moviepilot-downloader-and-sites.md` §0) | +| REQ 附录快照 | `33da9ce65d5020a8e087584a3321c1ff353c1921`:本地仓库里没有这个对象,无法逐行比对。**以本表提交为准**,引用行号时必须写明提交 | 与附录一致 | +| 仓库许可 | 根目录 `LICENSE` 是 **GNU GPL v3** 全文(674 行,md5 `1ebbd3e34237af26da5dc08a4e440464`) | 该提交的 `LICENSE` 是 **修改版 Apache-2.0,附加非商业条件**;LICENSE 第 44-45 行写明"v0.27.0 及之前发布的版本以 MIT 许可证发布……本许可证适用于 v0.27.0 之后发布的版本" | +| 包元数据 | `pyproject.toml` 没有 license 字段 | `pyproject.toml` 第 7 行 `version = "0.27.0"`,第 11 行 `license = { text = "MovieClaw License (Apache-2.0 with non-commercial conditions)" }` | +| MovieClaw 附加条件要点 | — | 两者冲突时附加条件优先;免费使用仅限非商业(个人/家庭自用、亲友间非营利共享、学习研究教学);**公司内部使用也算商业使用**,需要书面授权;再分发要附许可全文,衍生版本继续适用本许可。本项目给用户个人和家庭的媒体设施使用,是否落在"非商业"范围内要由用户确认;在确认之前,对 MovieClaw 一律只参考字段结构(`data-ref`) | +| 附加声明 | `README.md` 第 17 行写"仅用于学习交流使用,请勿在任何国内平台宣传该项目",第 79-80 行免责申明写"不得用于商业用途……不建议对用户认证机制进行规避或修改并公开发布"。这些是 README 里的声明,**不是 LICENSE 的附加条款**。GPLv3 §7 不允许附加"进一步限制",但按 REQ §14.2 的要求,本项目**仍然尊重这些声明**,不把它们解读成可以忽略的内容 | 附加条件写在 LICENSE 正文里,有约束力:商业使用需要书面授权,衍生版本继续适用同样的条款 | +| 版本字段陷阱 | — | main 的 `pyproject` 版本号仍是 0.27.0,但 LICENSE 已经是附条件版本。**不能根据版本号推定 main 是 MIT**(REQ §14.2 原话)。要按 MIT 使用,只能引用已经核实过的 `v0.27.0` 及更早的 **tag**,而且要逐个文件核对那个 tag 附带的许可 | + +### 1.1 MP 中的非源码或闭源部分(不得作为参考源) + +| 组件 | 位置 | 状态 | 处理 | +| --- | --- | --- | --- | +| `SitesHelper`(站点认证、流控、站点索引定义) | `app/application/site/sites.cpython-314-x86_64-linux-gnu.so`,已被 `.gitignore:4` 忽略;仓库只有 `sites.pyi` 类型存根 | 闭源二进制,由 `docker/Dockerfile:261-262` 从 `jxxghp/MoviePilot-Resources` 下载 | **不反编译,不参考。** 站点选择器改以 MovieClaw 配置和我们自己抓取的页面为准 | +| 站点样例配置 | `tests/test_rust_accel.py` 里有少量站点 indexer 样例(公开测试数据) | GPL-3.0 | 只用来理解字段语义,不作为选择器的来源 | +| 站点定义数据 | `app/application/site/user.sites.v3.bin`,已被 `.gitignore:12` 忽略,内容加密 | 同上 | 不解密,不使用 | +| Rust 加速 `moviepilot_rust` 0.3.3 | 以 wheel 形式安装在 `.venv`,源码仓库是 `jxxghp/MoviePilot-Rust` | wheel 附带的 LICENSE 是 GPLv3 | 只对照 Python 参考路径(见 `moviepilot-metadata-recognition.md` §0.1) | +| 内嵌的 `tmdbv3api` | `app/modules/themoviedb/tmdbv3api/*`,**文件头没有许可说明**,仓库里也没有单独的 LICENSE/NOTICE | 来源推测是 AnthonyBloomer/tmdbv3api(MIT),但未核实 | 不使用;本项目直接调 TMDB REST v3 | +| 第三方运行依赖 | `anitopy~=2.1.1`、`cn2an~=0.5.24`、`pinyin2hanzi~=0.1.1`、`regex`、`zhconv` 等(`pyproject.toml`) | 各有各的许可 | 本项目如需等价的 npm 库,另行选型并单独登记,不因为 MP 用过就默认可用 | + +--- + +## 2. 本项目的迁移边界 + +任务书 `docs/plan/00-first-version-plan.md` §1 规定:"MoviePilot:GPLv3,**只做行为对照与 TS 重写,不复制文件**"。REQ §14.1 允许"以具体实现为参考迁移业务逻辑"。两条放在一起执行时,按下表办。 + +| 允许 | 不允许 | +| --- | --- | +| 阅读上游源码,理解调用链、条件分支、状态机和数据结构,用我们自己的语言写成**行为对照记录**(也就是 `docs/porting/*.md`) | 把 `.py`、`.yaml` 文件或者大段代码原样放进仓库,包括放进注释或测试 fixture | +| 用本机上游解释器**运行**上游代码,只为得到行为输出,作为 parity 期望值(脚本放在仓库外,或放在 `packages/testkit/scripts/` 并标注"可选、需要本机 MP") | 让 CI 依赖上游代码;把上游模块作为运行时依赖;通过子进程调用 MP | +| 按对照记录**独立**编写 TS 实现,命名、结构和类型遵循本项目约定 | 逐行机械翻译(包括让 AI 逐行改写)后当作原创。REQ §14.2:跨语言翻译或 AI 改写不会自动让移植成为没有来源的独立原创 | +| 引用事实性常量:协议字段名(qB WebUI API、M-Team API、TMDB API)、站点公开的 HTML 结构、行业通用词表(`WEB-DL`、`REMUX`、`DDP5.1`) | 照搬上游**整理过的数据表**:`RELEASE_GROUPS` 站点到组名的正则表(`app/domain/meta/releasegroup.py:26`)、`StreamingPlatforms` 平台码表(`app/domain/meta/streamingplatform.py`,约 270 条)、`_name_nostring_re` 干扰词表。这类表要**自行编制**,并在 ledger 里写明来源(例如公开的 scene/P2P 命名规范、站点官方规则页) | +| 上游测试里的**标题字符串**(事实数据)可以作为输入语料 | 整份拷贝 `tests/cases/meta.py` 这类测试文件;期望值必须由我们自己按 REQ 判定,不是照抄上游的 target | +| MovieClaw:阅读 YAML 的**字段结构**,参考其字段设计 | 在没有书面授权时拷贝 MovieClaw main 的配置或代码(附条件许可);把 main 的内容当作 MIT 使用 | + +**对每个 PR 的要求**:只要实现参考了上游,PR 描述里就必须写"参考:`docs/porting/<文件>#<节>`",并在 §3 ledger 里补一行,登记状态为 `behavior-ref`。只有真的复制了代码或数据时,才登记为 `copied`;按目前的边界,**不应出现 `copied`**。 + +### 2.1 分发时的注意事项(首期不分发,先写在这里) + +- 本项目用于内部部署(REQ §1.1.1)。内部使用不是忽略上游条款的理由(REQ §14.2)。 +- 如果以后出现 `copied` 条目:MP 来源的部分会让整个分发物受 GPLv3 约束(需要同许可、提供源码、保留声明);MovieClaw main 来源的部分需要书面授权。发现这种情况要**先移除,再讨论**。 +- Docker 镜像不得包含 MP 或 MovieClaw 的任何文件,也不得在构建时下载 `MoviePilot-Resources`。 + +--- + +## 3. Component ledger + +### 3.1 字段模板 + +| 字段 | 说明 | +| --- | --- | +| `id` | `CL-<序号>` | +| `our_module` | 本项目的文件或函数,例如 `packages/domain/src/title/parse.ts#parseReleaseTitle` | +| `kind` | `behavior-ref`(只参考行为)/ `data-ref`(参考了数据结构,内容自行编制)/ `copied`(复制,原则上禁止)/ `dependency`(npm 依赖) | +| `upstream` | 仓库 URL | +| `commit_or_version` | 精确提交或 tag,不能写 `main` | +| `upstream_paths` | 文件:函数:行号 | +| `license` | 上游在该提交下的许可 | +| `porting_doc` | `docs/porting/*.md#节` | +| `modifications` | 和上游行为的差异摘要,要引用对照文档里的"调整项" | +| `notice_required` | 是 / 否(`copied` 或 MIT/Apache 依赖要保留版权声明) | +| `distribution` | internal-only / image / source | +| `reviewer`、`date` | 登记人和日期 | + +### 3.2 初始登记(W0-C) + +| id | our_module(建议) | kind | upstream | commit | upstream_paths | license | porting_doc | modifications | notice | distribution | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| CL-001 | `packages/domain/src/title/parse.ts` | behavior-ref | MoviePilot | e6820a8 | `app/domain/metainfo.py:495,533`;`app/domain/meta/metavideo.py:163-874`;`app/domain/meta/metabase.py:244-431,719` | GPL-3.0 | metadata-recognition §2-§3 | 新增 airDate、issue、cut、packHint、未知发布组兜底;调整了 merge 条件 | 否 | internal-only | +| CL-002 | `packages/domain/src/title/custom-words.ts`、`episode-format.ts` | behavior-ref | MoviePilot | e6820a8 | `app/domain/meta/words.py:54-221`;`app/application/formatting.py:84-230` | GPL-3.0 | metadata-recognition §4 | 禁用 eval,规则版本化,偏移范围收窄 | 否 | internal-only | +| CL-003 | `packages/domain/src/title/release-groups.ts` | data-ref | MoviePilot | e6820a8 | `app/domain/meta/releasegroup.py:26,117,127` | GPL-3.0 | metadata-recognition §5 | 只参考"站点到组名"的结构和界定符语义,组名表自行编制 | 否 | internal-only | +| CL-004 | `packages/domain/src/identity/tmdb-match.ts`、`packages/adapters/src/tmdb/*` | behavior-ref | MoviePilot | e6820a8 | `app/modules/themoviedb/__init__.py:190-930`;`app/modules/themoviedb/tmdbapi.py:130-770,961-1045`;`cache.py:143-265` | GPL-3.0 | metadata-recognition §7-§8 | 候选打分,人工锁定,缓存放 PG | 否 | internal-only | +| CL-005 | `packages/adapters/src/nfo/writer.ts`、`packages/tasks/src/refresh-metadata.ts` | behavior-ref | MoviePilot | e6820a8 | `app/chain/scraping.py:148-395`;`app/modules/themoviedb/scraper.py:35-420` | GPL-3.0 | metadata-recognition §10 | 刮削改为独立任务;uniqueid 里写本地 UUID | 否 | internal-only | +| CL-006 | `packages/domain/src/organize/*`、`packages/adapters/src/fs/*`、`packages/tasks/src/verify-and-import.ts` | behavior-ref | MoviePilot | e6820a8 | 见 `moviepilot-transfer.md` §2 各表 | GPL-3.0 | transfer §2-§4 | 见 transfer §3 的必须偏离表 | 否 | internal-only | +| CL-007 | `packages/adapters/src/qbittorrent/*` | behavior-ref | MoviePilot | e6820a8 | `app/modules/qbittorrent/__init__.py`、`qbittorrent.py`(行号见 downloader-and-sites 文档) | GPL-3.0 | downloader-and-sites §qB | 标签接管,删数据前做引用检查 | 否 | internal-only | +| CL-008 | `packages/adapters/src/sites/nexusphp/*` | behavior-ref | MoviePilot | e6820a8 | `app/modules/indexer/spider/*`、`app/modules/indexer/parser/nexus_php.py`、`nexus_audiences.py` | GPL-3.0 | downloader-and-sites §站点 | 站点定义自行编制;不使用闭源 SitesHelper | 否 | internal-only | +| CL-009 | `packages/adapters/src/sites/nexusphp/configs/audiences.ts` | data-ref | MovieClaw | fc9eb72 | `src/movieclaw_tracker/sites/configs/audiences.yaml` | MovieClaw License(Apache-2.0 加非商业条件) | downloader-and-sites §MovieClaw | **只参考字段结构**;选择器值以我们自己抓取的观众页面为准重新编写 | 否(未复制) | internal-only | +| CL-010 | `packages/adapters/src/sites/mteam/*` | behavior-ref + data-ref | MovieClaw + MoviePilot | fc9eb72 / e6820a8 | MC:`sites/configs/mteam.yaml`、`sites/custom/mteam.py`;MP:`app/modules/indexer/parser/mtorrent.py` | MC 附条件 / GPL-3.0 | downloader-and-sites §M-Team | API 字段以 M-Team 公开 API 为准,按 API Key 模式实现 | 否 | internal-only | +| CL-011 | `packages/testkit/fixtures/title-parity.json` | data-ref | MoviePilot | e6820a8 | 输入语料参考 `tests/cases/meta.py`;**期望值自行判定** | GPL-3.0(原测试文件) | metadata-recognition §9 | 只取标题字符串,必要时脱敏 | 否 | internal-only | + +> 新增 npm 依赖(例如 anitopy 的等价实现、`re2`、`fast-xml-parser`)按 `kind=dependency` 登记,填锁定版本和 SPDX。 + +### 3.3 ledger 的维护规则 + +1. ledger 以本文件 §3.2 为唯一登记处,按 id 追加,不删除旧行;撤销时把行标记为 `retired`。 +2. 上游提交升级时,**新增一行**,写新的提交号,不覆盖旧行。对照文档里的行号也要按新提交重新核对。 +3. `copied` 行出现时,CI 必须失败。建议在 `scripts/` 里加一个检查:ledger 中 `kind=copied` 的行数必须为 0。 +4. 审核人检查三件事:PR 里有没有"参考:"链接;ledger 有没有对应行;diff 中有没有大段和上游高度相似的代码(可以用 `jscpd` 这类工具,把上游目录作为外部语料做一次抽样检查)。 + +--- + +## 4. 待办与未决 + +| 项 | 状态 | +| --- | --- | +| REQ 附录快照 `33da9ce` 和本地 `e6820a8` 的差异 | 未比对,本地没有该对象。需要时 `git fetch --depth=1 origin 33da9ce...` 再比对(需要网络) | +| MovieClaw 是否有可用的 `≤v0.27.0` MIT tag,以及 tag 内的文件许可 | 未核对。只有在决定复用 MovieClaw 历史代码时才需要做 | +| `tmdbv3api` 内嵌副本的来源和许可 | 未核实;本项目不使用它 | +| 对外分发 | 首期不做;如果要做,先完成法律审查 | diff --git a/docs/porting/moviepilot-downloader-and-sites.md b/docs/porting/moviepilot-downloader-and-sites.md new file mode 100644 index 00000000..2752afad --- /dev/null +++ b/docs/porting/moviepilot-downloader-and-sites.md @@ -0,0 +1,547 @@ +# MoviePilot / MovieClaw 迁移对照:下载器(qBittorrent)与站点抓取底座 + +> 格式:REQ §14.1.2 迁移交付规范。只做行为对照与 TS 重写,**不复制上游文件**(许可边界见 `license-and-provenance.md`)。 +> 本文件是研发对照记录,不表示对应 TS 模块已实现或已逐函数核验。 + +## 0. 来源与版本 + +| 项 | 值 | +| --- | --- | +| MoviePilot 本地路径 | `/opt/moviepilot-plus/upstream/MoviePilot` | +| MoviePilot 精确提交 | `e6820a83ab05e8a00eb0e14adf0f357faa965c7e`(v3 分支,单提交浅克隆,提交时间 2026-08-30 13:32 +0800,`version.py` = `v3.0.0`) | +| 与 REQ 附录快照关系 | REQ [S16] 引用的是 `33da9ce65d5020a8e087584a3321c1ff353c1921`,**本地克隆中不存在该对象**(`git cat-file` 失败)。本文件所有 MP 行号均以 `e6820a8` 为准;若要对齐 [S16],需另行拉取该提交复核差异。 | +| MoviePilot LICENSE | 仓库根 `LICENSE` = GPLv3 | +| MoviePilot 闭源部分 | `app/application/site/sites.cpython-314-x86_64-linux-gnu.so`(`SitesHelper`,Cython 编译)+ `user.sites.v3.bin`(加密的站点索引资源)。**站点 indexer 定义(各站选择器 YAML/JSON)不可读**,只能看 `sites.pyi` 接口与 `tests/` 中的样例配置。 | +| MovieClaw | 本机无源码(`find /opt -maxdepth 3 -iname "*claw*"` 无结果)。已通过 GitHub API 取得提交 `fc9eb7248dd9c4f7cd8f3fbd704a357b0b766b90` 的目录树(2039 个 blob,未截断)与下列原文:`src/movieclaw_tracker/sites/configs/audiences.yaml`、`.../configs/mteam.yaml`、`.../sites/custom/mteam.py`、`src/movieclaw_tracker/selectors.py`、`src/movieclaw_tracker/frameworks/nexusphp.py`、`src/movieclaw_tracker/models.py`、`src/movieclaw_tracker/registry.py`、`LICENSE`、`pyproject.toml`。 | +| MovieClaw LICENSE(该提交) | 修改版 Apache-2.0 + 非商业附加条件;LICENSE 第 44-45 行:"v0.27.0 及之前发布的版本以 MIT 许可证发布…本许可证适用于 v0.27.0 之后发布的版本"。`pyproject.toml` 第 7 行 `version = "0.27.0"`,第 11 行 `license = { text = "MovieClaw License (Apache-2.0 with non-commercial conditions)" }`。**main 提交不能按 MIT 处理。** | + +路径约定:下文 "MP:" 前缀省略仓库根;"MC:" 指 MovieClaw 仓库根。行号为函数 `def` 所在行。 + +--- + +## 1. qBittorrent 适配器 + +### 1.1 上游文件/函数清单 + +| # | 上游文件 : 行 | 函数 | 输入 | 输出 | 职责 | +| --- | --- | --- | --- | --- | --- | +| Q1 | MP `app/modules/qbittorrent/qbittorrent.py:22` | `Qbittorrent.__init__` | host/port/username/password/apikey/category(bool)/sequentail/force_resume/first_last_piece/incomplete_files_ext | 实例(`self.qbc` 可能为 None) | 保存配置并立即登录 | +| Q2 | 同上 `:188` | `__login_qbittorrent` | — | `qbittorrentapi.Client` 或 None | 有 apikey 时 `EXTRA_HEADERS={"Authorization": "Bearer "}` 并调 `app_version()` 验证;否则 `auth_log_in()`;超时 `(15, 60)`;`VERIFY_WEBUI_CERTIFICATE=False`;登录成功后同步 `incomplete_files_ext` | +| Q3 | 同上 `:160` | `__sync_incomplete_file_suffix` | qbt, enabled | — | 读 `app_preferences()`,不一致才 `app_set_preferences({"incomplete_files_ext": enabled})`——**会改写下载器全局偏好**,目的是让未完成文件带 `.!qB` 后缀以免整理监控提前处理 | +| Q4 | 同上 `:174` / `:182` | `is_inactive` / `reconnect` | — | bool / — | `qbc is None` 即需要重连;重连 = 重新登录 | +| Q5 | 同上 `:225` | `get_torrents(ids, status, tags)` | hash 列表/逗号串、qB `status_filter`、标签 | `(list, is_error)` | `torrents_info(torrent_hashes, status_filter)`;**标签过滤在客户端做**:把 `torrent.tags` 按 `,` 拆分 strip 后判断 `set(tags) ⊆ torrent_tags` | +| Q6 | 同上 `:255` | `get_completed_torrents` | ids, tags | list 或 None(异常) | 先 `status="completed"`,再二次过滤:`progress>=1 && amount_left<=0 && state ∉ {allocating, checkingdl, checkingup, downloading, error, forceddl, missingfiles, metadl, moving, queueddl, stalleddl, unknown}` | +| Q7 | 同上 `:292` | `get_downloading_torrents` | ids, tags | list 或 None | `status="downloading"` | +| Q8 | 同上 `:305` | `delete_torrents_tag(ids, tag)` | ids 可为 None | bool | 先 `torrents_remove_tags`(有 ids 时),再 `torrents_delete_tags` 删除**全局标签定义** | +| Q9 | 同上 `:323` / `:338` | `remove_torrents_tag` / `set_torrents_tag` | ids, tags | bool / — | 单纯移除/添加标签 | +| Q10 | 同上 `:356` | `torrents_set_force_start` | ids | — | 仅当配置 `force_resume` 为真 | +| Q11 | 同上 `:385` | `get_torrent_id_by_tag(tags, status)` | 临时随机标签 | hash 或 None | 最多 9 次(`range(1,10)`),每次先 `sleep(3)` 再按标签查;查到即删临时标签;**全部失败也删全局临时标签**(避免残留随机标签) | +| Q12 | 同上 `:408` | `add_torrent(content, is_paused, download_dir, tag, category, cookie, **kwargs)` | str=URL/磁力,bytes=.torrent 内容 | `(ok, added_ids)` | 见 1.2 分支表 | +| Q13 | 同上 `:99` | `__parse_add_torrent_response` | qB 返回 | `(ok, ids)` | 字符串含 `Ok` 即成功;新版 API(5.x)返回 `{success_count, pending_count, added_torrent_ids}`,有 ids 用 ids,否则 count>0 视为成功 | +| Q14 | 同上 `:132` | `__sync_download_cookies` | url, cookie header | bool | Web API ≥ 2.11.3 时把站点 Cookie 同步到 qB Cookie 存储,成功则不再随 add 请求携带 cookie | +| Q15 | 同上 `:484`/`:497` | `start_torrents` / `stop_torrents` | ids | bool | `torrents_resume` / `torrents_pause` | +| Q16 | 同上 `:510` | `delete_torrents(delete_file, ids)` | — | bool | `torrents_delete(delete_files=..., torrent_hashes=...)`,**无任何路径引用检查** | +| Q17 | 同上 `:525` | `get_files(tid, retry, interval)` | hash | 文件列表或 None | 重试读 `torrents_files`;空列表也视为失败继续重试 | +| Q18 | 同上 `:550` | `set_files(torrent_hash, file_ids, priority)` | — | bool | `torrents_file_priority`;缺 hash 或 file_ids 直接 False | +| Q19 | 同上 `:741` | `get_content_layout` | — | `Original`/`Subfolder`/`NoSubfolder` | 读**全局偏好** `torrent_content_layout` | +| Q20 | 同上 `:677`/`:693`/`:615` | `set_torrent_location` / `set_torrent_category` / `recheck_torrents` | — | bool | 迁移保存路径、改分类、重新校验 | +| Q21 | MP `app/modules/qbittorrent/__init__.py:95` | `QbittorrentModule.download(content, download_dir, cookie, episodes, category, label, downloader)` | 种子内容/路径/磁力;需要的集号集合 | `(downloader_name, hash, layout, message)` | 业务级"添加 + 找回 hash + 选文件 + 启动",见 1.3 | +| Q22 | 同上 `:229` | `list_torrents(status, hashs, downloader, include_all_tags)` | 查询状态枚举 | `DownloaderTorrent[]` | 多实例遍历,默认只查带 `TORRENT_TAG`(默认 `"MOVIEPILOT"`,`app/runtime/config.py:458`)的任务 | +| Q23 | 同上 `:358` | `__normalize_torrent_state` | qB state | `paused`/`downloading`/`completed` | 见 1.4 | +| Q24 | 同上 `:369` | `transfer_completed(hashs)` | — | — | 打 `已整理` 标签 | +| Q25 | 同上 `:381` | `remove_torrents(hashs, delete_file=True)` | — | bool | **默认 delete_file=True** | +| Q26 | MP `app/modules/_base/downloader.py:60` | `_get_torrent_info` | Path/str/bytes | `(Torrent|None, bytes|None)` | Path 不存在时读 FileCache `region="torrents"`;磁力不解析 | +| Q27 | MP `app/modules/__init__.py:363` / `:381` | `normalize_path` / `normalize_return_path` | 路径, 下载器名 | str | 按下载器配置 `path_mapping: [(storage_path, download_path)]` 做前缀替换,首条命中即停;并去掉 `local:`/`rclone:` 等存储协议前缀 | + +### 1.2 `add_torrent`(Q12)关键分支 + +| 条件 | 行为 | +| --- | --- | +| `qbc is None` 或 `content` 空 | 返回 `(False, [])` | +| `content` 为 str | 作为 `urls` 提交(URL 或磁力);bytes 作为 `torrent_files` | +| `ignore_category_check=True`(默认) | `use_auto_torrent_management = self._category`(配置开关),category 原样传 | +| `ignore_category_check=False`(`download()` 走这条) | 只有 `category && self._category` 时才启用自动管理并传 category;否则 `is_auto=False, category=None` | +| URL 且有 cookie 且非磁力 | 尝试 `__sync_download_cookies`;成功则 add 请求不带 cookie | +| 其余固定参数 | `save_path`、`is_paused`、`tags`、`is_sequential_download=sequentail`、`is_first_last_piece_priority`、`cookie`、`category` | +| 抛异常 | 记日志,返回 `(False, [])`——**无法区分"qB 未收到"与"qB 已收到但响应丢失"** | + +### 1.3 `QbittorrentModule.download`(Q21)流程与状态 + +| 步骤 | 上游行为(`__init__.py:95-227`) | 失败返回 | +| --- | --- | --- | +| 1 | `_get_torrent_info` 读种子;既不是可解析种子也不是磁力 → 失败 | `(None,None,None,"添加种子任务失败:无法读取种子文件")` | +| 2 | 取实例 `get_instance(downloader)`;不存在 | 直接 `return None`(注意:不是四元组,调用方需兼容) | +| 3 | 生成 10 位随机标签 `tag`;`label` 存在则 `label.split(',') + [tag]`,否则 `[tag, TORRENT_TAG]` 或 `[tag]` | — | +| 4 | `is_paused = bool(episodes)`——**需要选文件时先暂停添加** | — | +| 5 | `add_torrent(..., download_dir=normalize_path(...), ignore_category_check=False)` | — | +| 6a | 添加失败:`get_torrents()` 全量查询;按 **`name` 相等且 `total_size` 相等** 判定"已存在同种子" → 删除临时标签;若带 `已整理` 标签则移除;若缺 `TORRENT_TAG` 则补打;返回 hash 与 layout,消息 `"下载任务已存在"` | 查询异常:`"无法连接qbittorrent下载器"`;未匹配:`"添加种子任务失败:"` | +| 6b | 添加成功:优先用 `added_torrent_ids[0]`(并删临时标签),否则 `get_torrent_id_by_tag(tag)` 轮询 | 找不到 hash:`"下载任务添加成功,但获取Qbittorrent任务信息失败"` | +| 7 | `layout = get_content_layout()`(全局偏好,不是该任务实际布局) | — | +| 8 | `is_paused` 时:`get_files(hash, retry=5, interval=1)`;对每个文件 `MetaInfo(file_name)`,**文件无集号或集号不是 `episodes` 子集 → 列入不下载** | 读文件失败:返回 hash 但消息 `"获取种子文件失败,下载任务可能在暂停状态"`(任务停留暂停) | +| 9 | 只有 `sucess_epidised`(拼写如此)非空 **且** 有要排除的文件时才 `set_files(priority=0)`;一个都没匹配上时**不排除任何文件、整种下载** | — | +| 10 | `force_resume` → `torrents_set_force_start`,否则 `start_torrents` | 返回消息 `"添加下载成功,已选择集数:[...]"` | +| 11 | 未暂停添加:仅在 `force_resume` 时强制开始 | `"添加下载成功"` | + +### 1.4 状态映射(Q23 + Q6) + +| qB 原始 state(小写) | `list_torrents` 归一(Q23) | `get_completed_torrents`(Q6)是否算完成 | 本项目建议 `download_task` 观测态 | +| --- | --- | --- | --- | +| `paused`/`pauseddl`/`stoppeddl` | paused | 否(status_filter=completed 不会返回;若返回 progress<1 也排除) | `paused`(未完成) | +| `pausedup`/`stoppedup` | paused | 是(progress=1) | `completed_paused` | +| `allocating`/`checkingdl`/`downloading`/`forceddl`/`metadl`/`queueddl`/`stalleddl` | downloading | 否 | `downloading` / `stalled`(stalleddl 单列,供 §8.4.2 无进展判定) | +| `uploading`/`stalledup`/`forcedup`/`queuedup` | completed | 是 | `seeding` | +| `checkingup` | completed | **否** | `checking` | +| `moving` | completed | **否** | `moving` | +| `error` | **completed(!)** | **否** | `error` | +| `missingfiles` | **completed(!)** | **否** | `missing_files` | +| `unknown` / 空 | completed | 否 | `unknown` | + +> 上游缺陷:Q23 用"不在 paused/downloading 集合 ⇒ completed"的兜底,`error`、`missingfiles`、`unknown` 在列表视图会显示为完成。完成判定只能用 Q6 的严格条件。本项目禁止兜底为完成,未识别状态一律 `unknown`。 + +### 1.5 `list_torrents` 查询分支(Q22) + +| 查询 | 行为 | +| --- | --- | +| 指定 `hashs` | 各实例 `get_torrents(ids=hashs, tags=query_tags)`——**即便按 hash 查也受 `TORRENT_TAG` 过滤**,未打标签的任务查不到(`include_all_tags=True` 除外) | +| `TRANSFER` | `get_completed_torrents(tags)` 后跳过 tags 字符串中含 `已整理` 的(注意是对逗号串做 `in` 子串判断) | +| `DOWNLOADING` | `get_downloading_torrents` | +| `ALL`/`COMPLETED`/`PAUSED` | 全量取回后按 Q23 归一值过滤 | +| 路径 | `content_path` 优先,否则 `save_path/name`;再 `normalize_return_path` 反向映射 | +| 返回字段 | downloader、title、name/year/season_episode(由 `MetaInfo(name)` 解析)、path、save_path、content_path、hash、size、tags、category、限速、ratio_limit、seeding_time_limit、progress(×100)、state、速度、剩余时间 | + +### 1.6 删除与"已整理"标签的上游条件 + +| 上游位置 | 条件 | 动作 | +| --- | --- | --- | +| MP `app/chain/transfer/settlement.py:620` `_TransferChain__mark_torrent_completed_if_done` | 作业视图中该 hash 所有整理任务结束 **且** `__is_torrent_download_completed`(`:650`,`list_torrents(hashs)` 全部 progress≥100;查不到/异常视为未完成) **且** 复查无新登记任务 | `transfer_completed` 打 `已整理`。注释引用 issue #6009:多集种子部分文件先完成时提前打标签会让剩余文件永远不被整理 | +| MP `app/chain/transfer/settlement.py:469-500` | 仅 `transfer_type == "move"`,且该作业全部成功,且 `jobview.is_torrent_success(hash)`(跨作业该种子所有任务成功),且 `_can_delete_torrent` 为真 | `remove_torrents(hash)`,**delete_file 默认 True** | +| MP `app/chain/transfer/history.py:305` `_can_delete_torrent` | 查不到种子 → False;progress<100 → False;取不到文件列表 → False;遍历文件,`save_path = torrents[0].path.parent`,若存在 **后缀在媒体扩展名内、未命中整理屏蔽词、且物理存在** 的文件 → False;否则 True | 判定 move 后源是否已搬空 | +| MP `app/chain/download/tasks.py:102` `_download_file_deleted` | 收到"下载源文件被删除"事件且按 hash 查得到任务 | `remove_torrents(delete_file=False)` 并发 `DownloadDeleted` 事件 | +| MP `app/agent/tools/impl/delete_download_tasks.py:63` | Agent 工具参数 `delete_files` | 直接调用,无额外引用检查 | + +上游**没有**:共享保存根检查、同一 save_path 被其他任务引用检查、H&R/做种时长判断、qB 失联时的保守分支(失联 → 查询返回 None → 不删,属于隐式保守)。 + +### 1.7 对应 TS 模块(建议) + +包:`packages/adapters/src/qbittorrent/`;纯判定逻辑放 `packages/domain/src/download/`。 + +| TS 路径 | 函数签名 | 对应上游 | +| --- | --- | --- | +| `packages/adapters/src/qbittorrent/client.ts` | `class QbClient { constructor(cfg: QbInstanceConfig, http: HttpPort); login(): Promise; ensureSession(): Promise; appWebApiVersion(): Promise }` | Q1-Q4 | +| 同上 | `torrentsInfo(q: { hashes?: string[]; filter?: QbStatusFilter; category?: string; tag?: string }): Promise` | Q5(**用 qB 原生 `tag` 参数**,不在客户端过滤全量) | +| 同上 | `torrentsAdd(req: QbAddRequest): Promise`,`QbAddRequest = { source: {kind:'file'; bytes: Uint8Array; filename: string} \| {kind:'url'; url: string}; savepath: string; category?: string; tags: string[]; paused: boolean; autoTMM: false; contentLayout?: 'Original'\|'Subfolder'\|'NoSubfolder'; sequential?: boolean; firstLastPiecePrio?: boolean }`,`QbAddResult = { accepted: boolean; addedIds: string[]; raw: unknown }` | Q12/Q13 | +| 同上 | `torrentFiles(hash: string, opts?: { retries: number; intervalMs: number }): Promise`;`setFilePriority(hash: string, fileIds: number[], priority: 0 \| 1 \| 6 \| 7): Promise` | Q17/Q18 | +| 同上 | `addTags(hashes: string[], tags: string[])`、`removeTags(hashes: string[], tags: string[])`、`deleteTagDefinitions(tags: string[])`、`pause(hashes)`、`resume(hashes)`、`setLocation(hash, path)`、`recheck(hashes)` | Q8-Q10/Q15/Q20 | +| 同上 | `deleteTorrents(hashes: string[], deleteFiles: boolean): Promise`——**adapter 层不暴露裸 deleteFiles=true**,由 `packages/tasks` 的 cleanupDownloadCache 在通过 `assertSafeToDeleteData` 后传入 | Q16/Q25 | +| `packages/adapters/src/qbittorrent/submit.ts` | `submitTorrent(inst: QbClient, req: SubmitRequest): Promise`;`SubmitOutcome = { kind: 'added'; hash: string } \| { kind: 'already_exists'; hash: string; existingSavePath: string; existingTags: string[] } \| { kind: 'accepted_hash_unknown'; probeTag: string } \| { kind: 'rejected'; reason: string } \| { kind: 'unreachable' }` | Q21 步骤 1-7 | +| `packages/adapters/src/qbittorrent/state.ts` | `mapQbState(raw: string): QbObservedState`;`isDownloadComplete(t: QbTorrent): boolean` | Q6/Q23 | +| `packages/adapters/src/qbittorrent/paths.ts` | `toDownloaderPath(p: string, map: PathMapping[]): string`;`fromDownloaderPath(p: string, map: PathMapping[]): string` | Q27 | +| `packages/domain/src/download/file-selection.ts` | `selectTorrentFiles(files: TorrentFileEntry[], want: EpisodeSelection, parse: (name: string) => ParsedRelease): FileSelectionPlan`,`FileSelectionPlan = { include: number[]; exclude: number[]; coveredEpisodes: number[]; unmatched: string[]; decision: 'select_subset' \| 'download_all' \| 'no_match' }` | Q21 步骤 8-9 + `get_torrent_episodes` | +| `packages/domain/src/download/delete-guard.ts` | `assertSafeToDeleteData(input: DeleteGuardInput): DeleteGuardVerdict`(见 §6 保留/调整) | 1.6 | +| `packages/testkit/src/mock-qb/` | `createMockQb(opts: { version: '4.6.7' \| '5.1.0'; apiKey?: string; addLatencyMs?: number; dropAddResponse?: boolean }): MockQbServer` | 验证样本 | + +### 1.8 保留行为 + +| 编号 | 保留的上游行为 | 依据 | +| --- | --- | --- | +| QK1 | 需要选文件时以 **paused** 方式添加,选完再 resume | Q21 步骤 4、10 | +| QK2 | 添加后 hash 的获取顺序:① 本地解析 .torrent 算 infohash(本项目新增,最可靠)② 5.x 返回的 `added_torrent_ids` ③ 按唯一探针标签轮询(3s×9);探针标签用完即删,并删除全局标签定义 | Q11/Q13 | +| QK3 | 添加失败时先查询 qB 判断是否已存在同种子,走"已存在"分支而非直接报失败 | Q21 步骤 6a;与 REQ §8.4"收单超时先查询 qB 实际任务" 一致 | +| QK4 | 完成判定需同时满足 `progress>=1 && amount_left<=0 && state ∉ 非终态集合` | Q6 | +| QK5 | 多集种子必须整种下载完成后才能打"已整理"/结束下载侧管理(#6009) | settlement.py:620 | +| QK6 | move 删种前逐文件检查"仍存在未屏蔽的媒体文件 ⇒ 不删" | history.py:305 | +| QK7 | 下载器路径 ↔ 本地存储路径双向前缀映射,首条命中 | Q27 | +| QK8 | 支持 API Key(Bearer)与用户名密码两种认证;401/403/LoginFailed 归类为凭据错误 | Q2 | +| QK9 | Web API ≥ 2.11.3 才使用 Cookie 同步 API | Q14 | + +### 1.9 调整项与原因 + +| 编号 | 上游行为 | 本项目调整 | 原因 | +| --- | --- | --- | --- | +| QA1 | 已存在判定用 `name + total_size` | 用 **infohash(v1,且记录 v2)** 精确比对;名称+大小只作提示 | REQ §8.4:业务关联用实例 ID + torrent 标识并保留 v1/v2 hash;同名同大小不同种子会误判 | +| QA2 | 已存在时移除 `已整理`、补打 MP 标签 | 发现已存在 → 走**接管与冲突检查**:不覆盖其 save_path、其他标签、文件选择;是否打管理标签取决于接管授权 | REQ §8.4"不覆盖其保存路径、其他标签或文件选择" | +| QA3 | `list_torrents` 默认只看带 `TORRENT_TAG` 的任务,按 hash 查也受限 | 管理标签限定**日常自动化**范围;按 hash 查询、用户委托调查不受标签限制 | REQ §8.4"标签范围与用户主动委托" | +| QA4 | 标签在客户端对全量任务过滤 | 使用 qB API `tag=` 参数;大实例避免全量拉取 | 性能;REQ §9.4 | +| QA5 | `__normalize_torrent_state` 兜底为 completed | 未识别/`error`/`missingFiles` 独立状态,绝不当完成 | 防止误触发入库/清理 | +| QA6 | 登录时改写全局 `incomplete_files_ext` | **不改写下载器全局偏好**;读取当前值记录到实例能力快照;入库只处理 Q6 判定为完成的任务的文件,并排除 `.!qB` 后缀 | 下载器可能被其他程序共用;全局设置属于用户资产 | +| QA7 | layout 取全局 `torrent_content_layout` | 提交时显式传 `contentLayout`(默认 `Original`);入库时以 `content_path` + 文件列表实际计算,不依赖全局偏好 | 全局偏好可能在任务创建后被改;接管任务的布局未知 | +| QA8 | `add_torrent` 异常即失败 | 异常/超时 → `accepted_unknown` 状态,由 submitDownload 任务先按 infohash 查询再决定重试(幂等键 = 实例ID+infohash) | REQ §8.4、§11.2 幂等 | +| QA9 | 选文件:一个集都没匹配上则整种下载 | 返回 `no_match` 决策;在 `acquisition_plan` 中显式记录"整种下载"并需计划允许(或进入待处理) | 避免把错误识别的整季包整包拉下占满缓存;REQ §8.4.1 | +| QA10 | 选文件时非视频文件(字幕、nfo)因无集号全被排除 | 附属文件按"随行规则"(同目录/同 stem 的字幕、音轨)保留下载;纯样本/截图排除 | 字幕随行是入库需求(REQ §14.1.3 附属文件) | +| QA11 | `remove_torrents` 默认 `delete_file=True`,只检查媒体文件是否仍存在 | 删数据必须通过 `assertSafeToDeleteData`:①下载器可达且 hash 存在;②该任务所有文件均有终态 `organization_file_result`(入库已验证或明确跳过);③`save_path`/`content_path` 不是共享保存根(内容路径必须是种子专属目录或单文件);④没有其他 qB 任务(含非纳管)的 content_path 与之重叠;⑤没有媒体库资产直接指向该路径(媒体库直接做种);⑥H&R/保留时长/分享率要求已满足或不存在;任一条件未知 → 拒绝 | REQ §8.3 全段 | +| QA12 | move 模式整理成功即删种(含数据) | Move:入库验证 + 数据库登记完成后,**仅删除 qB 任务记录**(`deleteFiles=false` 或由我们自己的文件层清理已验证路径);有 H&R 义务时不 Move | REQ §8.3 Move 段 | +| QA13 | 随机 10 位标签 | 探针标签带前缀 `uhd-probe-`,并在 `asset_operation` 账本记录,崩溃恢复时可清理残留 | 可追溯、可恢复 | +| QA14 | `get_instance` 失败返回裸 None | 统一返回判别联合 `unreachable` | TS 类型安全 | + +### 1.10 qB 验证样本 + +| 样本 | 输入 | 期望输出 | +| --- | --- | --- | +| QV1 5.x 正常添加 | `.torrent` 单文件 `Oppenheimer.2023.2160p.UHD.BluRay.REMUX.HDR.HEVC.DTS-HD.MA.5.1-FGT.mkv`,mock 返回 `{"success_count":1,"added_torrent_ids":["3f5a…c9"]}` | `added`,hash 与本地计算 infohash 一致,探针标签不需要 | +| QV2 4.x 正常添加 | 同上,mock 返回纯文本 `Ok.` | 本地 infohash 命中 → `added`;不轮询 | +| QV3 磁力 + 4.x | `magnet:?xt=urn:btih:…` 返回 `Ok.`,前 2 次 `torrents/info?tag=uhd-probe-…` 为空,第 3 次返回 | `added`,轮询 3 次,探针标签被 remove + deleteTags | +| QV4 响应丢失 | mock `dropAddResponse=true`(qB 已加入但连接中断) | `accepted_unknown` → 按 infohash 查询命中 → `added`,不重复提交 | +| QV5 已存在 | 已有同 infohash 任务,save_path=`/downloads/pt`,tags=`BT,keep`,文件优先级已部分关闭 | `already_exists`,不修改 save_path/tags/文件选择,进入接管冲突检查 | +| QV6 同名同大小不同种 | 名称 `The.Bear.S03.1080p.DSNP.WEB-DL.DDP5.1.H.264-NTb`、大小相同但 infohash 不同 | 不视为已存在(上游会误判) | +| QV7 整季包选集 | 文件:`The.Bear.S03E01.1080p….mkv` … `S03E10`、`The.Bear.S03E01.1080p….chs.ass`、`Sample/sample.mkv`;需要集 {4,5} | include = E04/E05 视频 + 同 stem 字幕;exclude 其余;paused 添加 → setFilePriority(0) → resume | +| QV8 无匹配 | 需要 {11},种子只有 E01-E10 | `no_match`,不整种下载,计划进入待处理 | +| QV9 状态映射 | `stalledDL`/`pausedUP`/`error`/`missingFiles`/`checkingUP`/`moving`/`forcedUP` | `stalled`/`completed_paused`/`error`/`missing_files`/`checking`/`moving`/`seeding`;只有 pausedUP、forcedUP(且 progress=1、amount_left=0)判完成 | +| QV10 部分完成多集种 | 种子 E01-E10,E01-E03 文件已完成,progress=0.3 | 可对已完成文件排入整理(文件级),但不得结束下载侧管理/打完成标记 | +| QV11 共享保存根 | 单文件种 content_path=`/downloads/pt/Movie.mkv`,同目录还有其他任务的文件 | 删数据守卫:只能删该文件,不能递归删 `/downloads/pt` | +| QV12 凭据错误 | mock 对 `/api/v2/auth/login` 返回 `Fails.` / API Key 返回 401 | `QbAuthResult.kind = 'bad_credentials'`,实例健康置为验证失败,不重试风暴 | + +--- + +## 2. 下载链:整季包与多文件选择 + +### 2.1 上游调用链 + +| # | 上游文件 : 行 | 函数 | 作用 | +| --- | --- | --- | --- | +| D1 | MP `app/chain/download/batch.py:32` | `DownloadBatchOwner.batch_download(contexts, no_exists, save_path, …)` | 公开入口,委托 `_execute_batch_download` | +| D2 | 同上 `:65` | `_execute_batch_download` | 三段式组合择优:整季 → 季内单集 → 整季包中挑集 | +| D3 | MP `app/application/download/selection.py:16/41/72/90/106/123/132` | `update_no_exists_seasons` / `update_no_exists_episodes` / `get_season_episodes` / `get_no_exist_media` / `get_required_episodes` / `requires_complete_coverage` / `apply_allowed_episodes` | 缺集账本与覆盖判定(纯函数) | +| D4 | MP `app/application/torrent/download.py:432` | `TorrentHelper.get_torrent_episodes(files, custom_words)` | 只对视频扩展名文件、**只用文件名**(不含目录)做 `MetaInfo`,有 `begin_episode` 才计入,返回集号并集 | +| D5 | MP `app/chain/download/submission.py:155` | `download_torrent(torrent)` | 取 .torrent:磁力原样返回;`[base64]url` 间接下载先换票(不带 cookie);返回 (content, 目录名, 文件清单) | +| D6 | 同上 `:65` | `_resolve_indirect_download_url` | 解码 `[base64(json)]url`,按 json 中 method/header/params/proxy/result 调接口取临时直链,结果 host 约束到 `result_base_url` | +| D7 | 同上 `:261`/`:295` | `download_single` / `_execute_download_single` | 解析下载目录与分类、调下载器 `download()`、登记 DownloadHistory、失败通知 | +| D8 | MP `app/chain/download/existence.py:25` | `get_no_exists_info(meta, mediainfo, no_exists, totals)` | 查媒体库已有季集,生成 `no_exists` 缺失账本 | + +### 2.2 `_execute_batch_download` 分支(D2) + +| 阶段 | 行(约) | 候选条件 | 下载前核验 | 成功后账本更新 | +| --- | --- | --- | --- | --- | +| 0 电影/音乐 | ~170-190 | 非剧集按 media key 去重下载 | — | 加入 `downloaded_list` | +| 1 整季匹配 | ~191-330 | TV;`no_exists` 中 `episodes` 为空的季(全季缺失);种子**无集号**(`meta.episode_list` 空);种子季集 ⊆ 需要季;无季号默认第 1 季 | 若种子只标一季:**下载 .torrent 解析文件集数**(D4);磁力无法核验 → 跳过;`require_complete_coverage` 时必须覆盖目标集合每一集,否则按数量:`len(episodes) < 总集数` → 放弃("不是完整合集") ;多季包直接下载不核验 | `update_no_exists_seasons` 移除已满足季;`confirmed_full_coverage` | +| 2 季内单集 | ~333-420 | 单季且季号一致、**有集号**的种子;`effective_need = need ∩ allowed_episodes` | 完整覆盖任务:候选集 ⊇ 目标集合(允许多余集);普通:候选集 ⊆ 需要集 | `update_no_exists_episodes` 扣减 | +| 3 整季包挑集 | ~421-540 | 仍缺集且非完整覆盖任务;单季、季号一致;种子无集号或与需要集有交集 | 下载 .torrent 解析文件集号;`selected = torrent_episodes ∩ effective_need`,空则跳过;磁力跳过 | 调 `download_single(episodes=selected)` → qB 暂停添加+选文件(§1.3);扣减缺集 | +| 通用 | — | 失败冷却:`__is_context_in_failure_cooldown` / `__remember_context_failure` 同一候选失败后本轮不再尝试 | — | 返回 `(downloaded_list, no_exists 剩余)` | + +### 2.3 保留 / 调整 + +| 类别 | 内容 | 原因 | +| --- | --- | --- | +| 保留 | 下载前打开 .torrent 用文件名核验"声称整季"的实际集数;磁力无法核验时不作为整季候选 | 防止命名错误的整季包 | +| 保留 | 完整覆盖语义:按**集合覆盖**判断,不按数量;允许包含范围外额外集 | selection.py:106/123 | +| 保留 | 三段优先级:整季 > 单集 > 整季包挑集 | 与上游择优一致 | +| 保留 | 集号识别只用文件名,不用目录名 | D4;目录常为整季名,会污染 | +| 调整 | 上游"只标一季 + 集数 ≥ TMDB 总集数"才视为完整;TMDB 缺集时会误判 | REQ §14.1.3 "TMDB 缺集" 场景:总集数来源需带置信度,本地集账本优先(REQ §5.4) | +| 调整 | 上游阶段 1 多季包不核验直接下载 | 多季包同样核验文件清单(可取得 .torrent 时) | +| 调整 | 综艺期号/日期集号:上游 `MetaInfo` 解析 `20240315` 类文件名基本得不到 `begin_episode`,阶段 3 会跳过 | v0.1 不做综艺分段(任务书 §3.2);`selectTorrentFiles` 对无法解析集号的视频返回 `unmatched`,不静默排除 | +| 调整 | 选片决策属于 `evaluateAcquisition`(确定性)+ `acquisition_slot`;下载链只执行计划 | 任务书 §3.1 第 5 条;Slot 多目标 | +| 调整 | `[base64]url` 把 **API Key 放进 enclosure 字符串**(见 §4.4) | 本项目 enclosure 只存站点+资源 ID,下载时由 adapter 按 `secret_ref` 取凭据 | + +TS 建议:`packages/domain/src/acquisition/season-pack.ts` +- `evaluateSeasonPackCandidate(input: { claimed: { seasons: number[]; episodes: number[] }; fileEpisodes: number[] | null /* null=磁力无法核验 */; need: SeasonNeed; }): SeasonPackVerdict` +- `type SeasonNeed = { season: number; requiredEpisodes: Set; totalEpisodes: { value: number; confidence: 'local_ledger' | 'tmdb' | 'unknown' }; requireCompleteCoverage: boolean; allowedEpisodes?: Set }` +- `type SeasonPackVerdict = { kind: 'full_season' } | { kind: 'select_episodes'; episodes: number[] } | { kind: 'reject'; reason: 'unverifiable_magnet' | 'coverage_gap' | 'fewer_than_total' | 'no_needed_episode'; missing?: number[] }` + +验证样本: + +| 样本 | 输入 | 期望 | +| --- | --- | --- | +| DV1 | 标题 `Severance.S02.2160p.ATVP.WEB-DL.DDP5.1.Atmos.DV.HDR.H.265-FLUX`,文件 E01-E10,缺失整季 S02(TMDB 10 集) | `full_season` | +| DV2 | 标题 `Severance.S02.1080p…`(无集号)但文件仅 E01-E06 | `reject: fewer_than_total`(非完整覆盖任务)/`coverage_gap missing=[7..10]`(完整覆盖任务) | +| DV3 | 缺 S02E07-E10,候选为 S02 整季包 E01-E10 | 阶段 3:`select_episodes [7,8,9,10]` | +| DV4 | 磁力 `magnet:?xt=urn:btih:…`,标题 `Some.Show.S01.1080p.WEB-DL` | `reject: unverifiable_magnet` | +| DV5 | 多季包 `Friends.S01-S10.1080p.BluRay.x265-RARBG` | 需下载 .torrent 核验每季文件集号(上游直接下) | +| DV6 | 综艺 `奔跑吧.第十二季.20240426.2160p.WEB-DL.H265.AAC-LelveTV`,文件名只有日期 | v0.1:`unmatched` 列出,不选择;标记待 M2 综艺账本 | + +--- + +## 3. 站点抓取底座(MoviePilot) + +### 3.1 上游结构总览 + +| # | 上游文件 : 行 | 符号 | 说明 | +| --- | --- | --- | --- | +| S1 | MP `app/application/site/sites.pyi` | `SitesHelper.get_indexers()` / `get_indexer(domain)` / `check(domain)` / `get_authsites()` | **Cython 闭源**:站点 indexer 配置由加密资源 `user.sites.v3.bin` 提供;`check(domain)` 是站点流控 | +| S2 | MP `app/modules/indexer/__init__.py:376` | `IndexerModule.search_torrents(site, keywords, mtype, cat, page)` | 搜索入口:`__create_search_request` → `__execute_search` → `__parse_result` → 健康统计 | +| S3 | 同上 `:150` | `__search_check(site, word)` | `site.language == "en"` 且关键词含中文 → 不搜;`SitesHelper().check(domain)` 流控命中 → 不搜 | +| S4 | 同上 `:171` | `__clear_search_text` | 去标点(替换为空格) | +| S5 | 同上 `:29` / `:40` | `SPIDER_PARSER_CLASSES` / `_SPECIALIZED_SEARCH_ARGUMENTS` | `site.parser` 取值 `mTorrent`/`TNodeSpider`/`TorrentLeech`/`Yema`/`Haidan`/`HDDolby`/`RousiPro`/`SunnyPT` 走专用 API 类;否则走通用 `SiteSpider` | +| S6 | 同上 `:183` | `__indexer_statistic` | 成功记耗时、失败计数 → `site_health_service` | +| S7 | 同上 `:211` | `__parse_result` | dict → `TorrentInfo`;`imdbid` 转 `media_source/media_id`;附带 site id/name/cookie/ua/proxy/pri/downloader | +| S8 | 同上 `:626` | `refresh_torrents(site, keyword, cat, page)` | RSS 式浏览刷新(无关键词) | +| S9 | 同上 `:662` | `refresh_userdata(site)` | 用户数据(上传/下载/分享率/做种)→ `parser/*` | +| S10 | MP `app/modules/indexer/spider/__init__.py:62` | `class SiteSpider` | 通用配置驱动 HTML 爬取 | +| S11 | MP `app/domain/context.py` `class TorrentInfo` | 字段见 3.4 | 统一候选结构 | + +### 3.2 `SiteSpider` indexer 配置结构(由 `__init__:84` 与 `tests/test_rust_accel.py:426-480` 推出) + +| 配置键 | 类型/示例 | 用途 | +| --- | --- | --- | +| `id` / `name` / `domain` | `"unit"` / `"Unit"` / `"https://example.com/"` | 身份与基址(domain 自动补 `/`) | +| `search.paths[]` | `[{path: "torrents.php", type: "all"\|"movie"\|"tv"\|"music"}]` | 按媒体类型选路径,无专用则回退 `all`(`__get_search_url:171`) | +| `search.params` | `{search: "{keyword}", search_area: 4}` | 额外查询参数模板;`search_area` 仅在关键词是 `tt\d+` 时保留(IMDb 搜索) | +| `batch` | `{delimiter: " ", space_replace: "_"}` | 多关键词批量搜索(search_mode=1 "或") | +| `browse` | `{path: "torrents.php?page={page}", start: 0, list, fields}` | 无关键词浏览;`start` 为起始页偏移 | +| `category` | `{movie: [{id: 401, cat, desc}], tv: [...], field?: "cat", delimiter?: ",", param?: "cat[]"}` | 分类参数三种写法:`field` 拼接、`param` 重复参数(如 HHClub `cat[]`)、默认 `cat=1` | +| `torrents.list.selector` | `'table.torrents > tr:has("table.torrentname")'` | 行选择器 | +| `torrents.fields.` | `{selector, attribute?, index?, contents?, remove?, filters?[], text?(Jinja), case?{css: value}}` | 字段提取 DSL | +| `result_num` / `timeout` / `ua` / `proxy` / `cookie` / `language` / `parser` / `media_type` | — | 每页上限、超时(默认 15s)等 | +| 默认查询参数 | `search_mode`(0 与/1 或)、`search_area`(0 标题)、`page`、`notnewword=1` | NexusPHP 标准 | + +### 3.3 字段提取 DSL(`SiteSpider` 私有方法) + +| 字段 | 方法 : 行 | 规则 | +| --- | --- | --- | +| title | `__get_title:405` | `selector` 直接取;或 `text` Jinja 模板,可引用 `title_default`/`title_optional`(NexusPHP 典型:优先 `a[title]` 的 title 属性,避免链接文字被截断) | +| description(副标题) | `__get_description:427` | `selector`/`selectors`,或 `text` 模板引用 `tags`/`subject`/`description_free_forever`/`description_normal`;`remove: "span,a"` 去掉内嵌标签元素 | +| details → `page_url` | `__get_detail:461` | 相对链接补全:`//` 开头补协议,`/` 开头拼 domain | +| download → `enclosure` | `__get_download:479` | 同上,磁力原样 | +| grabs / leechers(→`peers`)/ seeders | `:577` / `:549` / `:563` | 取 `/` 前部分、去 `,`、过滤后非纯数字则 0 | +| size | `__get_size:535` | 去换行后 `size_tools.parse_size`(如 `1.50 GB` → 字节);空 → 0 | +| imdbid | `__get_imdbid:527` | 通常 `re_search tt\d+` | +| downloadvolumefactor / uploadvolumefactor | `:703` / `:727` | 有配置则先置 1;`case: {css: 值}` 按声明顺序首个命中生效(支持 `"*"` 兜底);或 `selector` 抽数字——**上游用 `int()` 转换,`0.5` 会抛异常被 get_info 吞掉导致整条记录丢失** | +| date_added / date / date_elapsed | `__get_pubdate:591` / `:695` | `date_added` 优先(通常为 `span[title]` 的精确时间),否则 `date` 模板 + `dateparse` | +| freedate | `__get_free_date:773` | 促销截止时间文本 | +| labels | `__get_labels:751` | 多值列表 | +| hr → `hit_and_run` | `__get_hit_and_run:781` | 选择器命中 → True;**配置了但未命中 → False;未配置 → 字段缺省 False(TorrentInfo 默认值)** | +| category | `__get_category:796` | 取站点分类值后 `resolve_category_media_type` **投影为 电影/电视剧/音乐/未知**;未配置用站点 `media_type` | +| 过滤器 | `__filter_text:975` | `re_search [pattern, group]`、`split [sep, idx]`、`replace [a, b]`、`dateparse fmt`、`strip`、`appendleft s`、`querystring key` | +| 取值通用 | `_safe_query:872` / `__attribute_or_text:1024` / `__index:1039` | `attribute` 取属性否则文本;`index`/`contents` 选第 N 个 | +| 登录/权限页检测 | `__is_login_or_permission_page:1059` | `` 为"登录"或含":: 登录";正文前 1000 字含"未登录"/"登录 / 注册"/"必须在登录后才能访问"/"你需要启用cookies才能登录" → `is_error=True`,返回空 | +| 解析入口 | `parse:1077` | 优先 Rust 扩展 `rust_accel.parse_indexer_torrents`,不可用回退 PyQuery;最多 `result_num` 条 | + +### 3.4 `TorrentInfo` 候选字段(MP `app/domain/context.py`) + +`site, site_name, site_cookie, site_ua, site_proxy, site_order, site_downloader, title, description, media_source, media_id, enclosure, page_url, size, seeders, peers, grabs, pubdate, date_elapsed, freedate, uploadvolumefactor, downloadvolumefactor, hit_and_run(默认 False), labels, pri_order, category` + +> 注意:MP 候选对象携带 `site_cookie`,会随上下文流转。本项目 `source_release` 不得持有凭据,只保存 `site_account_id`。 + +### 3.5 NexusPHP 通用选择器对照(MP 测试样例 vs MovieClaw 默认) + +| 字段 | MP 样例配置(`tests/test_rust_accel.py:426`) | MC 默认(`MC:src/movieclaw_tracker/selectors.py:39` `NexusPHPSelectors`) | 本项目建议默认 | +| --- | --- | --- | --- | +| 行 | `table.torrents > tr:has("table.torrentname")` | `table.torrents > tr:not(:first-child)` | MP 写法(排除表头与嵌套行更稳) | +| 标题 | `a[title][href*="details.php?id="]@title` 优先,回退链接文字 | `a[href*='details.php']` 的 `title` 属性优先,回退文字 | 同 | +| 副标题 | `font.subtitle`(remove `span,a`) | `td.embedded > span` | 站点覆盖(观众:`span.torrent-subtitle-line`) | +| 详情/ID | `a[href*="details.php?id="]@href` | 同,正则取 `id` | 同,保存 `site_torrent_id` | +| 下载 | `a[href*="download.php?id="]@href` | `a[href*='download.php']@href` | 同 | +| 分类 | `a[href*="?cat="]@href` + `querystring cat` | `td:nth-child(1) img@class` | **保留原始分类 ID**(REQ §4.5),不投影 | +| 发布时间 | `td:nth-child(4) > span@title`(精确),回退 `span` 文本(相对时间) | 同(`torrent_time_css`/`_fallback_css`,fmt `%Y-%m-%d %H:%M:%S`) | 同;按站点时区转 UTC(MC 有 `timezone` 配置,默认站点时区) | +| 大小 | `td:nth-child(5)` | 同 | 同 | +| 做种/下载/完成 | `td:nth-child(6/7/8)` | 同 | 同(列号站点可覆盖) | +| 发布者 | — | `td:nth-child(9) a` | 同 MC | +| 下载系数 | `case {img.free: 0, "*": 1}` | 有序表 `img.pro_free→0, img.pro_free2up→0, img.pro_50pctdown→0.5, img.pro_50pctdown2up→0.5, img.pro_30pctdown→0.3` | MC 有序表 + 支持小数 | +| 上传系数 | `case {"*": 1}` | `img.pro_free2up→2, img.pro_50pctdown2up→2, img.pro_2up→2` | 同 MC | +| 促销截止 | `freedate` 字段(站点配置) | `torrent_promo_deadline_css` + `title` 属性 + 正则 `\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}`,仅在 dl<1 时解析 | 同 MC | +| H&R | `img.hitandrun` → bool | `torrent_hr_css` 默认空 ⇒ **None(未知)** | **三态**,同 MC | +| 登录失效 | 登录/权限页文本检测 | `login_success_css: a[href*='logout']` | 两者都用 | +| 总页数 | — | 扫描 `a[href*='page=']` 取最大 page | 同 MC,并标记"估计值" | + +### 3.6 MP 用户数据解析(`app/modules/indexer/parser/*`,站点健康/账号面板参考) + +| 上游 | 函数 : 行 | 行为 | +| --- | --- | --- | +| `parser/__init__.py:43` | `SiteParserBase.parse:186` | 抓首页 → 基础/详情/流量页 → 做种分页 → 未读消息 | +| 同上 `:136-163` | 默认页 | `userdetails.php?id=`、`index.php`、`messages.php?action=viewmailbox&box=1&unread=yes`、`getusertorrentlistajax.php?userid=` | +| `parser/nexus_php.py:96` | `_parse_user_traffic_info` | 正则抓"上传量/下载量/分享率/下载中",分享率页面值优先,否则计算 | +| 同上 `:170` | `_parse_user_torrent_seeding_info` | 按表头定位大小/做种列,累加做种体积,翻页 | +| `parser/nexus_audiences.py:16` | `NexusAudiencesSiteUserInfo` | 观众新版顶栏 userbar:`__parse_userbar_info:349`、`__parse_compact_metric:412`;未读私信第一页**不能带 page 参数**(`__build_unread_mailbox_page:318`) | +| `parser/mtorrent.py:37` | `_parse_site_page` | M-Team 用户数据走 API:`api/member/profile`、`api/msg/search`、`api/member/getUserTorrentList`、`api/tracker/myPeerStatus`,header `x-api-key` | + +v0.1 只需要"登录有效性 + 基本上传/下载/分享率"用于来源健康卡片;私信读取不做。 + +### 3.7 站点底座调整项 + +| 编号 | 上游 | 本项目 | 原因 | +| --- | --- | --- | --- | +| SA1 | 站点定义在闭源加密资源中,随资源包更新 | 站点定义随产品发布的 **Adapter 注册表**(TS 常量 + 可覆盖 JSON/YAML),版本号入 `site_snapshot` | REQ §4.5;可审计;闭源资源无法对照 | +| SA2 | 分类投影成 电影/电视剧 | 保存 `site_category_id` 原值 + 站点分类名;应用级分类只作检索辅助 | REQ §4.5"不能将综艺等不可逆地压成 tv" | +| SA3 | H&R 缺省 False | 三态 `true/false/unknown`;unknown 在清理/Move 判定中按"有义务"保守处理 | REQ §8.3 | +| SA4 | 促销 `selector` 模式 `int()` 截断 | `parseFloat`,解析失败该字段置 null,不丢整条 | 数据完整性 | +| SA5 | 候选携带 cookie | 凭据只在 adapter 内部通过 `secret_ref` 取;日志/快照/模型上下文不含 cookie、passkey、apikey | 任务书约定"含 passkey 的 URL 不得进入日志" | +| SA6 | `SitesHelper.check` 闭源流控 | `packages/adapters/src/sites/rate-limit.ts` 站点级令牌桶 + 最小间隔(MC `min_request_interval`,默认 3s,M-Team 1s) | REQ §4.3/§4.6 | +| SA7 | 登录页检测失败只记 `is_error` | 区分 `auth_expired`(登录页/权限页)、`parse_error`(结构变化)、`network_error`;auth 类立即置账号验证失败,parse 类累计多次再熔断(MC `_AUTH_ERROR_KEYWORDS` 思路) | REQ §4.3 退避/熔断 | +| SA8 | 英文站不搜中文 | 保留(`site.language`) | — | + +--- + +## 4. M-Team API 模式 + +### 4.1 MP `app/modules/indexer/spider/mtorrent.py` + +| 项 | 行 | 上游行为 | +| --- | --- | --- | +| 端点 | `:29-33` | `POST https://api.<domain>/api/torrent/search`、`POST …/api/torrent/genDlToken`、字幕 `…/api/subtitle/list`、`…/api/subtitle/genlink`、`…/api/subtitle/dlV2?credential=` | +| 认证 | `search:182` | header `x-api-key: <apikey>`、`Content-Type: application/json`、`User-Agent`;无 apikey 直接返回 `(True, [])`(错误) | +| 搜索参数 | `__get_params:85` | `{keyword, categories, pageNumber: page+1, pageSize: 100, visible: 1}`;关键词 `tt…` 改写为完整 IMDb URL `https://www.imdb.com/title/tt…` | +| 分类 | `:38-42` | movie `401,419,420,421,439,405,404`;tv `403,402,435,438,404,405`;music `434,406`(**404 纪录、405 动画同时属于 movie 与 tv**) | +| 结果映射 | `__parse_result:108` | `name→title`、`smallDescr→description`、`size`、`status.seeders/leechers/timesCompleted`、`createdDate→pubdate`、`imdb`→tt、`labelsNew`(新)或 `labels` 位图(旧,`_labels:47`:1 DIY/2 国配/4 中字 组合)、`page_url = <domain>detail/<id>` | +| 促销 | `:242` / `:258` | 下载:`FREE 0, PERCENT_50 0.5, PERCENT_70 0.3, _2X_FREE 0, _2X_PERCENT_50 0.5`;上传:`_2X/_2X_FREE/_2X_PERCENT_50 → 2`;`discountEndTime → freedate`;全站 `promotionRule.discount/endTime` 覆盖下载系数;`mallSingleFree.status == "ONGOING"` → 下载系数 0 并用 `endDate` | +| 响应判定 | `__process_response:171` | 仅看 HTTP 200,取 `data.data`;**不检查业务 `code/message`** | +| 下载链接 | `__get_download_url:271` | 生成 `"[" + base64(json{method:post, cookie:false, params:{id}, header:{UA, Accept, x-api-key}, proxy, result:"data"}) + "]" + genDlToken URL`,由 `submission.py:65 _resolve_indirect_download_url` 解码后 POST(`id` 作为 query params),取 `data` 字段为临时直链,再 GET .torrent | +| H&R | — | 搜索结果未映射 H&R | + +### 4.2 MovieClaw `src/movieclaw_tracker/sites/custom/mteam.py`(提交 fc9eb72) + +| 项 | 行 | 行为 | +| --- | --- | --- | +| 类 | `:116` `MTeamSite(BaseSite)` | 由 `mteam.yaml` 的 `custom_class` 装配 | +| 统一调用 | `_post_api:148` | POST;响应 `{"code":"0","message":"SUCCESS","data":…}`;非 JSON → `TrackerParseError`;`message ∉ {SUCCESS, OK, 0, ""}` → 命中 `_AUTH_ERROR_KEYWORDS`(停用/封禁/禁用/banned…)抛 `TrackerAuthError`,否则 `TrackerParseError`;返回 `data` | +| 促销表 | `_DISCOUNT_MAP`(文件头) | `NORMAL 1, FREE 0, PERCENT_70 0.3, PERCENT_50 0.5, _2X_FREE 0, _2X_PERCENT_50 0.5, _2X 1`;上传 `_UPLOAD_2X_DISCOUNTS = {_2X,_2X_FREE,_2X_PERCENT_50} → 2.0` | +| 商城单种免费 | `_mall_free_override:196` | `status.mallSingleFree.status == "ONGOING"` → 下载系数 0,截止取 `endDate`,否则保留 `discountEndTime`;注释:`discount` 字段此时保持原值(如 PERCENT_50) | +| 行映射 | `_parse_torrent:224` | `torrent_id=id`、`title=name`、`subtitle=smallDescr`、`site_category_id=category`、`size_bytes`、`seeders/leechers/snatched`、`upload_time=createdDate`(格式 `%Y-%m-%d %H:%M:%S`,站点时区)、`imageList`、`free`、`free_deadline`、`download_url=torrent_id`(**只存 ID**)、`detail_url = web_base_url/detail/<id>`;单条失败跳过不中断整页 | +| 单次搜索 | `_search_once:278` | `POST /api/torrent/search` JSON `{categories, keyword, pageNumber: page(1 起), pageSize: 100, visible: 1}`,可加 `mode: "adult"`、`discount: "FREE"`;返回 `data.data` 与 `totalPages` | +| 拆分搜索 | `_search:315` | 普通与成人(`mode=adult`)两个索引分别请求再合并;若请求覆盖全部非成人分类则 `categories=[]`(不过滤) | +| 列表/搜索 | `list_torrents:363` / `search:378` | 未指定分类时默认全部(含成人) | +| 详情 | `get_torrent_detail:386` | 从 URL/ID 取数字 ID;`POST /api/torrent/detail` 表单 `id`;额外 `descr`、`imdb` | +| 下载 | `download_torrent:445` | `POST /api/torrent/genDlToken` 表单 `{id}` → `data` 为一次性直链 → GET 字节;无 data → "可能未认证或达到下载频率限制" | +| 用户 | `get_user_profile:460` | `POST /api/member/profile`(`memberCount.uploaded/downloaded/shareRate/bonus`、`id/username/role`),`POST /api/tracker/myPeerStatus`(`seeder/leecher`) | + +### 4.3 `MC:src/movieclaw_tracker/sites/configs/mteam.yaml` 字段结构 + +| 键 | 值 | +| --- | --- | +| `site_id` / `display_name` | `mteam` / `M-Team` | +| `base_url` | `https://api.m-team.cc`(API 域) | +| `web_base_url` | `https://tp.m-team.cc`(用户可打开的详情页域) | +| `framework` / `custom_class` | `api` / `movieclaw_tracker.sites.custom.mteam.MTeamSite` | +| `min_request_interval` | `1.0`(默认 3s) | +| `auth.supported` | `[apikey]` | +| `categories` | movie `[401,419,420,421,439]`;tv `[403,402,435,438,407]`(407 体育);documentary `[404]`;anime `[405]`;music `[406,408,434]`;game `[423]`;other `[422,427,409]`;av `[410,429,424,430,426,437,431,432,436,425,433,411,412,413]` | + +### 4.4 M-Team 对照结论 + +| 编号 | 决定 | 依据 | +| --- | --- | --- | +| MT1 | 认证只用 API Key,header `x-api-key`;凭据经 `secret_ref` | MP/MC 一致;REQ §4.5"优先 API Key" | +| MT2 | 业务状态必须检查 `message`/`code`,并把凭据类错误与解析类错误分开 | MC `_post_api:148`;MP 未检查(缺陷) | +| MT3 | 下载:候选只保存 `site_torrent_id`;下载时现场 `genDlToken` 换一次性直链再 GET;直链不入库不入日志 | MC `download_torrent:445`;MP 把 apikey 编进 enclosure(`__get_download_url:271`),**必须偏离** | +| MT4 | 分类用 MC 的细分映射并保存原始 ID;404/405 不重复计入 movie+tv | MC `mteam.yaml`;REQ §4.5 | +| MT5 | 促销:基础 `discount` 表 + 全站 `promotionRule`(MP)+ `mallSingleFree`(MP/MC 都有);三者优先级:`mallSingleFree ONGOING` > `promotionRule` > `discount` | 合并 MP `:155-167` 与 MC `:196` | +| MT6 | 成人索引 `mode=adult` 默认**不请求**(本项目无此需求),保留参数位 | MC `_search:315` | +| MT7 | 分页:对外 1 起;MP 内部 0 起 `+1` | MP `:85`、MC `:278` | +| MT8 | IMDb 搜索时关键词改写为完整 IMDb URL | MP `__get_params:85`(引 wiki) | +| MT9 | H&R:API 搜索未提供 → `unknown` | 两者均未映射 | + +--- + +## 5. MovieClaw 观众(Audiences)配置 + +### 5.1 `MC:src/movieclaw_tracker/sites/configs/audiences.yaml` 字段结构(全文 36 行) + +| 键 | 值 | 说明 | +| --- | --- | --- | +| `site_id` | `audiences` | | +| `display_name` | `观众` | | +| `base_url` | `https://audiences.me` | | +| `framework` | `nexusphp` | 继承 `NexusPHPSelectors` 默认值,只覆盖差异 | +| `selectors.torrent_row_css` | `table.torrents > tr:has(table.torrentname)` | 表类为 `torrents torrents-table` | +| `selectors.torrent_category_css` | `a[href^='?cat=']::attr(href)` | 分类 ID 来自 `?cat=XXX` | +| `selectors.torrent_subtitle_css` | `span.torrent-subtitle-line` | 副标题专用类 | +| `selectors.page_offset` | `-1` | 分页 0 起,对外 page=1 发送 0 | +| `selectors.profile_uid_css` | `a[href*='userdetails.php?id=']::attr(href)` | 顶栏取 uid | +| `selectors.profile_username_css` | `a[href*='userdetails.php?id=']` | | +| `selectors.profile_uploaded_css` | `span.site-userbar__compact-metric--uploaded` | 新版 userbar | +| `selectors.profile_downloaded_css` | `span.site-userbar__compact-metric--downloaded` | | +| `categories` | movie `[401]`;tv `[402, 403]`(剧集/综艺);documentary `[406]` | **403 综艺被并入 tv**——本项目需保留 403 原值 | +| 未配置 | `torrent_hr_css`、`promo_*`、`torrent_promo_deadline_css` | 使用默认 `img.pro_*` 促销规则;**H&R 为 unknown** | + +MC 注册表 `MC:src/movieclaw_tracker/registry.py:172` `_load_site_yaml` 读取的全部顶层键:`site_id`(必填)、`display_name`、`base_url`、`web_base_url`、`framework`、`custom_class`、`timezone`、`auth.supported`、`categories`、`selectors`、`http2`、`timeout`(30)、`max_retries`(3)、`min_request_interval`、`hr_seed_hours`。 + +### 5.2 MC NexusPHP 实现要点(`MC:src/movieclaw_tracker/frameworks/nexusphp.py`) + +| 函数 : 行 | 行为 | +| --- | --- | +| `list_torrents:48` | GET `torrent_list_path`,`page = page + page_offset`,分类参数 `cat<ID>=1`(`_apply_category_params:358`) | +| `search:67` | GET `search_path`,参数 `search_keyword_param`(默认 `search`) | +| `get_torrent_detail:87` | detail_url 由网页域换回请求域;取标题、副标题、描述、大小、下载链接、IMDb、豆瓣、文件列表(`td.rowfollow > ul > li`) | +| `_parse_promo:199` | 有序规则表首个命中;截止时间仅在 dl<1 且配置了选择器时解析,转 UTC | +| `_parse_torrent_row:284` | 无详情链接的行跳过;title 属性优先;`site_category_id` 原值;H&R 三态;`free = dl < 1.0` | +| `_parse_total_pages:343` | 扫描分页链接最大 page | + +### 5.3 观众适配建议(本项目) + +| 项 | 取值 | 来源 | +| --- | --- | --- | +| 列表路径 | `torrents.php`,page 0 起 | MC `page_offset:-1` | +| 行 | `table.torrents > tr:has(table.torrentname)` | MC/MP 一致 | +| 副标题 | `span.torrent-subtitle-line` | MC | +| 分类 | `a[href^='?cat=']@href` → `cat` 参数原值(401/402/403/406 …) | MC;不投影 | +| 促销 | 默认 NexusPHP `img.pro_*`;**需用真实页面核对**,未核对前允许 unknown | MC 未覆盖 | +| H&R | 待核对选择器;默认 unknown | MC 未配置 | +| 登录有效 | `a[href*='logout']` 存在 + MP 登录页文本检测 | MC `login_success_css` + MP `:1059` | +| 用户数据 | 顶栏 `span.site-userbar__compact-metric--uploaded/--downloaded` | MC + MP `nexus_audiences.py:349/412` | +| 私信 | 不做(第一页不能带 page 参数的坑留档) | MP `nexus_audiences.py:318` | + +--- + +## 6. TS 模块建议(站点) + +| TS 路径 | 函数签名 | 对应上游 | +| --- | --- | --- | +| `packages/adapters/src/sites/types.ts` | `interface SiteAdapter { readonly siteKey: string; listTorrents(q: ListQuery, ctx: SiteCallCtx): Promise<SitePage<SiteTorrentRow>>; search(q: SearchQuery, ctx: SiteCallCtx): Promise<SitePage<SiteTorrentRow>>; getDetail(siteTorrentId: string, ctx: SiteCallCtx): Promise<SiteTorrentDetail>; fetchTorrentFile(siteTorrentId: string, ctx: SiteCallCtx): Promise<Uint8Array>; checkAuth(ctx: SiteCallCtx): Promise<SiteAuthStatus>; fetchUserStats?(ctx: SiteCallCtx): Promise<SiteUserStats> }` | MC `BaseSite` 契约 + MP `IndexerModule` | +| 同上 | `type SiteTorrentRow = { siteTorrentId: string; title: string; subtitle: string; siteCategoryId: string \| null; siteCategoryName: string \| null; sizeBytes: number \| null; seeders: number \| null; leechers: number \| null; snatched: number \| null; publishedAt: string \| null /* UTC ISO */; uploader: string \| null; downloadFactor: number \| null; uploadFactor: number \| null; promoUntil: string \| null; hitAndRun: 'yes' \| 'no' \| 'unknown'; labels: string[]; imdbId: string \| null; doubanId: string \| null; detailUrl: string /* 无凭据 */; raw: Record<string, string> }` | MP `TorrentInfo` + MC `TorrentListItem` | +| 同上 | `type SiteCallCtx = { account: { id: string; secretRef: string }; signal: AbortSignal; limiter: SiteRateLimiter; http: HttpPort }` | — | +| 同上 | `type SiteError = { kind: 'auth_expired' \| 'auth_invalid' \| 'rate_limited' \| 'parse_error' \| 'network' \| 'site_maintenance'; message: string; httpStatus?: number }` | MC 异常分层 + MP 登录页检测 | +| `packages/adapters/src/sites/nexusphp/selectors.ts` | `type NexusPhpSelectors = { rowCss: string; titleCss: string; subtitleCss: string; categoryCss: string; categoryParam: 'cat' ; sizeCss: string; seedersCss: string; leechersCss: string; snatchedCss: string; timeCss: string; timeFallbackCss: string; timeFormat: string; timezone: string; promoDownloadRules: Array<[css: string, factor: number]>; promoUploadRules: Array<[string, number]>; promoDeadline?: { css: string; attr: string; re: string }; hrCss?: string; pageOffset: 0 \| -1; listPath: string; searchPath: string; searchParam: string; loginSuccessCss: string }`;`const NEXUSPHP_DEFAULTS: NexusPhpSelectors` | MC `selectors.py:39` + MP 测试样例 | +| `packages/adapters/src/sites/nexusphp/parse.ts` | `parseTorrentList(html: string, sel: NexusPhpSelectors, base: URL): { rows: SiteTorrentRow[]; totalPages: number \| null; pageKind: 'list' \| 'login' \| 'permission_denied' \| 'unknown' }` | MP `parse:1077`/`__is_login_or_permission_page:1059`、MC `_parse_torrent_row:284` | +| 同上 | `parseSize(text: string): number \| null`(`1.50 GB`/`1,234.5 MiB`/`850 KB`);`parseCount(text: string): number \| null`(取 `/` 前、去逗号) | MP `__get_size:535`/`__get_seeders:563` | +| `packages/adapters/src/sites/nexusphp/adapter.ts` | `createNexusPhpAdapter(def: SiteDefinition): SiteAdapter` | MC `NexusPHPSite` | +| `packages/adapters/src/sites/registry.ts` | `const SITE_DEFINITIONS: Record<'audiences' \| 'mteam', SiteDefinition>`;`getSiteAdapter(siteKey: string): SiteAdapter` | MC `registry.py:172` | +| `packages/adapters/src/sites/audiences.ts` | `export const audiencesDefinition: SiteDefinition` | §5.3 | +| `packages/adapters/src/sites/mteam/client.ts` | `mteamPost<T>(path: '/api/torrent/search' \| '/api/torrent/detail' \| '/api/torrent/genDlToken' \| '/api/member/profile' \| '/api/tracker/myPeerStatus', body: { json?: unknown; form?: Record<string, string> }, ctx: SiteCallCtx): Promise<T>`(检查 `message`,凭据类错误映射 `auth_invalid`) | MC `_post_api:148` | +| `packages/adapters/src/sites/mteam/map.ts` | `mapMteamTorrent(t: MteamTorrentJson, webBase: string): SiteTorrentRow`;`resolveMteamPromo(status: MteamStatus, now: Date): { downloadFactor: number; uploadFactor: number; promoUntil: string \| null }` | §4.4 MT5 | +| `packages/adapters/src/sites/mteam/adapter.ts` | `createMteamAdapter(def: SiteDefinition): SiteAdapter`(`fetchTorrentFile` = genDlToken + GET) | MC `download_torrent:445` | +| `packages/adapters/src/sites/rate-limit.ts` | `createSiteRateLimiter(opts: { minIntervalMs: number; burst: number }): SiteRateLimiter` | SA6 | +| `packages/testkit/src/mock-nexusphp/` | `createMockNexusPhp(opts: { flavor: 'audiences' \| 'generic'; pages: FixturePage[]; loggedIn: boolean }): MockServer`(fixture:列表页 HTML、详情页、登录页、`download.php?id=` 返回 .torrent) | — | +| `packages/testkit/src/mock-mteam/` | `createMockMteam(opts: { apiKey: string; torrents: MteamTorrentJson[]; failMode?: 'banned_key' \| 'rate_limit' \| 'non_json' }): MockServer` | — | + +--- + +## 7. 验证样本(站点) + +### 7.1 NexusPHP(观众风格)列表行 fixture 要点 + +| 样本 | 行内容 | 期望 `SiteTorrentRow` | +| --- | --- | --- | +| SV1 免费 + 截止 | `a[href="details.php?id=812345"][title="Dune.Part.Two.2024.2160p.UHD.BluRay.REMUX.DV.HDR.HEVC.TrueHD.Atmos.7.1-FraMeSToR"]`;`span.torrent-subtitle-line`=`沙丘2 / 沙丘:第二部 | 杜比视界 国英双语 中字`;`a[href="?cat=401"]`;`img.pro_free[title="2026-10-01 12:00:00"]`;时间 `span[title="2026-09-26 21:14:03"]`=`3小时前`;大小 `78.42 GB`;做种 `1,204`;下载 `17`;完成 `3,388` | siteTorrentId `812345`,siteCategoryId `401`,sizeBytes ≈ 84202833838,seeders 1204,downloadFactor 0,uploadFactor 1,promoUntil `2026-10-01T04:00:00Z`(站点时区 +08),hitAndRun `unknown`(未配置),publishedAt `2026-09-26T13:14:03Z` | +| SV2 综艺原分类 | 标题 `Keep.Running.S12E01.20240426.2160p.WEB-DL.H265.AAC-LelveTV`,副标题 `奔跑吧 第十二季 第1期`,`?cat=403` | siteCategoryId `403`(不得折叠为 tv) | +| SV3 50% + 2x | `img.pro_50pctdown2up` | downloadFactor 0.5,uploadFactor 2 | +| SV4 H&R 已配置未命中 | 站点定义 `hrCss: "img.hitandrun"`,行无该元素 | hitAndRun `no` | +| SV5 截断标题 | `a` 文本 `The.Last.of.Us.S02.2160p.MAX.WEB-DL.DDP5.1.Atmos.D…`,无 title 属性 | 回退文字(并记录 `raw.titleTruncated=1`) | +| SV6 登录失效 | 返回页 `<title>观众 :: 登录` | `pageKind='login'` → `SiteError auth_expired`,不产生 0 条"正常结果" | +| SV7 大小格式 | `1,234.56 MiB` / `850 KB` / `2.1 TB` | 1294529986 / 870400 / 2308974418329 | +| SV8 分页 | 请求 page=1 | 发出 `torrents.php?page=0`(观众 0 起) | + +### 7.2 M-Team API fixture + +| 样本 | `status` / 响应 | 期望 | +| --- | --- | --- | +| MV1 | `{"code":"0","message":"SUCCESS","data":{"data":[{"id":"912233","name":"Shogun.2024.S01.2160p.DSNP.WEB-DL.DDP5.1.Atmos.DV.HDR.H.265-FLUX","smallDescr":"幕府将军 第一季 全10集 杜比视界","category":"402","size":"61234567890","createdDate":"2026-09-20 08:15:00","imdb":"https://www.imdb.com/title/tt2788316/","status":{"seeders":"532","leechers":"4","timesCompleted":"2011","discount":"PERCENT_50","discountEndTime":"2026-09-28 08:15:00"}}],"totalPages":"3"}}` | downloadFactor 0.5,promoUntil 对应 +08 转 UTC,siteCategoryId `402`,imdbId `tt2788316`,detailUrl `https://tp.m-team.cc/detail/912233`,不含 apikey | +| MV2 商城单种免费 | `discount: "PERCENT_50"`,`mallSingleFree: {"status":"ONGOING","endDate":"2026-09-27 20:00:00"}` | downloadFactor 0,promoUntil = endDate | +| MV3 全站促销 | `discount: "NORMAL"`,`promotionRule: {"discount":"FREE","endTime":"2026-10-07 23:59:59"}` | downloadFactor 0 | +| MV4 2x 免费 | `_2X_FREE` | 0 / 2 | +| MV5 Key 失效 | HTTP 200 `{"code":"1","message":"帳號已停用"}` | `auth_invalid`,账号验证失败,停止重试 | +| MV6 非 JSON | HTTP 200 HTML 维护页 | `site_maintenance`/`parse_error`,退避 | +| MV7 下载 | `genDlToken` 表单 `id=912233` → `{"code":"0","message":"SUCCESS","data":"https://api.m-team.cc/api/rss/dlv2?sign=…&t=…&tid=912233"}` → GET 返回 .torrent | `fetchTorrentFile` 返回字节;直链不写日志/DB | +| MV8 分类 | category `405`(动画)、`404`(纪录)、`407`(体育) | 原值保存;应用分类 anime/documentary/tv | +| MV9 IMDb 搜索 | keyword `tt15239678` | 请求体 keyword = `https://www.imdb.com/title/tt15239678` | + +--- + +## 8. 给实现者的行为提醒(汇总) + +| # | 提醒 | 位置 | +| --- | --- | --- | +| 1 | qB 状态兜底不能是"完成";完成只按 `progress>=1 && amount_left<=0 && state∉非终态`。`error/missingFiles/moving/checkingUP` 均非完成 | §1.4 | +| 2 | 提交下载必须幂等:本地算 infohash → 超时/异常先按 hash 查询 qB 再决定重试;已存在走接管冲突检查,不改其路径/标签/文件选择 | §1.3、QA1/QA2/QA8 | +| 3 | 删数据守卫:共享保存根、其他任务重叠、媒体库直接做种、部分文件未终态、H&R 未知 任一成立都禁止 `deleteFiles=true`;上游 move 后默认删数据不可照搬 | §1.6、QA11/QA12 | +| 4 | 多集种子整种完成前不结束下载侧管理(MP #6009) | QK5 | +| 5 | 站点原始分类 ID 原样保存(观众 403 综艺、馒头 404/405/407);H&R 三态,unknown 视为有义务 | SA2/SA3、§5.1 | +| 6 | 凭据(cookie/passkey/apikey)不进入候选对象、enclosure、日志、快照;M-Team 下载现场 genDlToken | SA5、MT3 | +| 7 | M-Team 检查业务 `message`,区分凭据死亡与解析异常 | MT2 | +| 8 | 整季包声称完整时先解析 .torrent 文件名核验集数;磁力无法核验不当整季 | §2.2 | + +## 9. 未取得 / 待核验 + +| 项 | 状态 | +| --- | --- | +| MP 站点 indexer 真实配置(观众、馒头、HHClub、CHD 选择器) | 未取得:在闭源 `SitesHelper` .so + 加密 `user.sites.v3.bin` 中;本文只用测试样例与 MC 配置对照 | +| MP 提交 `33da9ce…`(REQ [S16]) | 本地缺失,未对照 | +| 观众促销/H&R 真实类名 | MC 未配置,需用真实页面(或用户提供的脱敏 HTML)核对后写入 `audiences.ts` | +| M-Team `genDlToken` 返回直链的有效期与频率限制 | 两项目均未写明;mock 中按一次性处理 | +| qB 5.x API Key 认证与 `added_torrent_ids` 字段的最小版本 | MP 以运行时探测兼容;需在 spike 中以 qB 4.6.x / 5.1.x 实例验证 | diff --git a/docs/porting/moviepilot-metadata-recognition.md b/docs/porting/moviepilot-metadata-recognition.md new file mode 100644 index 00000000..27d25755 --- /dev/null +++ b/docs/porting/moviepilot-metadata-recognition.md @@ -0,0 +1,458 @@ +# MoviePilot 标题识别与刮削链路:迁移对照 + +> 格式遵循 REQ §14.1.2。本文只记录上游行为,以及本项目 TS 重写时要保留和要改动的点,不复制上游源码。许可边界见 `license-and-provenance.md`。 + +## 0. 来源与核验方式 + +| 项 | 值 | +| --- | --- | +| 上游仓库 | `https://github.com/jxxghp/MoviePilot`,分支 `v3` | +| 本地快照 | `/opt/moviepilot-plus/upstream/MoviePilot` | +| 精确提交 | `e6820a83ab05e8a00eb0e14adf0f357faa965c7e`(2026-08-30 13:32 +0800,"docs(architecture): close residual audit goal",`version.py` = `v3.0.0`) | +| 与 REQ 附录的差异 | REQ 附录 [S16] 引用的快照是 `33da9ce65d5020a8e087584a3321c1ff353c1921`。本地仓库是单提交浅克隆,不包含该对象,因此**本文所有行号都以 `e6820a8` 为准**。v3 的目录与 v2 不同:`app/core/meta/*` 已迁到 `app/domain/meta/*`,`app/core/metainfo.py` 已迁到 `app/domain/metainfo.py`,`app/chain/media.py` 已拆成 `app/chain/media/*` 和 `app/chain/_recognition.py` | +| 许可 | 仓库根目录 `LICENSE` 为 GPLv3 全文 | +| 运行核验 | 用本地 `.venv` 的 Python 解释器直接调用 `app.domain.metainfo.MetaInfo / MetaInfoPath`。运行时只注入默认扩展名(`RMT_MEDIAEXT/SUBEXT/AUDIOEXT`),`accelerator=None`,所以走的是纯 Python 参考实现。§9 的"实测输出"列全部来自这次运行,不是推测。探针脚本放在会话临时目录,没有写入任何仓库 | + +### 0.1 一个必须知道的结构事实:Rust 快路径 + +`app/domain/metainfo.py:495 MetaInfo()` 先检查 `get_metainfo_accelerator()`(由 `app/startup/composition/domain.py:34` 注入 `app/adapters/system/rust.py`,底层是二进制扩展 `moviepilot_rust`,本机 uv 缓存里的版本是 0.3.3)。满足以下条件时,MetaInfo 走 Rust 解析:扩展存在、`RUST_ACCEL` 已开启(或运行在 free-threaded 运行时)、标题里没有 `media_source=` 或 bangumi/anilist 显式标签(`_requires_python_metainfo`,:472)。**Rust 源码不在本仓库。** Python 路径的注释自称"参考实现"(`_prepare_meta_input`,:285),上游测试 `tests/test_recognition_parity.py` 负责校验两条路径等价。 + +**结论**:本项目的对照基准只取 Python 路径。如果以后发现生产环境 MP 的输出和本文不一致,先确认它有没有开 Rust 加速。 + +--- + +## 1. 调用链总览 + +``` +下载完成 / 手动整理 + └─ app/chain/transfer/request.py:83 _build_path_meta ──► MetaInfoPath(path, custom_words, force_video=True) + └─ app/chain/transfer/request.py:108 _apply_meta_overrides (手动季号 / FormatParser 集数定位与偏移) + └─ app/chain/transfer/execution.py:320-373 (优先使用下载历史里的 media_id,否则按 meta 识别) + ├─ MediaChain.recognize_media(media_source, media_id) 显式身份 + └─ MediaChain.recognize_by_meta(meta) 名称识别 + └─ app/chain/media/recognition.py:232 _recognize_with_fallback_by_meta + └─ app/chain/media/plugin.py:252 select_recognize_source(native_fn, plugin_fn) + ├─ native: app/chain/_recognition.py:531 recognize_media + │ └─ _recognition_steps (:395) NATIVE → SUPPLEMENT(插件补充) → REPORT/QUERY_SHARE(共享识别) + │ └─ app/modules/themoviedb/__init__.py:928 recognize_media + │ └─ _recognize_steps (:744) LOAD_CACHE → LOOKUP_IDENTITY | SEARCH_NAME → LOAD_DETAILS → SAVE_CACHE → BUILD_RESULT + │ └─ app/modules/themoviedb/tmdbapi.py:546 match / :746 match_multi + └─ plugin: app/chain/media/plugin.py:343 recognize_help (NameRecognize 事件,辅助识别,例如 LLM 插件) + └─ 刮削:app/chain/scraping.py:995 scrape_metadata → app/modules/themoviedb/scraper.py:35 get_metadata_nfo / :87 get_metadata_img +``` + +--- + +## 2. 标题解析:MetaInfo / MetaInfoPath 入口 + +### 2.1 入口函数 + +| 上游 | 输入 | 输出 | 关键分支 | +| --- | --- | --- | --- | +| `app/domain/metainfo.py:495 MetaInfo(title, subtitle, custom_words, force_video)` | 种子名、文件名或标题;站点副标题;临时识别词 | `MetaVideo`、`MetaAnime` 或 `MetaMusic` | ① 后缀是音频且没有 `force_video` 时走 `MetaMusic`;② 可用时走 Rust;③ 否则走 `_build_python_meta_info` | +| `app/domain/metainfo.py:354 _build_python_meta_info` | 同上 | MetaBase(带 `original_name`) | 有识别词生效时,再用不带识别词的原标题解析一次,把结果存为 `original_name` | +| `app/domain/metainfo.py:279 _prepare_meta_input` | title | `_PreparedMetaInput` | 依次做三件事:`WordsMatcher.prepare`(应用识别词)→ `_find_metainfo_python`(剥离显式 ID 标签)→ 后缀在媒体扩展名表里时 `isfile=True` 并取 stem | +| `app/domain/metainfo.py:332 _build_meta_info` | prepared | MetaBase | `is_anime(parsed_title)` 为真时用 `MetaAnime`,否则用 `MetaVideo`;**显式标签最后覆盖**(`_apply_explicit_metainfo`,:308) | +| `app/domain/metainfo.py:579 is_anime(name)` | name | bool | 按顺序判断:`【xx】【`、` - 12 ` 形式为 True;接着命中 `S\d{2}` 或 `EP?\d{2,4}` 就返回 False;再匹配 `[xx][` 为 True;其他情况为 False | +| `app/domain/metainfo.py:533 MetaInfoPath(path, custom_words, force_video)` | 完整路径 | 合并后的 MetaBase | 见 §2.3 | + +### 2.2 显式身份标签(`_find_metainfo_python`,:178) + +| 语法 | 例子 | 结果字段 | 优先级 | +| --- | --- | --- | --- | +| `{[k=v;...]}` 块 | `{[tmdbid=94997;type=tv;s=1;e=3-4;g=5eb730...]}` | `media_source/media_id`、`type`、`begin/end_season`、`begin/end_episode`、`episode_group` | 块内任意字段命中就把整块从标题里删掉 | +| Emby 风格 | `[tmdbid=94997]`、`[tmdb-94997]`、`{tmdbid=..}`、`{tmdb=..}` | TMDB 身份 | 第一个 `[tmdbid]` **总是**优先,会覆盖 `{[...]}` 里的旧 tmdbid;其余写法按 `[tmdbid]>[tmdb]>{tmdbid}>{tmdb}` 取第一个 | +| 扩展来源 | `[bangumiid=..]`、`[anilist=..]` | Bangumi/AniList 身份 | 只在还没有同源身份时取 | +| 多来源并存 | — | `media_source/media_id` 只保留一对 | 按 `_LEGACY_ID_KEYS` 的顺序 TMDB > Douban > Bangumi > AniList 取第一个 | +| id 为 `0` | `[tmdbid=0]` | 忽略 | — | + +`_apply_range_total`(:144):有 begin 和 end 时自动算出 total。 + +**TS 对应**:`packages/domain/src/title/explicit-tags.ts` + +```ts +export interface ExplicitTags { identity?: { provider: 'tmdb'|'douban'|'bangumi'|'anilist'; id: string }; + mediaType?: 'movie'|'tv'; beginSeason?: number; endSeason?: number; beginEpisode?: number; endEpisode?: number; episodeGroup?: string } +export function extractExplicitTags(title: string): { rest: string; tags: ExplicitTags } +``` + +| 保留 | 调整 | 原因 | +| --- | --- | --- | +| 四种标签写法、TMDB 优先、id=0 忽略、整块剥离 | 输出改为 `external_identity` 的候选,**不直接视为已确认身份** | REQ §7.2:本地 UUID 是主身份,外部 ID 只是映射。标签来自文件名,可信度低于人工锁定 | + +### 2.3 多文件、整季包:目录名和文件名如何合并(`MetaInfoPath`,:533-576) + +| 步骤 | 行为 | 说明 | +| --- | --- | --- | +| 1 | 音频后缀且没有 `force_video` 时直接返回 `MetaMusic` | 整理链路一律传 `force_video=True`(`request.py:102`),所以影视目录里的音轨按视频解析 | +| 2 | `file_meta = parse(path.name)` | 文件名带后缀,后缀会被识别出来并设 `isfile=True` | +| 3 | `should_use_parent_title_for_file_stem`(`infopath.py:15`)为真时清空 `cn_name/en_name/original_name` | 条件:文件 stem 全是中文、长度 ≤16、完全由 `双语/简体/繁中/国配/…` 这类辅助词组成、不含"第X季/集",父目录含两个以上连续拉丁字母,并且文件本身没有显式身份 | +| 4 | `dir_meta = parse(parent.name)`;**只有当 `file_meta.type==TV` 或 `dir_meta.type!=TV` 时才 merge** | 防止一部电影文件被 TV 目录的季集信息污染。副作用是:文件自身识别不出 TV 而父目录是 TV 时,**父目录的名称、季号都不会被合并**(见样本 M-10、M-11) | +| 5 | `root_meta = parse(parent.parent.name)`,merge 条件同上 | 最多只看两级父目录 | +| merge 规则(`metabase.py:719 merge`) | 文件优先,父目录只补空字段:type 在 UNKNOWN 时补;name 为空时补 cn/en;year 为空时补;**季只在 `type==TV` 且 begin_season 为空时整组补(begin/end/total)**,集同理;可选字段 `resource_type/pix/team/customization/effect/web_source/video_encode/video_bit/audio_encode/part/episode_group` 为空时补;身份按成对原子合并,不会把不同层级的来源和 ID 拼成一对 | — | + +**整季包的判定**:MetaBase 里没有 `is_pack` 字段。约定是 `type==TV`、`begin_season` 有值、`begin_episode is None` 时表示"整季或多季包"(`season_list`,:473;`episode_list` 为空,:503)。`begin_episode/end_episode` 同时有值时表示单文件多集或集区间包。 + +**文件级集号的规则**(`MetaVideo.__init_episode`,metavideo.py:578): + +| 条件 | 行为 | +| --- | --- | +| `isfile` 且集区间长度 >2 | 丢弃 end,只保留 begin(一个文件不可能是 E01-E10)。**`S02E05E06` 这种区间长度为 2 的会保留** | +| `isfile` 且季区间长度 >1 | 丢弃 end_season | +| 文件名是纯数字、长度 <5(例如 `01.mp4`,metavideo.py:176) | 直接视为集号,type=TV,然后依靠父目录补名称和季号 | + +**TS 对应**:`packages/domain/src/title/parse-path.ts` + +```ts +export function parseReleasePath(segments: { file: string; parent?: string; grandParent?: string }, + opts: ParseOptions): ParsedRelease // 纯函数,不访问文件系统 +export function mergeParsed(primary: ParsedRelease, fallback: ParsedRelease): ParsedRelease +``` + +| 保留 | 调整 | 原因 | +| --- | --- | --- | +| 文件优先、父目录只补空、身份原子合并、最多两级父目录、纯数字文件名视为集号、文件多集区间 ≤2 | ① 步骤 4 的 merge 条件在"文件无类型、父目录是 TV"时**改为合并名称和季号**,但要给结果打 `evidence: 'parent_dir'` 标记,并把置信度降为"待确认";② 辅助词文件(字幕、音轨)**不单独识别身份**,交给附属文件配对逻辑(见 `moviepilot-transfer.md`),按主视频 stem 归属 | 上游的这条规则会让 `奔跑吧.2024.05.03.mp4`、`简体.srt` 这类文件识别失败或跑偏(M-10、M-11 实测)。本项目综艺是核心场景(REQ §5) | +| — | 输出里增加 `packHint: 'single'|'multi_episode'|'season_pack'|'multi_season_pack'|'unknown'` | 把上游的隐含约定变成显式字段,方便 Slot 满足度和整理计划使用 | + +--- + +## 3. MetaVideo 词元解析(`app/domain/meta/metavideo.py`) + +### 3.1 预处理(`__init__`,:163-252) + +| 顺序 | 行为 | 正则或常量 | +| --- | --- | --- | +| 1 | `isfile` 且标题是 <5 位的纯数字时,直接当作集号并返回 | :176 | +| 2 | 标题整体是 `Season N` 或 `SN` 时,只设季号并返回 | `SEASON_FULL_RE` :18 | +| 3 | 去掉开头第一个 `[..]` 或 `【..】`;如果括号里是"点分隔英文名、年份、资源类型"组成的发布名,就保留内容、只去括号 | `FIRST_BRACKET_RE`、`BRACKET_DOT_TITLE_RE`、`BRACKET_RESOURCE_RE` :19-24 | +| 4 | `2019-2020` 这种年份范围只保留前一个年份 | `YEAR_RANGE_RE` :25 | +| 5 | 删除文件大小 `12.3GB` | `FILE_SIZE_RE` :26 | +| 6 | **删除日期 `2024.05.03`、`2024-5-3`、`2024 05 03`** | `DATE_RE = \d{4}[\s._-]\d{1,2}[\s._-]\d{1,2}` :27 | +| 7 | 分词:以 `. 空白 ( ) [ ] - 【 】 / ~ ; & | # _ 「 」 ~` 为分隔 | `app/domain/tokens.py Tokens.load_text` | +| 8 | 逐个词元执行 `__parse_token`(:254),命中一个阶段就停止后续阶段 | 见 §3.2 | +| 9 | 汇总:`resource_effect` = 效果词按**出现顺序的逆序**用空格拼接;`resource_type` = 来源词按顺序拼接并去重 | :213-216 | +| 10 | BluRay 且副标题或原标题含 `DIY` 或 `-DIY@` 时,追加 ` DIY` | :218-221 | +| 11 | `init_subtitle(org_string)`;没解析出东西再解析 `subtitle` | metabase.py:244 | +| 12 | `__fix_name`:用 `_name_nostring_re` 清除干扰词。结果是 <1800 的纯数字、且没有年份、季、分辨率、来源、编码时,把它当作集号 | :311 | +| 13 | 英文名是拼音且没有中文名时,从副标题第一个词里取中文名(要求词数相等) | :240-246 | +| 14 | `resource_team = ReleaseGroupsMatcher().match(原标题)`,`customization = CustomizationMatcher().match(原标题)` | :248-250 | + +### 3.2 词元阶段的优先级(`__parse_token`,:254-285) + +每个词元按下表顺序尝试,**第一个返回 True 的阶段结束本词元的处理**。有些阶段返回 False 但会改状态(例如命中季号后设置 `stop_name`)。 + +| 序 | 阶段(函数:行) | 触发条件与规则 | 前置门槛 | +| --- | --- | --- | --- | +| 1 | `__init_part` :435 | `^PART[0-9ABI]{0,2}$|^CD\d{0,2}$|^DVD..|^DISK..|^DISC..`;下一个词元是 1 位数字、`0x`、A/B/C/I/II/III 时拼接上去;单独一个 `PART` 最后会清空 | 已有名称,并且已有 year、季、集、分辨率、来源中至少一项 | +| 2 | `__init_name` :334 | 含中文的词元作为 cn_name,后续中文只追加一次(`剧场版` 等可以追加,`xx版/xx字幕` 不追加);`AKA` 之后停止取名;<4 位数字或罗马数字拼进名称;4 位数字暂存为 `pending_name`,下一个词元不是年份时再回收进英文名;命中季、集、来源、分辨率模式时 `stop_name`;媒体后缀不计入名称 | — | +| 3 | `__init_year` :461 | 4 位数字且在 1900 到 2050 之间(开区间);已经有年份时,把旧年份并入名称,用新的覆盖(例如 `新精武门1991 (1991)`、`Blade Runner 2049 2017`) | 已有名称 | +| 4 | `__init_resource_pix` :486 | `^[SBUHD]*(\d{3,4}[PI]+)`、`\d{3,4}X(\d{3,4})`、`^[248]+K`;纯数字时补 `p` | 已有名称 | +| 5 | `__init_season` :525 | `S(\d{3})|^S(\d{1,3})$|S(\d{1,3})E`;`Season` 后跟 <3 位数字;**第二个更大的季号成为 end_season(S01-S05)**;文件时季区间 >1 就丢弃 end;TV 类型但没有季号时默认 begin_season=1 | — | +| 6 | `__init_episode` :578 | `EP?(\d{2,4})$|^EP?(\d{1,4})$|^S\d{1,2}EP?(\d{1,4})$|S\d{2}EP?(\d{2,4})`;紧跟在集号后面的更大数字作为 end;2 到 3 位的裸数字,前一个词元不是年份或编码、也不是 pending_name 时视为集号;`EPISODE` 后跟 <5 位数字 | — | +| 7 | `__init_resource_type` :652 | 来源:`BLURAY HDTV UHDTV HDDVD WEBRIP DVDRIP BDRIP BLU WEB BD HDRip REMUX UHD`,`WEB`+`DL` 合成 `WEB-DL`,`BLU`+`RAY` 合成 `BluRay`;效果:`SDR HDR\d* HDRVIVID DOLBY DOVI DV 3D REPACK HLG HDR10+/Plus/P VIVID EDR HQ` | 已有名称 | +| 8 | `__init_web_source` :688 | 流媒体平台码(`streamingplatform.py` 里约 270 条,如 NF→Netflix、MAX→Max),单个词元或与相邻词元合并匹配;**前后各 1 到 2 个词元内必须有 WEB/DL/WEBDL/WEBRIP**,否则不算 | 已有名称 | +| 9 | `__init_video_encode` :746 | `H26[45] x26[45] AVC HEVC VC\d MPEG\d Xvid DivX AV1 AVS+/2/3`;`H`/`X` 加下一个 `264/265` 拼接;`10BIT` 追加为 `x265 10bit` | 已有名称,并有 year、分辨率、来源、季、集中至少一项 | +| 10 | `__init_video_bit` :796 | `(8|10|12|16)[\s._-]*bits?` | 同上 | +| 11 | `__init_audio_encode` :817 | `DTS DTSHD DTSHDMA Atmos TrueHD AC3 EAC3 DDP DD+ DD LPCM AAC FLAC MA HR Opus Vorbis AV3A AVSA \dAudios`;后跟数字时拼成 `DDP 5.1`、`TrueHD 7.1`;DTS 用 `-` 连接(`DTS-HD MA 6.1`) | 同上 | +| 12 | `__init_fps` :853 | `(\d{2,3})(?=FPS)`,结果只存数值 | 已有名称 | + +### 3.3 `edition` 的含义(必读) + +`metabase.py:580 edition` = `resource_type + resource_effect`,**不含分辨率**(带分辨率的是 `resource_term`,:566)。例如 `UHD BluRay REMUX HDR DV`。 + +**上游没有"剪辑版本"字段**:`Extended`、`Extended Version`、`UNCUT`、`Director's Cut`、`IMAX` 等都在 `_name_nostring_re` 里被当作干扰词删掉,或者直接不识别(M-08 实测:`Extended.Edition` 完全消失)。 + +| 保留 | 调整 | 原因 | +| --- | --- | --- | +| 来源和效果词表、`WEB-DL`/`BluRay` 合成、DIY 追加、效果按出现逆序拼接(为了输出结果逐字一致) | 新增 `cut` 字段(`extended/directors/uncut/imax/theatrical/remastered/criterion`),从原标题独立提取;`edition` 只作为展示字段 | REQ §6.1 双版本和收录目标需要区分剪辑版本;不能因为干扰词清理把这个信息丢掉 | + +### 3.4 MetaAnime(`app/domain/meta/metaanime.py:53`) + +| 行为 | 说明 | +| --- | --- | +| 用第三方库 `anitopy` 解析,没取到标题就尝试加 `[ANIME]` 前缀,再不行取第一个 `[..]` 的内容 | 需要 anitopy 等价实现 | +| 名称按 `/` 拆中英文,中文在哪一侧就取哪一侧 | 实测 `葬送的芙莉莲 / Sousou no Frieren` 两边都取到了 | +| 发布组由 ReleaseGroupsMatcher 统一匹配(内置 anime 组表) | 实测 `喵萌奶茶屋@LoliHouse` | +| 季号没写时保持 None(集号有值) | 实测 Frieren `- 13` 得到 begin_season=None。后续 TMDB 按 `season_seq` 取值,默认按 1 处理 | + +**TS 对应**:`packages/domain/src/title/anime.ts`。首期可以只实现 `is_anime` 的判定、方括号分段、`- NN` 集号和 `[01-28]` 区间,完整的 anitopy 放到 M2。**npm 上不引入 GPL 依赖**;anitopy 本身是 MPL-2.0,如果采用需要在 ledger 里登记。 + +### 3.5 副标题季集(`metabase.py:244 init_subtitle`) + +| 模式 | 正则(行) | 结果 | 陷阱 | +| --- | --- | --- | --- | +| `Episode 12` | `TITLE_EPISODE_RE` :17 | begin_episode | ≥10000 时忽略 | +| `全3季`、`共3季` | `SUBTITLE_SEASON_ALL_RE` :20 | begin=1,end=total=3 | 只在季号和集号都为空时生效,并且**生效后直接 return** | +| `第二季`、`第1-3季` | `SUBTITLE_SEASON_RE` :19 | begin/end_season,中文数字经 cn2an 转换 | 有负向前瞻 `(?!\s*[全共])`,所以**`第八季 全12期` 这类写法季号会丢**(M-13 实测) | +| `第1-10集` | `SUBTITLE_EPISODE_BETWEEN_RE` :22 | begin/end_episode | — | +| `第5集`、`第5期`、`第5话` | `SUBTITLE_EPISODE_RE` :21 | begin_episode | **`期` 和 `集` 同等对待,都会成为 episode 号** | +| `全39集`、`39集全` | `SUBTITLE_EPISODE_ALL_RE` :27 | **只设 total_episode**,begin 仍为空 | 这是整季包的信号,但没有区间 | +| `01-26 Fin`、`[01-38 END]`、`完结` | `SUBTITLE_EPISODE_RANGE_FIN_RE` :33 | begin/end,total=end | 两个数都在 1900 到 2155 之间时当作年份,不当作集号 | + +--- + +## 4. 识别词、集号偏移与 FormatParser + +### 4.1 自定义识别词(`app/domain/meta/words.py:100 WordsMatcher.prepare`) + +| 语法 | 解析(:141 `__parse_word`) | 行为 | +| --- | --- | --- | +| `屏蔽词` | block | 正则替换为空 | +| `被替换 => 替换` | replace | 正则替换(`regex` 库,允许变长后顾) | +| `前定位 <> 后定位 >> EP-12` | offset | 前定位词和后定位词**都必须命中**;找出二者之间所有 `[0-9一二…十]+`,按算术白名单计算(`_calculate_episode_offset` :54,仅允许 `+ - * / // % **` 和括号,`EP` 是占位符);保留前导零宽度,中文数字用 cn2an 写回;向前偏移时按升序替换,向后偏移时按降序替换,避免连锁覆盖 | +| `A => B && 前 <> 后 >> EP-12` | replace_and_offset | 替换成功后才做偏移 | +| `#` 开头 | 注释 | 跳过 | + +生效的规则写入 `meta.apply_words`;有生效规则时,再用原标题解析一次,得到 `original_name`(metainfo.py:360)。 + +**实测陷阱**:`鬼灭之刃 => 鬼灭之刃 第四季` 与 `第 <> 集 >> EP-44` 叠加时,偏移正则会把"第四季"里的"四"也算进去,季号最终丢失(W-02)。偏移作用于前后定位词之间的**所有**数字。 + +### 4.2 整理时的集数定位(`app/application/formatting.py:84 FormatParser`) + +| 参数 | 含义 | +| --- | --- | +| `eformat` | 集数定位模板,例如 `[LoliHouse] Frieren - {ep} [1080p].mkv`,编译成带命名组 `ep` 的正则 | +| `details` | 指定集数:`5`、`1,12`、`3-4` | +| `offset` | `-12`、`+1`、`EP*2`,自动补成 `EP±n` | +| `part` | 强制 part | + +`split_episode`(:170):有 details 时直接返回;没有模板时只对 meta 的 begin/end 做偏移;有模板时从文件名提取后再偏移。**上游用 `eval()` 计算偏移表达式**(:185,:193,:197,:203)。 + +调用处 `app/chain/transfer/request.py:108 _apply_meta_overrides`:先覆盖手动季号,再覆盖集号和 part。注释特别说明,`_build_path_meta` 已经应用过一次覆盖,不要重复偏移(:69-72)。 + +**TS 对应**:`packages/domain/src/title/custom-words.ts`、`packages/domain/src/title/episode-format.ts` + +```ts +export type RecognitionWord = { kind: 'block'; pattern: string } | { kind: 'replace'; from: string; to: string } + | { kind: 'offset'; front: string; back: string; expr: string } | { kind: 'replace_offset'; from: string; to: string; front: string; back: string; expr: string } +export function applyRecognitionWords(title: string, words: RecognitionWord[]): { title: string; applied: RecognitionWord[] } +export function evalEpisodeOffset(expr: string, ep: number): number // 自写安全解析器,禁止 eval/Function +export function splitEpisodeByTemplate(fileName: string, rule: EpisodeFormatRule, meta: ParsedRelease): { begin?: number; end?: number; part?: string } +``` + +| 保留 | 调整 | 原因 | +| --- | --- | --- | +| 四种语法、偏移的方向排序、零宽度保留、偏移只执行一次 | ① 表达式求值禁止 `eval`,一律走白名单解析器;② 识别词是版本化的**规则**(REQ §5.3),需要 `policy_revision` 和回放样本,不能用全局单例配置;③ 偏移只作用于前后定位词之间、**离后定位词最近**的一个数字(可配置),不是所有数字 | 安全问题;REQ §5.3 要求规则可回放、可回滚;W-02 实测的误伤 | +| 正则引擎差异 | Python `regex` 支持变长后顾 `(?<=%s.*?)`,JS 的 `RegExp` 支持后顾,但性能和语义需要逐条测试;用户规则进库前先编译校验,并设超时保护(例如 `re2`/`node-re2` 或长度限制) | 防止 ReDoS | + +--- + +## 5. 发布组与自定义占位符 + +| 上游 | 行为 | 本项目 | +| --- | --- | --- | +| `app/domain/meta/releasegroup.py:26 RELEASE_GROUPS` | 按站点分组的内置正则表(mteam: `MTeam(?:TV|)`、`MPAD`、`MWeb`;audiences: `Audies`、`AD(?:Audio|E(?:book|)|Music|Web)`;hhanclub: `HHWEB`;chdbits: `CHD(?:Bits|PAD|(?:|HK)TV|WEB|)`… 以及 `others`、`anime`)加上用户自定义 | **不照抄这张表**。在 `packages/domain/src/title/release-groups.ts` 里保留"站点 → 组名"的数据结构,用我们自己维护的种子数据,并支持在设置表里扩展 | +| `:117 __get_groups_re` | `(?<=[-@\[£【&])(?:groups)(?=$|[@.\s\]\[】&])`,不区分大小写 | 保留前后界定符语义 | +| `:127 match` | 所有匹配去重后用 `@` 连接 | 保留,输出改为数组 | +| **限制** | 组名不在表里时 `resource_team=None`(M-01 `FraMeSToR`、M-15 `VARYG` 实测都识别不到) | **增加兜底**:取标题末尾 `-GROUP`(后缀剥离后),输出 `releaseGroup: { name, source: 'known'|'suffix' }`。REQ §4.5 要求"发布组偏好与站点身份分开",偏好规则需要看到未知组 | +| `app/domain/meta/customization.py:50 match` | 用户占位符正则,按配置顺序去重后连接 | M2 再做;首期不实现 | + +--- + +## 6. 识别编排(chain 层) + +### 6.1 MediaChain 入口 + +| 上游 | 输入 → 输出 | 关键分支 | +| --- | --- | --- | +| `app/chain/media/recognition.py:201 recognize_by_meta` | MetaBase → MediaInfo 或 None | 经 `select_recognize_source` 选"原生优先"或"插件优先"(配置项);成功后按需 `obtain_images` | +| `app/chain/media/path.py:466 recognize_by_path` | 路径 → Context(meta, mediainfo) | 音乐另走一条路;影视走 `MetaInfoPath` 加 `_recognize_with_fallback_by_meta` | +| `app/chain/_recognition.py:254 _build_recognition_plan` | meta、来源、id → plan | 请求级 `media_id` 优先;没有时,meta 自带同源 id 就用它;`mtype` 缺省且没有身份时用 `meta.type`;**只给 id 不给 source 属于无效请求** | +| `app/chain/_recognition.py:395 _recognition_steps` | plan → 生成器状态机 | NATIVE(TMDB 模块)→ SUPPLEMENT(插件补充,`_supplement_media_recognize` :745)→ 有身份时 REPORT(上报共享);没有身份且允许共享时 QUERY_SHARE → 再 NATIVE → UPDATE_CACHE(回填本地正缓存) | +| `app/chain/media/plugin.py:343 recognize_help` | 标题 → 辅助识别 | 发送 `NameRecognize` 事件,插件(如 LLM)返回 `{name, year, season, episode}`;**和原识别一致时不重识别**(`_prepare_video_help_meta` :323);否则改写 meta 后再走一次 `recognize_media` | + +### 6.2 整理时的识别优先级(`app/chain/transfer/execution.py:320-373`) + +| 条件 | 行为 | +| --- | --- | +| 任务已经带 mediainfo(手动指定) | 直接使用 | +| 有下载历史(按 hash、文件全路径、父目录或 savepath 回查,`records.py:131 _resolve_download_history`),且历史里有 `media_source/media_id`,且**电影年份不冲突**(`_is_movie_year_conflict`) | 用历史身份做显式识别;沿用历史的 `media_category` | +| 下载历史的年份和文件年份冲突 | 按文件名重新识别,并记录日志 | +| 没有历史 | `recognize_by_meta(file_meta)` | + +**TS 对应**:`packages/tasks/src/resolve-observation.ts` 和 `packages/tasks/src/verify-and-import.ts` 负责编排;纯判定逻辑放在 `packages/domain/src/identity/resolve-plan.ts` + +```ts +export type IdentityEvidence = + | { kind: 'manual_lock'; entityId: string } // 最高:人工锁定 + | { kind: 'download_task'; entityId: string; slotId?: string } // 提交下载时就绑定了本地实体 + | { kind: 'explicit_tag'; provider: string; id: string } + | { kind: 'title_parse'; parsed: ParsedRelease } +export function planIdentityResolution(evidence: IdentityEvidence[]): IdentityResolutionPlan +``` + +| 保留 | 调整 | 原因 | +| --- | --- | --- | +| "下载时的身份优先于下载后重新识别",以及年份冲突时回退 | 下载历史换成 `download_task → acquisition_plan → media_entity(UUID)` 的**强绑定**;年份冲突不再静默回退,而是生成 `decision`(待处理),因为下载时的绑定可能是用户确认过的 | REQ §7.2、§11.3 | +| 辅助识别事件 | 由 Pi Agent 的有界任务 `identity.resolve` 代替(REQ §10),输出结构和 `{name, year, season, episode}` 兼容,**需要在授权范围内**,并且写入 `agent_run` | Agent 优先 | +| 共享识别(MP 社区服务上报和查询) | **不迁移** | 外部服务,隐私和可控性都有问题 | + +--- + +## 7. TMDB 匹配策略 + +### 7.1 模块级计划(`app/modules/themoviedb/__init__.py`) + +| 函数:行 | 行为 | +| --- | --- | +| `_build_recognize_plan` :217 | MUSIC 或非 TMDB 来源时返回 None;`media_id` 必须是数字 | +| `_validate_recognize_params` :190 | 只有 meta、没有 id,且全局 `RECOGNIZE_SOURCE` 不是 TMDB 时不识别;meta 没有 name 时不识别 | +| `_prepare_search_names` :280 | 查询名的顺序:`cn_name` → 简体化的 cn_name(zhconv)→ `en_name`,去重去空,**找到第一个结果就停** | +| `_build_match_plan` :537 | ① 类型未知且没有年份时只做 `match_multi`;② TV 时先按 `(name, year, TV, season_year=year, season_number=begin_season, 剧集组)` 匹配,再按 `(name, TV)`(不带年份);③ 其余情况(电影或未知但有年份)依次试电影带年份、TV 带年份、`match_multi` | +| `_get_info_by_tmdbid` :446 | 只有 id 没有类型时,**电影和 TV 各查一次**;两边都有结果时用 `_disambiguate_by_meta`(:483,标题包含关系 +2 分,年份相等 +1 分,平分时看 `meta.type`),仍无法判断就返回 None;每一路的连接错误分开记录,两路都无结果且至少一路是连接错误时抛出 `TMDbConnectionError`,**不把它当成"不存在"** | +| `_recognize_steps` :744 | 先读缓存;命中且有 `title` 时按缓存里的 id 重新拉详情(**缓存只存身份,不存详情**);结果没有 `genres` 时补一次详情 | +| `_build_media_info_base` :704 | 按 `category.py` 的规则计算二级分类,构造 `MediaInfo` | + +### 7.2 客户端匹配(`app/modules/themoviedb/tmdbapi.py`) + +| 函数:行 | 规则 | +| --- | --- | +| `_generate_year_range` :200 | **年份容差**:依次试 `[year, year+1, year-1]`;没有年份时只试 `[None]` | +| `_build_match_plan` :211 | 电影:对每个年份做 MOVIE_NAME 查询;TV:有 `season_year` 和 `season_number` 时先做 TV_SEASON,再对每个年份做 TV_NAME | +| `_search_kwargs` :149 | 电影传 `year`,TV 传 `release_year`(对应 TMDB 的 `first_air_date_year`) | +| `_build_name_candidate_plan` :395 | 结果按上映或首播日期降序排列;**有年份时只保留日期前 4 位等于该年份的候选**;`direct` = 第一个 `title` 或 `original_title`(TV 为 `name` 或 `original_name`)规范化后**完全相等**的候选 | +| `__compare_names` :130 | 规范化方法:`remove_punctuation` 后转大写,然后**精确相等**。没有模糊匹配 | +| `_candidate_resolution` :437 | 没有 direct 时,**依次对每个候选拉详情**(`get_info`,含 `alternative_titles` 和 `translations`),用 `names`(`__get_names` :513:别名加各语言译名)精确比较,第一个命中的胜出;都不命中时返回 `{}` | +| `__search_tv_by_season` :713 | 搜索时不带年份;direct 要求标题相等**且**首播年等于 season_year;别名回退时要求标题或别名相等,并且首播年相等,或者**该季的 `air_date` 年份**等于 season_year(`_season_year_matches` :628,有剧集组时看组内对应 order 的第一集 air_date) | +| `match_multi` :746 | multi 搜索只保留 movie 和 tv,排序键是 `("1" if movie else "0") + date` 降序(电影排前面),然后走同样的标题优先、别名回退 | +| `__get_movie_detail` :1046 | `append_to_response=images,credits,alternative_titles,translations,release_dates,external_ids` | +| `__update_tmdbinfo_cn_title` :961 | 标题不含中文时,取 `alternative_titles` 里 `iso_3166_1=CN`、含中文且已是简体的别名;没有就用新加坡译名 `sg_title` | +| `__update_tmdbinfo_extra_title` :1008 | 原语言是 en 时 `en_title=original`,否则取 US 译名;另外生成 `hk_title/tw_title/sg_title` | + +**TS 对应**:`packages/adapters/src/tmdb/client.ts`(HTTP、限流、重试、语言参数),`packages/domain/src/identity/tmdb-match.ts`(纯函数:候选排序、年份过滤、标题规范化、消歧) + +```ts +export function yearWindow(year?: number): (number | undefined)[] // [y, y+1, y-1] +export function normalizeTitleForCompare(s: string): string // 去标点 + 大写 +export function pickDirectCandidate(name: string, items: TmdbSearchItem[], kind: 'movie'|'tv', year?: number): TmdbSearchItem | undefined +export function aliasCandidatesOrder(items: TmdbSearchItem[], kind: 'movie'|'tv', year?: number): TmdbSearchItem[] +export function disambiguateById(tv: TmdbDetail, movie: TmdbDetail, meta: ParsedRelease): 'tv'|'movie'|'ambiguous' +export interface TmdbPort { searchMovie(q: string, year?: number): Promise; searchTv(q: string, firstAirYear?: number): Promise; + searchMulti(q: string): Promise; detail(kind: 'movie'|'tv', id: number): Promise } +// packages/tasks: resolveObservation 编排 → 结果写 identity_revision +``` + +| 保留 | 调整 | 原因 | +| --- | --- | --- | +| 查询名顺序、按类型分步计划、年份容差 ±1、标题精确匹配优先、别名回退、季年份匹配、无类型 id 的双查加消歧、连接失败和不存在分开 | ① 所有 direct 或 alias 结果**只作为候选**,按证据打分输出 `candidates[]`;分数够高并且只有一个候选时才自动绑定,否则进入待识别池(REQ §7.5);② 别名回退要**限制拉详情的次数**(例如前 5 个),避免上游那样对每个候选都请求一次;③ 已经存在本地实体时,先查 `title_alias`(pg_trgm)召回本地实体,再查 TMDB;④ TMDB 缺失或失败**不阻断**有本地证据的入库(REQ §14.1.3) | 本地 UUID 优先;控制成本;REQ 要求不因外部服务阻断 | +| 中文标题补全 | 放进元数据协调层(REQ §7.3 字段级来源),**不改变路径绑定** | REQ §7.1 | + +--- + +## 8. 识别缓存(`app/modules/themoviedb/cache.py`) + +| 项 | 上游行为(行) | +| --- | --- | +| 键 | `__get_key` :143:`[类型][TMDB_LOCALE]{tmdb_id 或 meta.name}-{year}-{begin_season}` | +| 值 | `{id, type, year, title, poster_path, backdrop_path}`,只有身份没有详情 | +| 正缓存 TTL | `META_CACHE_EXPIRE`,默认 24h(大内存模式 72h),容量 256 或 1024(`app/runtime/config.py:1157-1184`) | +| 负缓存 | `update(meta, {})` 写入 `{id: 0}`,TTL 取 `EMPTY_RESULT_CACHE_TTL`,默认 **30 分钟**(config.py:290);`info is None`(网络错误)**不写缓存** | +| 冲突保护 | `__is_type_conflicted` :151:键类型是 TV 而结果是电影时拒绝写入(反过来允许,因为电影分支查不到时会回退到 TV) | +| 持久化 | 非 Redis 时 pickle 到文件,带版本号和每条的过期时间;`save()` 只保存正缓存 | +| 共享回填 | `update_recognize_cache` (`__init__.py:1044`):共享识别成功后覆盖负缓存 | +| 旁路 | `recognize_media(cache=False)` 通过 `fresh()` 上下文让下游 HTTP 缓存失效 | + +**TS 对应**:`packages/db` 的 `recognition_cache` 表(或者直接复用 `observation` 的解析结果),`packages/adapters/src/tmdb/cache.ts` + +| 保留 | 调整 | 原因 | +| --- | --- | --- | +| 键的组成(类型、语言、名称或 id、年份、季)、正负缓存 TTL 分开、网络错误不缓存、TV 键拒存电影 | ① 缓存存 PG,带 `parser_version`;解析器升级后,旧键自动失效;② 人工纠错写 `identity_revision` 并**锁定**,不依赖缓存 TTL(REQ §5.3 "人工纠错的持续效力");③ TMDB 详情另外做 HTTP 层缓存(ETag 和 TTL),和身份缓存分开 | 人工锁定不能被缓存过期冲掉;PG 是唯一状态源(REQ §9.3) | + +--- + +## 9. 验证样本(实测,Python 参考路径,`e6820a8`) + +字段缩写:T=type,cn/en=名称,y=year,S=begin_season(-end),E=begin_episode(-end),tE=total_episode,rt=resource_type,fx=resource_effect,px=pix,grp=resource_team,web=web_source,v=video_encode,a=audio_encode。 + +### 9.1 发布标题(MetaInfo) + +| # | 输入 | 实测输出 | 覆盖分支 | TS 期望 | +| --- | --- | --- | --- | --- | +| M-01 | `Oppenheimer.2023.2160p.UHD.BluRay.REMUX.DV.HDR.HEVC.TrueHD.7.1.Atmos-FraMeSToR` | T=未知 en=Oppenheimer y=2023 rt=`UHD BluRay REMUX` fx=`HDR DV` px=2160p v=HEVC a=`TrueHD 7.1 Atmos` **grp=None** | 效果逆序、未知发布组 | 同上,但 grp=`FraMeSToR`(suffix) | +| M-02 | `奥本海默 Oppenheimer 2023 2160p UHD Blu-ray HEVC DV TrueHD 7.1-MTeam` | cn=奥本海默 en=Oppenheimer rt=`UHD BluRay` fx=DV grp=MTeam | 中英双名、`Blu-ray` 合成 | 一致 | +| M-03 | `The.Last.of.Us.S01.2023.1080p.WEB-DL.H264.DDP5.1.Atmos-ADWeb` | T=TV S=1 E=空 y=2023 a=`DDP 5.1 Atmos` grp=ADWeb | 整季包 | 一致;packHint=season_pack | +| M-04 | `The.Last.of.Us.S01E03.Long.Long.Time.2160p.MAX.WEB-DL.DDP5.1.Atmos.DV.HDR.H.265-FLUX` | S=1 E=3 web=Max v=H265 fx=`HDR DV` en=`The Last Of Us`(集名被丢弃) | 流媒体平台需要相邻的 WEB、`H.265` 拼接 | 一致 | +| M-05 | `Breaking.Bad.S01-S05.Complete.1080p.BluRay.x264-ROVERS` | S=1-5 total_season=5 | 多季包 | packHint=multi_season_pack | +| M-06 | `庆余年 第二季 Joy of Life S02 2024 2160p WEB-DL H265 60fps DDP5.1-HHWEB` | cn=庆余年 en=`Joy Of Life` S=2 fps=60 grp=HHWEB | 中文季词、fps | 一致 | +| M-07 | `庆余年.Joy.of.Life.S02E01-E06.2024.2160p.WEB-DL.H265.AAC-ADWeb` | S=2 E=1-6 tE=6 | 非文件的集区间 | packHint=multi_episode | +| M-08 | `The.Lord.of.the.Rings.The.Fellowship.of.the.Ring.2001.Extended.Edition.2160p.UHD.BluRay.x265.10bit.HDR.DTS-HD.MA.6.1-CHD` | `Extended.Edition` **消失**;v=`x265 10bit` a=`DTS-HD MA 6.1` grp=CHD | 剪辑版本丢失、DTS 连接 | cut=extended(新增) | +| M-09 | `Kill.Bill.Vol.1.2003.Part.1.1080p.BluRay.x264-CHD` | en=`Kill Bill Vol 1` part=`Part1` | part 拼接 | 一致 | +| M-10 | `Avatar.The.Way.of.Water.2022.2160p.WEB-DL.DDP5.1.Atmos.HDR10Plus.HEVC-CMCT` | fx=HDR10Plus grp=CMCT | HDR10+ | 一致 | +| M-11 | `奔跑吧 第十二季 20240426 第1期 1080p WEB-DL H264 AAC-HHWEB` | S=12 **E=1**(`第1期` 当作集号),`20240426` 被忽略 | 综艺期号 | 期号记为 `issue=1`,`airDate=2024-04-26`,episode 待映射 | +| M-12 | `奔跑吧.Keep.Running.S12.2024.04.26.1080p.WEB-DL.H264.AAC-HHWEB` | S=12 **E=空**,日期被 `DATE_RE` 删除,y=空 | **综艺单期会被当成整季包** | airDate=2024-04-26,packHint=single,**绝不能**判为整季 | +| M-13 | `向往的生活 第八季 全12期 2024 1080p WEB-DL` | **S=空**,tE=12 | `第八季 全` 的负向前瞻导致季号丢失 | S=8,tE=12 | +| M-14 | `[喵萌奶茶屋&LoliHouse] 葬送的芙莉莲 / Sousou no Frieren - 12 [WebRip 1080p HEVC-10bit AAC][简繁日内封字幕]` | MetaAnime:cn=葬送的芙莉莲 en=`Sousou No Frieren` E=12 S=空 grp=`喵萌奶茶屋@LoliHouse` video_bit=10bit rt=空 | 动漫分支 | 一致(S 按 1 推定并标记 inferred) | +| M-15 | `Frieren.S01E12.1080p.NF.WEB-DL.DDP2.0.H.264-VARYG` | web=Netflix a=`DDP 2.0` **grp=None** | NF 平台 | grp=VARYG(suffix) | +| M-16 | 主:`黑暗荣耀 The Glory 2022 S01 Complete 1080p NF WEB-DL x264 DDP5.1-HHWEB`;副:`黑暗荣耀 第一季 全8集 宋慧乔` | S=1 **tE=8**,E 为空 | 副标题"全N集" | 一致;整季包加上预期集数 8 | +| M-17 | `Dune.Part.Two.2024.2160p.UHD.BluRay.REMUX.HDR.HEVC.Atmos-MTeam` | en=`Dune Part Two`(Part 属于片名)a=Atmos | Part 门槛(需要先有 year 等) | 一致 | +| M-18 | `Some.Show.S02E05E06.1080p.WEB-DL-GRP` | S=2 E=5-6 | 双集 | 一致 | +| M-19 | `狂飙 The Knockout 2023 全39集 1080p WEB-DL H264 AAC-OurTV` | T=TV S=空 tE=39 | 无季号的全集包 | S 推定为 1,packHint=season_pack | +| M-20 | `Some.Show.2019.1080p.WEB-DL-GRP`,副:`第1-10集 全10集` | E=1-10 tE=10 | 副标题集区间 | 一致 | +| M-21 | `[Nekomoe kissaten][Oshi no Ko][01-11 Fin][1080p]` | S=1 E=1-11 grp=`Nekomoe kissaten` | `Fin` 区间 | 一致 | +| M-22 | `Show.Name.2019-2020.1080p.WEB-DL` | y=2019,没有集号 | 年份范围 | 一致 | + +### 9.2 路径(MetaInfoPath) + +| # | 路径 | 实测输出 | 覆盖分支 | TS 期望 | +| --- | --- | --- | --- | --- | +| P-01 | `/downloads/The.Last.of.Us.S01.2023.1080p.WEB-DL…-ADWeb/The.Last.of.Us.S01E03.1080p.WEB-DL…-ADWeb.mkv` | S=1 E=3 y=2023(year 从目录补入) | 文件优先、目录补空 | 一致 | +| P-02 | `/downloads/庆余年 第二季 Joy of Life S02 2024 2160p WEB-DL H265 DDP5.1-HHWEB/01.mp4` | 名称、S=2、y 来自目录,E=1 | 纯数字文件名 | 一致 | +| P-03 | 同一目录下的 `第02集.mkv` | E=2,其余来自目录 | 中文集号文件名 | 一致 | +| P-04 | `/downloads/Breaking.Bad.S01-S05.1080p.BluRay.x264-ROVERS/S03/07.mkv` | 名称来自祖父目录,S=3 来自父目录,E=7 | 两级父目录 | 一致 | +| P-05 | `…/Oppenheimer.2023.2160p.UHD.BluRay.REMUX-FraMeSToR/Featurettes/Making.Of.mkv` | en=`Making Of` y=2023(**附加内容被识别成名为 Making Of 的电影**) | 附加内容没有特殊处理 | 由 `Featurettes/Extras/SP/花絮` 目录规则判为 extra,归属父电影(REQ §5.5) | +| P-06 | `…/Oppenheimer.2023.1080p.BluRay.x264-CHD/Oppenheimer.2023.1080p.BluRay.x264-CHD.chs.srt` | 与视频相同的 meta | 字幕后缀也被当作 isfile | 字幕由附属文件配对决定归属,不单独识别 | +| P-07 | `/downloads/The Glory 2022 S01 1080p NF WEB-DL-HHWEB/简体.srt` | **en=`Downloads`**(辅助词清空名称后,父目录是 TV 不合并,结果取到了根目录名) | merge 条件的缺陷 | 按附属文件处理,不产生身份 | +| P-08 | `/downloads/奔跑吧 第十二季 1080p WEB-DL-HHWEB/奔跑吧.20240426.第1期.mp4` | S=12 E=1 | 期号 | issue=1,airDate=2024-04-26 | +| P-09 | 同一目录下的 `奔跑吧.2024.05.03.mp4` | **T=未知,没有季号**(日期删除后文件无类型,TV 目录不合并) | 综艺日期命名 | T=TV S=12 airDate=2024-05-03,episode 待按日期映射 | +| P-10 | 同一目录下的 `奔跑吧.20240503.加更版.mp4` | T=未知 | 加更版 | 标记为 extra(加更),关联 airDate | +| P-11 | `/downloads/Kill.Bill.2003.1080p.BluRay.x264-CHD/CD1.mkv` | 名称和年份来自目录,**part 丢失**(`CD1` 这个词元出现时还没有名称,Part 门槛不满足) | 分盘 | part=CD1 | +| P-12 | `/media/tv/庆余年 (2019)/Season 2/庆余年 - S02E03 - 第 3 集.mkv` | S=2 E=3 y=2019 | 媒体库目录回扫 | 一致(旧库登记) | +| P-13 | `…/[LoliHouse] Sousou no Frieren [01-28][WebRip…]/[LoliHouse] Sousou no Frieren - 13 [WebRip…].mkv` | E=13 S=空 | 动漫文件 | 一致 | + +### 9.3 显式标签与识别词 + +| # | 输入 | 实测输出 | +| --- | --- | --- | +| W-01 | `House.of.the.Dragon.S01E03.1080p [tmdbid=94997].mkv` | media_source=themoviedb media_id=94997 S=1 E=3 | +| W-02 | `{[tmdbid=94997;type=tv;s=1;e=3-4]}House.of.the.Dragon.1080p.mkv` | S=1 E=3-4,标签覆盖推断 | +| W-03 | `[Sub] Kimetsu no Yaiba - 45 [1080p].mkv` + `Kimetsu no Yaiba <> \[1080p\] >> EP-44` | E=1,apply_words 记录了这条规则 | +| W-04 | `鬼灭之刃 第45集 1080p.mkv` + `鬼灭之刃 => 鬼灭之刃 第四季`、`第 <> 集 >> EP-44` | E=1,**S=空**("四"也被偏移了) | +| W-05 | `Some.Show.S01E13.1080p.WEB-DL.mkv` + `Some.Show.S01 => Some.Show.S02 && S02E <> .1080p >> EP-12` | S=2 E=1 | +| W-06 | `Oppenheimer.2023.1080p.WEB-DL-FraMeSToR.mkv` + 屏蔽词 `FraMeSToR` | 屏蔽成功,名称不变,grp=None | + +**parity 测试建议**:`packages/domain/test/title.parity.test.ts` 读取 `packages/testkit/fixtures/title-parity.json`(输入加上本表的"TS 期望")。在 `packages/testkit/scripts/` 下另外放一个**可选**脚本,调用本机 MP 的 Python 生成"上游实际值",只用于对比,不作为断言源。上游 `tests/cases/meta.py`(67 条)可以作为补充输入语料,需要先在 ledger 登记(标题字符串属于事实数据,但整份文件不直接拷贝进仓库)。 + +--- + +## 10. 刮削(NFO、图片) + +| 上游 | 行为 | 本项目 | +| --- | --- | --- | +| `app/chain/scraping.py:148 ScrapingOption`、`:243 get_default_config` | 按 `(movie|tv|season|episode)_(nfo|poster|backdrop|logo|disc|banner|thumb|clearart|landscape)` 逐项配置策略 `skip/missingonly/overwrite/upgrade`,默认 `missingonly` | 保留"逐项策略"的结构,放在 `policy_revision` 下的目录策略中(REQ §14.1.3:"NFO/海报写入受目录策略控制") | +| `:320 _should_scrape` | skip 时不写;文件不存在时写;文件存在时,只有 overwrite 或全局 overwrite 才写 | 保留;另外增加一条:**用户或其他工具已修改的 NFO(含 `true` 或 hash 与上次写入不一致)不覆盖** | +| `:356 _save_file` | 先写临时文件,chmod `0666 & ~umask`,再经存储链上传 | 保留"临时文件加原子替换" | +| `app/modules/themoviedb/scraper.py:35 get_metadata_nfo` | 电影、剧、季、集四种 NFO;季和集走 `get_tv_season_detail`,有剧集组时走 `get_tv_group_detail`;标签包括 `uniqueid(tmdb default, imdb, tvdb)`、`plot`、`genre`、`actor`、`director`、`studio`、`country`、`mpaa`、`rating`、`runtime` | `packages/adapters/src/nfo/writer.ts` 输出 Kodi/Emby 兼容 NFO;**另写一个 `uniqueid type="uhd"`,内容为本地 UUID** | +| 失败语义 | 刮削在整理后执行,失败只记日志,不回滚转移 | 保留"不重做搬运";改为**独立任务 `refreshMetadata`,有自己的状态和重试**,不能伪报全部步骤成功(REQ §14.1.3) | + +--- + +## 11. 综艺与日期编号:现状结论 + +| 问题 | 上游现状(实测) | 本项目要求 | +| --- | --- | --- | +| `2024.05.03` 或 `2024-05-03` | `DATE_RE` 在分词前删除,**没有 air_date 字段** | 解析成 `airDate`,删除前先保存 | +| `20240503`(8 位连写) | 不是年份(位数不对),也不是集号(位数 >3),被忽略 | 在 1990 到 2100 年、月日合法时识别为 airDate | +| `第N期` | 和 `第N集` 一样当作 episode | 存为 `issue`,和 episode 分开(REQ §5.1 "内容分类与集号分开建模") | +| 上/下、加更、会员版、纯享版 | 不识别(`加更版` 只进名称或被丢弃) | 识别为 `segment` 或 `extra` 标记(REQ §5.5) | +| 文件只有日期、目录是季 | 文件无类型,不与 TV 目录合并,最终识别失败 | 按 §2.3 的调整合并,进入按日期匹配 TMDB `air_date` 的流程;匹配不到时进入待识别池,**不猜集号** | +| 季号 + 无集号 + 日期 | 被当作整季包(M-12) | 有日期就判为单期 | + +--- + +## 12. 给 packages/domain 实现者的清单 + +1. 解析器是纯函数,输入只有 `{title, subtitle?, pathSegments?, words?, releaseGroups?, platforms?, mediaExts}`,**不读全局配置**。 +2. 词元阶段的优先级、"已有名称才识别"的门槛、`stop_name` 语义,逐条对照 §3.2 实现,并用 §9 的样本逐字比对。 +3. 所有和上游不同的地方(M-01、M-08、M-11 到 M-13、M-15、P-05 到 P-11、W-04)都要在测试里显式写出 `deviation: '<原因>'`。 +4. `ParsedRelease` 在上游字段的基础上增加:`airDate`、`issue`、`cut`、`packHint`、`releaseGroup.source`、`evidence[]`、`parserVersion`。 +5. 不引入 `eval` 或 `new Function`;用户正则进库前先校验,并设长度和时间上限。 diff --git a/docs/porting/moviepilot-transfer.md b/docs/porting/moviepilot-transfer.md new file mode 100644 index 00000000..e9847ccf --- /dev/null +++ b/docs/porting/moviepilot-transfer.md @@ -0,0 +1,421 @@ +# MoviePilot 视频文件转移入库:迁移对照记录 + +> 格式:REQ §14.1.2 迁移交付规范。本文件只记录行为对照和 TS 重写建议,**不复制 MP 源码**(MP 为 GPLv3,见 `license-and-provenance.md`)。 +> 对应需求:REQ §7.1 / §7.1.1(稳定路径)、§8.3(Copy/Move 与缓存清理)、§8.4(qB 标签接管)、§14.1.1(多文件/整季包/目标冲突/中断恢复/源数据清理专项)、§14.1.3(转移入库优先专项)。任务书:`docs/plan/00-first-version-plan.md` §3.1 第 7、8 条。 + +## 0. 上游版本与核验范围 + +| 项 | 值 | +| --- | --- | +| 上游仓库 | `https://github.com/jxxghp/MoviePilot`,分支 `v3` | +| 本地路径 | `/opt/moviepilot-plus/upstream/MoviePilot` | +| **本次对照的精确提交** | `e6820a83ab05e8a00eb0e14adf0f357faa965c7e`(2026-08-30 13:32 +0800,"docs(architecture): close residual audit goal";`version.py` = `v3.0.0`) | +| 克隆形态 | 单提交浅克隆(`git log` 只有 1 条),无法回看历史 | +| 与 REQ 附录快照差异 | REQ 附录 [S16] 引用 `33da9ce65d5020a8e087584a3321c1ff353c1921`,**本地仓库不含该对象**(`git cat-file` 失败)。本文件所有行号只对 `e6820a83` 有效;若需对齐 33da9ce,须重新拉取并复核本文"关键分支"各行。 | +| LICENSE | GPLv3(本地 `LICENSE` 首行已核对) | +| 核验方式 | 只读源码,沿调用链阅读;**未运行服务、未跑上游测试**。标注"推断"的条目为源码推理结论,须在 testkit 中用样本复现。 | + +V3 目录布局与旧版(V1/V2 `app/chain/transfer.py` 单文件)不同,本文件对应的实际路径如下: + +| 层 | 路径 | 职责 | +| --- | --- | --- | +| 入口/批次 | `app/chain/transfer/workflow.py` | `do_transfer` 公开入口、候选收集、任务构建、同步执行 | +| 候选规划 | `app/chain/transfer/request.py` | `_TransferCandidatePlanner`:文件元数据、主视频/附属文件排序与元数据继承 | +| 识别与定向 | `app/chain/transfer/execution.py` | `__perform_transfer`:识别媒体、补 TMDB、取集信息、选目标目录 | +| 冻结计划 | `app/chain/transfer/plan.py` | `_plan_checkpoint_and_execute`:先持久化计划检查点再执行 | +| 文件模块 | `app/modules/filemanager/module.py` | `plan_transfer` / `execute_transfer_plan`:解析目录配置 → 交给 TransHandler | +| 核心算法 | `app/modules/filemanager/transhandler.py` | 目标路径、覆盖裁决、步骤化执行、字幕改名、版本文件删除 | +| 本地存储 | `app/modules/filemanager/storages/local.py` | copy/move/link/softlink 的实际文件系统操作、临时文件 | +| 硬/软链接 | `app/adapters/system/host.py` | `SystemUtils.link` / `softlink` | +| 持久执行 | `app/application/transfer/execution.py`、`app/chain/transfer/execution.py` | 步骤账本状态机、`_DurableTransferStepRunner` | +| 准入与恢复 | `app/application/transfer/workflow.py`、`app/chain/transfer/queue.py` | `TransferPlanningInput`/`TransferPlanCheckpoint`、租约、`__replay_pending` | +| 结算 | `app/chain/transfer/settlement.py` | `__default_callback`:写历史、打"已整理"标签、Move 模式删种 | +| 历史 | `app/db/models/transferhistory.py`、`app/application/history.py` | `TransferHistory` 表、`add_transfer_success/fail`、查重闸 `evaluate_history_gate` | +| 过滤/清理条件 | `app/chain/transfer/filter.py`、`app/chain/transfer/history.py` | 屏蔽词、`_can_delete_torrent`、空目录清理、覆盖拒绝判定 | +| 附属文件归属 | `app/chain/transfer/records.py` | 文件键、字幕主干名、下载历史回查 | + +--- + +## 1. 端到端调用链(自动整理:下载完成 → 入库) + +``` +下载器轮询/目录监控 + └─ TransferChain.do_transfer chain/transfer/workflow.py:350 + └─ _run_transfer_workflow workflow.py:418 + ├─ _normalize_transfer_identity workflow.py:221 显式 media_source/media_id 必须成对 + ├─ _collect_transfer_candidates workflow.py:273 遍历 + 过滤(后缀/大小/屏蔽词/回收站) + │ └─ __get_trans_fileitems workflow.py:62 蓝光原盘目录整体作为一个候选 + ├─ _TransferCandidatePlanner._plan_file_items request.py:239 主视频优先,同名字幕/音轨紧随并继承元数据 + ├─ _build_transfer_tasks workflow.py:651 历史查重闸、回查下载历史、构建 TransferTask + │ └─ put_to_queue / __put_to_jobview queue.py:356 后台 → 持久准入(admission)+内存队列 + └─ _execute_transfer_tasks workflow.py:935 同步路径逐任务执行 + └─ __handle_transfer → __perform_transfer chain/transfer/execution.py:290/304 + ├─ 识别媒体(下载历史 ID 优先 → 按 meta 识别) + ├─ supplement_tmdb_info / scrape_follow_tmdb 标题回溯 execution.py:403/408 + ├─ tmdb_episodes(集信息,供命名) execution.py:443 + ├─ DirectoryHelper.get_dir(选目录配置) execution.py:453/460 + └─ _plan_checkpoint_and_execute plan.py:526 + ├─ FileManagerModule.plan_transfer modules/filemanager/module.py:417 + │ └─ TransHandler.plan_transfer transhandler.py:263 纯计算,零副作用 + ├─ __persist_transfer_checkpoint plan.py:701 计划落库(带输入指纹) + └─ FileManagerModule.execute_transfer_plan module.py:559 + └─ TransHandler.execute_transfer_plan transhandler.py:1085 + ├─ __resolve_overwrite_with_step :989 (decision/resolve_overwrite) + ├─ __intercept_with_step :1054 (decision/plugin_transfer_intercept) + ├─ __cleanup_with_step :908 (prepare/cleanup_previous_destination) + ├─ __ensure_directory_with_step :875 (prepare/ensure_target_directory) + ├─ __delete_version_files_with_steps:1847 (prepare/discover_version_targets, delete_version_target) + ├─ __delete_target_with_step :960 (prepare/delete_overwrite_target) + └─ __execute_transfer_with_steps :786 (transfer/materialize_target, transfer/delete_move_source) + └─ __transfer_command :1404 → LocalStorage.copy/move/link/softlink + └─ 结算:TransferChain.__default_callback chain/transfer/settlement.py:255 + ├─ add_transfer_success / add_transfer_fail application/history.py:1366/1421 + ├─ __mark_torrent_completed_if_done → qB 标签“已整理” settlement.py:620 + └─ move 模式 + 全部成功 → _can_delete_torrent → remove_torrents(delete_file=True) settlement.py:470-513 +``` + +--- + +## 2. 模块对照 + +### 2.1 候选文件收集与过滤 + +| 项 | 内容 | +| --- | --- | +| 上游 | `app/chain/transfer/workflow.py` `_collect_transfer_candidates`(:273)、`__get_trans_fileitems`(:62);`app/chain/transfer/filter.py` `_is_media_file`(:125)、`_is_allow_filesize`(:354)、`_is_subtitle_file`(:101)、`_is_audio_file`(:111);`app/chain/transfer/history.py` `_is_blocked_by_exclude_words`(:286) | +| 输入 | 源 `FileItem`(文件或目录)、批次媒体类型、`min_filesize`(MB)、自定义集数模板 `EpisodeFormat`、屏蔽词列表(系统配置 `TransferExcludeWords`) | +| 输出 | `List[(FileItem, is_bluray_dir)]` + 是否命中过集数模板 | +| 关键分支 | ① 路径含 `BDMV/STREAM` → 回溯到 BDMV 的父目录作为**一个**目录候选(`is_bluray_dir=True`);② 目录的直接子项含原盘结构 → 整个目录作为一个候选,不递归;③ 普通目录 → 递归,所有叶子文件各自成为候选;④ 过滤:蓝光目录/字幕/音轨不受"媒体后缀+最小体积"约束,其余必须命中 `RMT_MEDIAEXT` 且 ≥ `min_filesize`;⑤ 路径含 `/@Recycle/`、`/#recycle/`、`/.`(任意隐藏段)、`/@eaDir` 一律排除;⑥ 屏蔽词为**正则**,对完整路径 `re.search(..., IGNORECASE)`;⑦ 设置了集数模板但没有文件匹配 → 返回成功且不整理(`workflow.py:558` "未匹配到集数定位模板,跳过整理") | +| 默认后缀 | `RMT_MEDIAEXT`:.mp4 .mkv .ts .iso .rmvb .avi .mov .mpeg .mpg .wmv .3gp .asf .m4v .flv .m2ts .strm .tp .f4v;`RMT_SUBEXT`:.srt .ass .ssa .sup;`RMT_AUDIOEXT`:.aac .ac3 .m4a … (`app/runtime/config.py:345-380`) | +| 对应 TS | `packages/domain/src/organize/candidates.ts`
`export function collectImportCandidates(tree: FileTreeSnapshot, opts: CandidateOptions): ImportCandidate[]`
`export function isBlurayRoot(entries: FileEntry[]): boolean` | +| 保留行为 | 原盘目录整体搬运;隐藏/回收站路径排除;字幕/音轨不受体积下限;屏蔽词作用于完整相对路径 | +| 调整项 | ① 文件树由 adapters 先生成只读快照(路径、size、mtime、inode/dev)再交给 domain,domain 不做 IO(任务书 §2 约定);② 屏蔽词改为结构化规则(正则需预编译 + 超时保护),命中结果写入 `organization_file_result.skip_reason`,不静默丢弃(REQ §14.1.1"跳过文件如何记账");③ 候选以 torrent 文件清单为准(qB `files` API)而不是只扫目录,防止同一共享保存根下的其他种子文件被误纳入(REQ §8.3 共享保存根) | + +### 2.2 主视频 / 附属文件排序与元数据继承(多文件、整季包) + +| 项 | 内容 | +| --- | --- | +| 上游 | `app/chain/transfer/request.py` `_TransferCandidatePlanner._plan_file_items`(:239)、`_build_directory_index`(:181)、`_get_single_file_sibling_items`(:206)、`_apply_meta_overrides`(:108);`app/chain/transfer/records.py` `_get_related_main_file_key`(:309)、`_get_subtitle_media_stem`(:284)、`SUBTITLE_STEM_TAGS`(:33);`_is_same_media_meta`(约 :255) | +| 输入 | 候选列表、批次 meta(可空)、手动季号 `season`、集数模板 `FormatParser`、`sync_extra_files` 开关 | +| 输出 | 有序列表(主视频 → 其同名附属 → 剩余文件)+ `inherited_meta_map`(附属文件键 → 主视频 meta 副本) | +| 关键分支 | ① 同目录内按主干名(小写 stem)精确匹配:字幕会逐级剥掉 `.chi`/`.chs`/`.cht`/`.default`/`.cn`/`.cc` 等语言标签后再比;**唯一**匹配才关联(多个主视频同 stem → 不关联);② 关联上的附属文件直接复用主视频 meta(季集、身份一致);③ 未关联的附属文件仅在 `sync_extra_files=True` 且非单文件模式时,单独按路径解析 meta 并用 `_is_same_media_meta` 比较(类型、季、集、年份等"任一侧有值则必须相等"),相同才跟随;④ 单文件整理:额外读取一次父目录找同名字幕/音轨;若用户选中的是字幕本身,则找其主视频的 meta 继承;⑤ 剩余文件按原顺序最后处理,各自独立识别 | +| 整季包行为 | 每个视频文件都是**独立任务**(`TransferTask`),各自识别季集、各自冻结计划、各自写一条 `TransferHistory`;没有"整季包事务"。部分成功就部分入库,失败文件单独失败记录 | +| 对应 TS | `packages/domain/src/organize/sidecars.ts`
`export function pairSidecars(files: ParsedFile[]): { mains: MainFile[]; sidecars: SidecarLink[]; orphans: ParsedFile[] }`
`export function subtitleMediaStem(name: string): string` | +| 保留行为 | 主干名精确匹配 + 唯一性要求;字幕语言标签剥离表;附属文件继承主视频 meta;逐文件独立结果 | +| 调整项 | ① 无法确认归属的附属文件进入 `orphans`,**不搬也不删**,记录为待处理(REQ §14.1.3"无法确认归属时不误搬或误删");MP 会把剩余文件当独立任务尝试识别,可能把孤立字幕按自身文件名识别到别的作品;② 整季包在本项目额外生成 `organization_record`(批次)+ `organization_file_result`(逐文件),批次状态 = 聚合,部分成功显式为 `partial`;③ 附属文件关联结果写回 `asset_location` 的 sidecar 关系,便于后续删除/迁移时一起处理 | + +### 2.3 识别、目标目录选择与集信息 + +| 项 | 内容 | +| --- | --- | +| 上游 | `app/chain/transfer/execution.py` `__perform_transfer`(:304);`app/chain/transfer/workflow.py` `_build_transfer_tasks`(:651,下载历史回查、电影合集年份冲突);`app/chain/transfer/records.py` `_resolve_download_history`(:131)、`_is_movie_year_conflict`(:186);`app/application/directory.py` `DirectoryHelper.get_dir`(:117) | +| 输入 | `TransferTask`(fileitem、meta、可选 mediainfo、下载历史、目标目录配置) | +| 输出 | 补全后的 `mediainfo`、`episodes_info`、`target_directory`、`target_storage` | +| 关键分支 | ① 下载历史回查顺序:显式 hash → 原盘目录路径 → 文件 fullpath 记录 → 逐级父目录(遇到**共享下载根**只接受明确文件记录匹配,然后停止上溯,`workflow.py:150 _get_shared_download_roots`);② 下载历史有 `media_source+media_id` 且无年份冲突 → 按 ID 识别;否则按文件名 meta 识别;③ 电影合集:文件年份 ≠ 下载历史年份 → 放弃历史 ID,逐文件重新识别;④ 识别失败 → 写"未识别到媒体信息"的计划拒绝检查点并结算为失败;⑤ **`SCRAPE_FOLLOW_TMDB=False` 且主源为 TMDB 时,用该 TMDB ID 首条整理历史的 title 覆盖当前 title**(`execution.py:408-417`)——这是 MP 自己的"译名变化不改目录"机制;⑥ 电视剧取 `tmdb_episodes(tmdbid, season 默认1, episode_group)` 供命名模板用;⑦ 目录配置按"源目录匹配 → 同盘优先 → 媒体类型/类别"选取;⑧ 目录启用了类别子目录但 mediainfo 无 category → 拒绝 | +| 对应 TS | 识别本身见 `moviepilot-metadata-recognition.md`。此处:`packages/domain/src/organize/target-binding.ts`
`export function resolveTargetBinding(input: { entity: MediaEntityRef; unit: ContentUnitRef; existing: PathBinding[]; roots: LibraryRoot[]; template: TemplateRevision }): TargetBindingDecision` | +| 保留行为 | 下载历史优先于文件名;共享下载根上溯截止;电影合集年份冲突逐文件识别;"标题回溯到首次入库名"的思想 | +| 调整项 | ① MP 用"历史 title 覆盖"间接稳定目录,本项目改为**显式 `path_binding`**:作品/季目录一旦绑定,后续入库直接用绑定的 `library_root_id + relative_path`,不再经过标题渲染(REQ §7.1);② 两个历史目录并存时 MP 会随便命中首条历史,本项目建冲突任务、不自动合并(REQ §7.1 关键场景);③ 识别失败不阻断:本地证据充分时进入临时 `content_unit`/待识别池,而非直接失败(REQ §14.1.3) | + +### 2.4 目标路径与重命名模板 + +| 项 | 内容 | +| --- | --- | +| 上游 | `transhandler.py` `plan_transfer`(:263)、`get_rename_path`(:1961)、`get_naming_dict`(:1796)、`get_dest_dir`(:1757)、`get_dest_path`(:1741)、`__get_tv_bluray_dir_path`(:72)、`__rename_subtitles`(:1614);`app/application/directory.py` `get_media_root_path`(:194);命名上下文 `app/application/messaging/message.py` `TemplateContextBuilder.build`(:111,字段见 :250-415);默认模板 `app/runtime/config.py:522-533` | +| 输入 | 目标根目录、Jinja2 模板、meta、mediainfo、episodes_info、文件扩展名 | +| 输出 | `final_target_path`(文件)或目录目标;冻结在 `TransferPlanCheckpoint` | +| 目录结构 | `library_path` [/ 类型(自动=mediainfo.type 或手动 media_type)] [/ 类别(自动=mediainfo.category 或手动)] / 模板渲染结果(`get_dest_dir` :1757) | +| 默认模板 | 电影:`{{title}} ({{year}})/{{title}} ({{year}})[-{{part}}][ - {{videoFormat}}]{{fileExt}}`;剧集:`{{title}} ({{year}})/Season {{season}}/{{title}} - {{season_episode}}[-{{part}}][ - 第 {{episode}} 集]{{fileExt}}` | +| 模板变量 | `title` `en_title` `original_title` `year` `title_year` `season` `season_fmt` `season_year` `episode` `season_episode` `total_episodes` `episode_title` `episode_date` `part` `customization` `fps` `resourceType` `effect` `edition` `videoFormat` `resource_term` `releaseGroup` `videoCodec` `videoBit` `audioCodec` `webSource` `tmdbid` `imdbid` `doubanid` `category` `type` `fileExt` 等;`media_source/media_id` 在命名场景被主动剔除(:1814) | +| 关键分支 | ① `renaming=False` → 目标 = 根/原文件名(不改名);② 手动指定 `target_path` 时强制 `need_rename=True`、`overwrite_mode="never"`、`notify=False`(`module.py:487-492`);③ 电视剧单文件:`begin_episode is None` → 若文件名命中特典正则(NCOP/NCED/OP/ED/MENU/PV/CM/TRAILER/SP/OVA/OAD/…,`__is_special_extra_file` :220)则**成功跳过**,否则抛"未识别到文件集数";④ 电视剧多集文件:`total_episode > 2` 时被强制截为单集(`end_episode=None`),即 E01-E03 只按 E01 命名;E01-E02 保留区间(:387-389);⑤ 电视剧原盘目录:渲染路径的父目录 + `Disc N`(从 part / 目录名 `Disc|Disk|DVD|CD n` / `S01D02` 提取,否则用原目录名);⑥ 电影原盘目录:`get_media_root_path` 按模板层数反推"标题所在层"作为目录目标;⑦ 字幕:按文件名识别语言 → 追加 `.zh-tw` / `.chi.zh-cn` / `.ja` / `.eng`,与 `DEFAULT_SUB` 相同时再加 `.default`,繁中优先于简中判断;⑧ 非法字符 `\/:*?"<>|` 转全角(`__convert_invalid_characters` message.py:520);⑨ 插件事件 `TransferRenameBuild`/`TransferRename` 可改写上下文或最终字符串 | +| 对应 TS | `packages/domain/src/organize/naming.ts`
`export function renderFileName(tpl: CompiledTemplate, ctx: NamingContext): string`
`export function subtitleLanguageSuffix(fileName: string, defaultLang: SubLang): string`
`export function sanitizePathSegment(s: string): string` | +| 保留行为 | 默认模板结构(作品/Season N/文件名)、字幕语言后缀与 `.default` 规则、全角替换、原盘 Disc 目录、特典文件无集号跳过 | +| 调整项 | ① **模板只在"首次建立绑定"时用于生成作品/季目录名**,已有 `path_binding` 时目录部分不渲染(REQ §7.1);② 文件名仍可按模板生成,但模板版本号写入 `path_binding.template_version`,模板变更不回溯改名;③ 不使用 Jinja2 任意表达式,改为受限模板(白名单变量 + `if` 片段),避免模板注入与不可预测输出;④ 多集文件不静默截断:`E01-E03` 保留区间命名并在 content_unit 覆盖关系中登记 3 集(REQ §5 集账本),MP 截断属于必须偏离项;⑤ 无集号且非特典的剧集文件进入待识别,不报错丢弃 | + +### 2.5 覆盖/冲突策略 `overwrite_mode` + +| 项 | 内容 | +| --- | --- | +| 上游 | `transhandler.py` `__resolve_overwrite`(:494)、`__resolve_overwrite_with_step`(:989)、`__find_version_files`(:1821)、`__delete_version_files_with_steps`(:1847);取值定义 `app/schemas/system.py:263`(`always/size/never/latest`)、`app/schemas/event.py:825` | +| 输入 | 源文件、目标路径、`overwrite_mode`、插件 `TransferOverwriteCheck` 事件结果 | +| 输出 | `(over_flag, delete_versions, failure)`,并作为 `decision/resolve_overwrite` 步骤结果冻结 | + +判定表(单文件;目录模式不走此判定,见下方"注意"): + +| 条件 | 结果 | +| --- | --- | +| 源是字幕/音轨(附属文件) | 无条件覆盖(`over_flag=True`) | +| 严格查询目标失败(`StorageQueryError`) | 失败"无法确认目标文件状态,已跳过整理以避免误覆盖" | +| 目标不存在 | 不覆盖;`delete_versions = (mode == "latest")` | +| 本地目标是断链软链接 | 覆盖 | +| 插件返回 overwrite=True / False | 覆盖 / 失败并 `overwrite_skipped=True` | +| `always` 或 `latest`(目标存在) | 覆盖(`latest` 此时**不**删其他版本) | +| `never` | 失败 "媒体库存在同名文件,当前覆盖模式为不覆盖",`overwrite_skipped=True` | +| `size` | 目标 size < 源 size → 覆盖;否则失败 "媒体库存在同名文件,且质量更好"(注意此分支**未**设 `overwrite_skipped`) | +| 其他/未设置(`None`) | 返回 `(False, False, None)`:既不覆盖也不失败 → 进入执行(见下"推断风险") | + +`latest` 的"其他版本"定义:同目录内、扩展名属 `RMT_MEDIAEXT`、按 `MetaInfoPath` 解析出**相同季、相同集**、且 part 不冲突的其他视频文件(:1821-1845)。先冻结候选清单(`discover_version_targets`),再逐个作为独立步骤删除。 + +| 项 | 内容 | +| --- | --- | +| 覆盖拒绝后的历史 | `filter.py` `_is_overwrite_declined`(:410):仅当 `overwrite_skipped` 且该源路径**已有成功历史**时,保留原成功记录、不写失败、不通知、不重试;否则照常写失败记录 | +| **推断风险(须复现)** | 持久路径(`step_runner` 非空)下,`overwrite_mode=None` 且目标已存在时:不删除目标、直接 `materialize_target` → 本地 copy 走 `_write_atomically` 最后 `os.replace(partial, dest)` 会**静默覆盖**;move 同盘 `os.replace(src, dest)` 同样覆盖;link 的 `shutil.move(tmp, dest)` 也覆盖;softlink `symlink_to` 会失败。旧同步路径 `__transfer_file`(:1672)有"已存在即失败"保护,持久路径没有。 | +| 对应 TS | `packages/domain/src/organize/conflict.ts`
`export type ConflictPolicy = "skip" | "replace_if_larger" | "replace" | "keep_both_as_slot";`
`export function decideConflict(src: FileFacts, dst: FileFacts | null, ctx: ConflictContext): ConflictDecision` — `ConflictDecision = { kind: "proceed" } | { kind: "replace"; requiresGrant: GrantKind } | { kind: "skip"; reason } | { kind: "attention"; reason }` | +| 保留行为 | 目标状态查询失败 → 不写;覆盖判定结果在执行前冻结;附属文件可随主视频覆盖;覆盖拒绝不毁掉原成功记录;"latest 删其他版本"只按同季同集同 part 限定 | +| 调整项 | ① **默认策略 = skip + 进入待处理**,任何覆盖都需要 `automation_grant` 或用户显式授权(REQ §14.1.1"不能因 MP 某个默认允许覆盖,就视为用户已授权");② 目标存在但内容 hash 相同 → 视为"已在库",登记关联而非失败;③ 未设置策略**不得**落入"不判定直接写",执行器写入必须使用 no-clobber 语义(`link()`+`unlink` 或 `copyFile(COPYFILE_EXCL)`/`rename` 前 `lstat` 校验),把 MP 的推断风险消除;④ `latest` 的"删除其他版本"与本项目多 Slot 冲突:1080p 与 4K 属于不同 Slot 的资产,**不得**因为同季同集就删除(REQ §6.1 双版本互不淘汰);升级只替换同一 Slot 的旧资产,旧文件先移入隔离区而非直接删除;⑤ `size` 以体积判断质量不可靠,本项目改为由 Slot 满足度/升级阈值给出决策,conflict 模块只负责文件层 | + +### 2.6 转移模式与文件系统执行(copy / move / link / softlink、跨盘、临时文件) + +| 项 | 内容 | +| --- | --- | +| 上游 | `transhandler.py` `__transfer_command`(:1404)、`__execute_transfer_with_steps`(:786);`storages/local.py` `copy`(:405)、`move`(:416)、`link`(:448)、`softlink`(:459)、`_write_atomically`(:278)、`_partial_path`(:244)、`_cleanup_stale_partials`(:257);`app/adapters/system/host.py` `SystemUtils.link`(:500)、`softlink`(:518);`app/adapters/system/fsproxy.py` `_direct_copy`(:198) | +| 输入 | 源 FileItem、目标路径、`transfer_type` ∈ {copy, move, link, softlink}(`schemas/system.py:261`)、源/目标存储 | +| 输出 | 目标 FileItem(本地:重新 stat 得到 size/mtime) | + +行为矩阵(本地 → 本地): + +| 模式 | 实现 | 临时文件 | 跨文件系统 | 失败时 | +| --- | --- | --- | --- | --- | +| copy | `_write_atomically`:同目录写 `.{name}.{pid}.mp-partial`,分块复制(可取消),`utime` 复制时间戳,`os.replace` 到最终名 | 有,同目录隐藏 + 专用后缀;每次写入前清理该目录下超过 24h 的残留 partial | 无差别(总是复制) | 删除 partial,返回 False | +| move | 先 `os.replace(src, dest)`;任何 `OSError`(含 EXDEV)→ 降级为 `_write_atomically` 复制,成功后 `src.unlink()`;**unlink 失败只 warn,仍返回成功** | 仅跨盘降级时有 | 自动降级为"复制 → 删源" | 复制失败返回 False,源保留 | +| link | `SystemUtils.link`:`dest.mp` 临时名 `hardlink_to(src)`,再 `shutil.move(tmp, dest)` | `.mp` 后缀(**非隐藏**) | EXDEV → 返回 -1,整理失败,**不降级** | 失败 | +| softlink | `dest.symlink_to(src)` | 无 | 可跨盘 | 目标已存在即失败 | + +其他存储组合(:1465-1612):本地→网盘 copy=上传、move=上传后删源;网盘→本地=下载到目标目录再 `SystemUtils.move`;同一网盘用存储自身 copy/move/link;两个不同网盘之间不支持。 + +跨存储 move 在持久路径下拆成两步:`materialize_target`(按 copy 执行)→ `delete_move_source`(:797-866),中间可被中断并按观察结果恢复。 + +| 项 | 内容 | +| --- | --- | +| 校验 | **没有内容校验**:copy 完成的判断只有"分块读写无异常"(`fsproxy._direct_copy` 无 size 比对、无 fsync、无 hash);`materialize_target` 的恢复观察对本地 copy 用 `filecmp.cmp(shallow=False)` 逐字节比较(`__observe_transfer_operation` :701),只在重启恢复时发生 | +| 对应 TS | `packages/adapters/src/fs/local-transfer.ts`
`export async function copyToTarget(src: string, dst: string, opts: { signal: AbortSignal; onProgress?: (b: number) => void }): Promise` — `CopyReceipt = { bytes: number; sha256?: string; partialPath: string }`
`export async function renameNoClobber(src: string, dst: string): Promise<"renamed" | "exdev" | "exists">`
`export async function hardlinkNoClobber(src: string, dst: string): Promise<"linked" | "exdev" | "exists">`
`export async function verifyFile(dst: string, expect: { size: number; sha256?: string }): Promise`
`export async function sweepStalePartials(dir: string, olderThanMs: number): Promise` | +| 保留行为 | 同目录临时文件 + 原子 rename;partial 命名隐藏 + 专用后缀(本项目用 `.uhd-partial`);定期清理 24h 以上残留;复制保留 mtime、不覆盖目标目录继承的权限/ACL(`_copy_with_target_permissions` :324);跨存储 move 拆两步且先落目标后删源;同盘 move 优先 rename | +| 调整项 | ① copy 完成后必须 `fsync(file)` + `fsync(dir)` 并做 size 校验,可选 sha256(REQ §8.3"校验并提交后登记");② move 跨盘:MP 由 EXDEV 隐式降级为复制;本项目要求 **EXDEV 显式返回**,由 `verifyAndImport` 记录"跨卷搬运"并走可恢复的三步(copy → verify → delete source),日志和 `asset_operation` 可见(REQ §8.3 "不能假定 rename 原子成立"、"EXDEV 不得静默改变策略");③ 删源失败**不能**报成功:进入 `attention` 并保留重试步骤;④ link EXDEV 不降级(与 MP 一致),但作为"兼容登记",首期主工作流不使用 link/softlink(REQ §8.3);⑤ 硬链接产物登记 `inode/dev`,共享 inode 时禁止原地修改(REQ §8.3 [S12]);⑥ 所有写入 no-clobber,见 2.5 | + +### 2.7 步骤账本与中断恢复 + +| 项 | 内容 | +| --- | --- | +| 上游 | `app/application/transfer/execution.py`:`TransferExecutionState`(:18)、`TransferStepState`(:29)、`TransferOperationObservationState`(:54)、`TransferManualReviewDecision`(:63)、`build_transfer_operation_id`(:94)、`build_transfer_checkpoint_fingerprint`(:128)、`TransferExecutionCommand`(:646);`app/chain/transfer/execution.py` `_DurableTransferStepRunner.run`(:175)、`_execute_attempt`(:100)、`checkpoint`(:256);`transhandler.py` 各 `observe` 函数(:647-785);`app/chain/transfer/queue.py` `__replay_pending`(:751)、`__queue_planned_replay`(:1061)、`__queue_accepted_replay`(:1004);`app/chain/transfer/plan.py` `__restore_settling_transfer_result`(:376) | +| 输入 | 冻结计划 `TransferPlanCheckpoint`(指纹)、任务 ID、租约 token | +| 输出 | 每一步持久记录(prepared → started → succeeded/failed/manual_review);最终 `TransferExecutionCheckpoint`(outcome ∈ succeeded/failed/overwrite_skipped + TransferInfo) | + +状态与判定: + +| 概念 | 取值 / 规则 | +| --- | --- | +| 任务执行态 | `not_started` `running` `retry_wait` `settling` `failed` `manual_review` | +| 步骤态 | `prepared` `started` `succeeded` `failed` `manual_review` | +| 操作 ID | `sha256(canonical_json{schema_version, task_id, checkpoint_fingerprint, ordinal, phase, kind, intent_payload})`——同一计划重放得到相同 ID | +| 步骤种类 | decision:`resolve_overwrite`、`plugin_transfer_intercept`;prepare:`cleanup_previous_destination`、`ensure_target_directory`、`discover_version_targets`、`delete_version_target`、`delete_overwrite_target`;transfer:`materialize_target`、`delete_move_source` | +| 已 succeeded 的步骤 | 直接返回持久结果,不再访问外部存储(`__restore_step_item` :641) | +| 遗留 started(崩溃) | 先 `observe()`:APPLIED → 以观察证据补记成功;NOT_APPLIED → 重新执行;UNKNOWN/CONFLICT → `manual_review`,**禁止自动重放** | +| 执行异常 | 先 observe;APPLIED → 记成功;NOT_APPLIED 且重试次数 < `TRANSFER_MAX_FAILED_RETRIES`(默认 3,`config.py:518`)→ `retry_wait`,固定 30s 后重试;达到上限 → exhaust(失败终态);其余 → manual_review | +| move 观察 | 源无 + 目标有 = APPLIED;源有 + 目标无 = NOT_APPLIED;其他 = CONFLICT | +| copy/link/softlink 观察 | 目标无 = NOT_APPLIED;非本地存储 = UNKNOWN;本地 copy 逐字节比较、link `samefile`、softlink `resolve()` 相等 → APPLIED,否则 CONFLICT | +| 插件拦截观察 | 恒为 UNKNOWN(插件调用无回执)→ 崩溃后转人工 | +| 人工复核 | 人工给出 `not_applied / applied / failed` + 理由(`schemas/transfer.py:165-170`) | +| 启动恢复 | `claim_recoverable` 取租约 → 已有计划检查点:只执行冻结路径,不再识别/重命名;只有 accepted 输入:按原始请求重新规划;源已消失且未开始执行 → `abandon_unstarted` 注销;settling 状态 → 用 execution checkpoint 直接结算,不再执行外部步骤 | +| 结算原子性 | 历史写入 + outbox 事件 + 任务终态由 `durable_event_writer.transfer_result` 一次提交(`settlement.py` :321-420);终态结算失去租约 → 任务不算完成,留待恢复 | + +| 项 | 内容 | +| --- | --- | +| 对应 TS | `packages/tasks/src/import/step-ledger.ts`
`export async function runStep(ctx: StepContext, spec: { phase: StepPhase; kind: StepKind; intent: JsonObject; execute: () => Promise; observe: () => Promise> }): Promise`
`export function operationId(taskId: string, planFingerprint: string, ordinal: number, phase: string, kind: string, intent: JsonObject): string`
`packages/tasks/src/import/verify-and-import.ts`
`export async function verifyAndImport(ctx: TaskContext, input: VerifyAndImportInput): Promise`
表:`asset_operation`(步骤账本,唯一键 operation_id)、`organization_record` / `organization_file_result` | +| 保留行为 | **整体保留**:先冻结计划并持久化指纹、再执行;步骤 ID 由计划确定性派生;崩溃后先观察再决定,UNKNOWN/CONFLICT 转人工;已成功步骤不重做;失败有界重试;终态 + 历史 + 事件同事务提交;租约 fencing | +| 调整项 | ① 若 OpenWorkflow spike 通过,step 语义映射到其 durable step,但 `asset_operation` 业务账本仍独立(REQ §11.2);② 观察函数增加 size + sha256(copy 场景)证据,远端存储(首期不做)默认 UNKNOWN;③ 新增步骤 `verify_target`(size/hash)与 `register_asset`(写 media_asset/asset_location/path_binding),文件搬完但登记失败时,重启观察到目标已存在且校验一致 → 只补登记,不重搬(REQ §14.1.1"搬运完成但登记失败");④ 重试退避改为指数退避 + 上限,重试次数按 `automation_grant` 配置;⑤ manual_review 在控制台"需要我处理"与 Agent `task.inspect` 中展示证据 | + +### 2.8 历史记录 `TransferHistory` 与查重闸 + +| 项 | 内容 | +| --- | --- | +| 上游 | `app/db/models/transferhistory.py` `TransferHistory`(:22);`app/application/history.py` `add_transfer_success`(:1366)、`add_transfer_fail`(:1421)、`HistoryGateAction`(:929)、`resolve_history`(:1221)、`evaluate_history_gate`(:1244);调用处 `workflow.py` `_build_transfer_tasks`(:651 起) | + +字段(`TransferHistory`): + +| 字段 | 说明 | 本项目去向 | +| --- | --- | --- | +| id | 主键 | organization_file_result.id | +| transfer_task_id / transfer_settlement_revision | 失败 pending 映射的任务 ID 与结算版本(唯一索引 task_id) | asset_operation.task_id | +| src / src_storage / src_fileitem | 源路径、存储、源文件快照 JSON;`(src, src_storage)` **唯一** | organization_file_result.source_* | +| dest / dest_storage / dest_fileitem | 目标路径、存储、目标文件快照 | asset_location(library_root_id + relative_path) | +| mode | move/copy/link/softlink | organization_file_result.mode | +| type / category / title / year | 媒体类型、二级分类、标题、年份 | media_entity(按 UUID 引用,不冗余标题) | +| media_source / media_id | 识别源与原生 ID | external_identity | +| music_type / total_tracks / audio_* / bit_depth / sample_rate / bitrate | 音乐字段 | 首期不迁 | +| seasons / episodes | `Sxx` / `Exx` 字符串 | content_unit 覆盖关系(结构化) | +| image | 海报 URL | 不迁(元数据侧) | +| downloader / download_hash | 下载器与 hash(索引) | download_task(实例 ID + torrent 标识 + v1/v2 hash) | +| status / errmsg | 成功布尔、失败信息 | organization_file_result.state + error | +| date | 字符串时间 | timestamptz | +| files | 本次涉及的源文件清单 JSON | organization_file_result 多行 | +| episode_group | TMDB 剧集组 | external_identity 附属字段 | + +查重闸(`evaluate_history_gate` :1244): + +| 历史状态 | 源文件指纹(size/mtime/fileid)变化 | 动作 | +| --- | --- | --- | +| 无记录 | — | `pass_no_record` | +| 失败 | 变了 | `pass_failed_version_changed`(重试预算重置) | +| 失败 | 未变,失败次数 < 上限(默认 3) | `pass_failed` | +| 失败 | 未变,达到上限 | `skip_retry_exhausted`(仍给种子打"已整理"标签,停止轮询) | +| 成功 | 变了 | `pass_size_changed`(交给 overwrite_mode 裁决) | +| 成功 | 未变或无法比对 | `skip` | + +手动整理:`reorganize=True` 或历史为失败时,先请求 durable 重试或删除旧历史再整理;`cleanup_dest_fileitem` 用于"重新整理时先删旧目标",只绑定到本批第一个成功入队的任务(`workflow.py` `cleanup_intent_assigned`)。 + +| 项 | 内容 | +| --- | --- | +| 对应 TS | `packages/domain/src/organize/import-gate.ts`
`export function evaluateImportGate(prev: FileResultSnapshot | null, current: FileFingerprint, retry: { count: number; max: number }): ImportGateAction` | +| 保留行为 | 指纹变化才放行;失败有界重试;重试耗尽后停止对该种子空转;成功记录不被覆盖拒绝改写 | +| 调整项 | ① 唯一键从"源路径"改为 `(downloader_instance_id, torrent_id, file_index)` + 源路径,避免同路径被新种子复用时混淆(REQ §8.4);② 历史不承载作品身份与标题快照,只引用 UUID;③ 整理记录对外经公开 API 增量读取(游标),MP 无此合同(REQ §12.4) | + +### 2.9 下载器收尾、源文件清理与删种条件 + +| 项 | 内容 | +| --- | --- | +| 上游 | `settlement.py` `__default_callback`(:255,move 分支 :470-513)、`__mark_torrent_completed_if_done`(:620)、`__is_torrent_download_completed`(:650);`history.py` `_can_delete_torrent`(:305);`filter.py` `_should_delete_empty_source_directories`(:380);`app/chain/storage.py` `delete_media_file`(:161);`app/modules/qbittorrent/__init__.py` `transfer_completed`(:369)、`remove_torrents`(:381);`app/application/transfer/workflow.py` `JobManager.is_success`(:1773)、`is_torrent_done`(:1810)、`is_torrent_success`(:1824) | + +| 动作 | MP 条件 | +| --- | --- | +| 打"已整理"标签 | 该 hash 在内存作业视图中所有任务都 completed/failed,**且**下载器查询进度 = 100%(查询失败视为未完成);补丁 #6009 防止多集种子部分完成时过早打标签 | +| Move 模式删种(`delete_file=True`) | 当前任务 transfer_type = move **且** 同作品同季作业全部成功 **且** 该 hash 的所有任务成功 **且** `_can_delete_torrent`:种子存在、进度 100%、文件清单可取得、`save_path/文件名` 中**没有任何"未被屏蔽且仍存在的媒体后缀文件"** | +| Move 模式删源空目录 | 任务无 download_hash(目录监控来源)时,`delete_media_file(delete_self=False)`:向上删除不含媒体文件的空目录,止于配置的下载/媒体库根;根目录与一级目录禁止删;挂载盘(网络/FUSE)可配置不删 | +| Copy / link / softlink | 不删种、不删源 | + +| 项 | 内容 | +| --- | --- | +| 关键观察 | ① 删种判断**不看 H&R / 分享率 / 做种时长**,也不看是否有其他种子共享同一文件;② 删种使用 `delete_file=True`,依赖"媒体文件已被 move 走"这一事实,剩下的 nfo/样本/图片随种子一起删除;③ 条件依赖内存 `JobManager`,进程重启后作业视图丢失,删种只会在下一次完整成功的结算中发生 | +| 对应 TS | `packages/domain/src/organize/source-cleanup.ts`
`export function evaluateSourceRelease(input: { task: DownloadTaskFacts; fileResults: FileResultFacts[]; retention: RetentionFacts; references: PathReferenceFacts; grants: GrantFacts }): SourceReleaseDecision`
`packages/tasks/src/cleanup/cleanup-download-cache.ts`
`export async function cleanupDownloadCache(ctx: TaskContext, input: { instanceId: string; lowWater: number; highWater: number }): Promise` | +| 保留行为 | 打"已整理"类标签前确认种子整体下载完成、所有文件任务已终结;根目录/一级目录不可删;Move 后才考虑移除下载器任务 | +| 调整项 | 见第 3 节"必须偏离点" 第 4-8 条 | + +### 2.10 刮削触发(与转移的边界) + +| 项 | 内容 | +| --- | --- | +| 上游 | `app/chain/transfer/scrape.py` `_record_scrape_target`(:112)、`_send_metadata_scrape_event`(:22)、`_flush_scrape_batch_if_ready`(:178) | +| 行为 | 转移成功且 `need_scrape` → 登记批次刮削目标;批次全部结束后发 `EventType.MetadataScrape`,由刮削链异步写 NFO/图片。刮削失败不影响转移结果 | +| 对应 TS | `packages/tasks/src/metadata/refresh-metadata.ts` `refreshMetadata`(outbox 事件触发) | +| 保留 / 调整 | 保留"转移与刮削解耦、刮削失败独立重试、不重做搬运"(REQ §14.1.3);NFO/图片写入受目录策略控制,且刮削**不得**改动路径绑定 | + +--- + +## 3. 对照 REQ §7.1 / §8.3:本项目必须偏离 MP 的行为 + +| # | MP 行为(源码位置) | 本项目要求(REQ) | 偏离做法 | +| --- | --- | --- | --- | +| 1 | 目标目录每次按模板 + 当前 mediainfo 渲染;仅在 `SCRAPE_FOLLOW_TMDB=False` 时用首条历史 title 回填(`chain/transfer/execution.py:408`) | §7.1:已绑定作品/季目录固定,译名变化不改路径;两个历史目录并存建冲突任务 | 目录来自 `path_binding`(library_root_id + relative_path),模板只用于首次绑定;存在多个候选绑定 → `decision` 待处理 | +| 2 | 手动整理/重新整理可以改名、删旧目标(`cleanup_dest_fileitem`,`module.py:578`) | §7.1.1:主动整理是显式操作,可变更绑定;自动元数据刷新不得改名 | 自动链路禁用"删旧目标";`organize.plan/execute` 能力才允许改绑定,须预览 + 授权 + 发布整理记录 | +| 3 | move 跨盘在 `LocalStorage.move` 内部由 `OSError` 隐式降级为复制 + 删源,删源失败仍报成功(`storages/local.py:430-446`) | §8.3:跨盘需"复制、验证、再删源"的可恢复步骤;EXDEV 不得静默改变策略 | `renameNoClobber` 返回 `exdev` → 任务显式切换为 `copy → verify → delete_source` 三个账本步骤;删源失败 = `attention`,不算完成 | +| 4 | Move 模式全部成功后 `remove_torrents(delete_file=True)`,不检查 H&R、做种时长、分享率(`settlement.py:470-507`、`history.py:305`) | §8.3:有 H&R 义务不得直接搬走做种源;删数据须基于路径引用检查 | Move 前置条件包含 `retention` 判断:H&R 未满足 → 等待或(预授权时)改 Copy;移除 qB 任务一律 `deleteFiles=false`,残留文件由路径引用检查后单独删除 | +| 5 | `_can_delete_torrent` 只看"剩余未屏蔽媒体文件是否存在",不看共享保存根/其他种子引用/未接管文件 | §8.3:共享保存根、未接管文件、媒体库直接做种时不能递归误删 | `PathReferenceFacts`:同一实际路径被其他 torrent、未纳管文件、asset_location 引用 → 禁止删除;只删除本任务 torrent 文件清单内且无其他引用的文件,不递归删目录 | +| 6 | 删种依赖进程内 `JobManager`(:1773/:1824),重启后状态丢失 | §11:持久任务管过程 | 清理决策只读 PG 中的 `organization_file_result` 与 `download_task`,可在任何时刻重算 | +| 7 | 部分文件入库、其余失败时,种子仍可能因"剩余媒体文件已不存在"而被判定可删(推断) | §8.3:部分文件入库不能整种按"已完成"清掉 | 必须 torrent 文件清单中**每个被选择的媒体文件**都有成功结果或显式 `skipped_by_rule`,才进入可清理集合 | +| 8 | Copy 模式无缓存清理机制(源永远留给下载器) | §8.3:Copy 后按保留天数/H&R/水位清理下载缓存,清理到恢复阈值停止;下载器失联/条件未知不得清理 | 新增 `cleanupDownloadCache` 任务:低水位触发,候选须"已验证入库 + 满足保留 + H&R 满足",到高水位停止;任一事实未知 → 不清理 | +| 9 | `latest` 删除同目录同季同集的其他版本(`transhandler.py:1821`) | §6.1:1080p/4K 独立 Slot 互不淘汰 | 版本删除改为"同 Slot 升级替换",旧资产先隔离;跨 Slot 永不删除 | +| 10 | 未设置 overwrite_mode 时持久路径可能静默覆盖(推断,见 2.5) | §8.3:不能覆盖无关资产 | 执行器一律 no-clobber;覆盖需授权决策冻结在计划中 | +| 11 | copy 完成不校验内容、不 fsync(`fsproxy.py:198`) | §8.3:写目标侧临时文件,校验并提交后登记 | copy 后 fsync + size(必做)+ sha256(可配置,首期对 < N GB 或抽样)校验,校验通过才 rename 与登记 | +| 12 | 硬链接/软链接是一等转移模式,EXDEV 直接失败 | §8.3:兼容登记既有硬链接,不设为主工作流 | 首期执行器只实现 copy/move;link 仅用于识别/登记既有硬链接(inode/dev),不作为新入库模式 | +| 13 | 剧集多集文件 `total_episode > 2` 截为单集命名(`transhandler.py:387`) | §5.4 集账本:覆盖关系要准确 | 文件名保留区间,content_unit 覆盖全部集 | +| 14 | 下载完成即按标签"已整理"退出轮询;标签是 MP 私有中文字面值 | §8.4:管理标签名可配置,标签不承载身份 | 使用配置的管理标签 + 独立状态表;不依赖标签判断是否已整理 | +| 15 | 历史唯一键 `(src, src_storage)` | §8.4:业务关联用下载器实例 ID + torrent 标识 | 结果按 `(instance, torrent, file_index)` 关联,源路径仅作辅助 | + +--- + +## 4. TS 模块总览(按任务书 §2 包划分) + +| 包 / 文件 | 导出 | 说明 | +| --- | --- | --- | +| `packages/domain/src/organize/candidates.ts` | `collectImportCandidates(tree, opts): ImportCandidate[]`;`isBlurayRoot(entries): boolean` | 2.1 | +| `packages/domain/src/organize/sidecars.ts` | `pairSidecars(files): SidecarPairing`;`subtitleMediaStem(name): string` | 2.2 | +| `packages/domain/src/organize/target-binding.ts` | `resolveTargetBinding(input): TargetBindingDecision` | 2.3,稳定路径核心 | +| `packages/domain/src/organize/naming.ts` | `renderFileName(tpl, ctx): string`;`subtitleLanguageSuffix(name, defaultLang): string`;`sanitizePathSegment(s): string` | 2.4 | +| `packages/domain/src/organize/conflict.ts` | `decideConflict(src, dst, ctx): ConflictDecision` | 2.5 | +| `packages/domain/src/organize/plan.ts` | `buildOrganizePlan(input: OrganizePlanInput): OrganizePlan`;`planFingerprint(plan): string` | 汇总 2.1-2.5,输出冻结计划(每文件:source、target、mode、conflict decision、sidecars、required grants、preconditions);纯函数 | +| `packages/domain/src/organize/import-gate.ts` | `evaluateImportGate(prev, current, retry): ImportGateAction` | 2.8 | +| `packages/domain/src/organize/source-cleanup.ts` | `evaluateSourceRelease(input): SourceReleaseDecision` | 2.9,含 H&R/保留/引用检查 | +| `packages/adapters/src/fs/local-transfer.ts` | `copyToTarget`、`renameNoClobber`、`hardlinkNoClobber`、`verifyFile`、`sweepStalePartials`、`statStrict(path): Promise` | 2.6;查询异常必须抛出而不是返回 null | +| `packages/adapters/src/fs/observe.ts` | `observeMaterialize(step): Promise`;`observePresence(path, appliedWhenPresent)` | 2.7 观察函数 | +| `packages/tasks/src/import/step-ledger.ts` | `runStep(ctx, spec)`;`operationId(...)` | 2.7 | +| `packages/tasks/src/import/verify-and-import.ts` | `verifyAndImport(ctx, input): Promise` | 编排:加载冻结计划 → 逐文件 runStep → 结算(organization_record + outbox)同事务 | +| `packages/tasks/src/cleanup/cleanup-download-cache.ts` | `cleanupDownloadCache(ctx, input)` | 2.9 | +| `packages/contracts/src/organize.ts` | zod:`OrganizePlan`、`OrganizeFileResult`、`ImportOutcome`、`ConflictDecision` | 公开整理记录 API 与能力 `organize.plan/execute` 共用 | + +`OrganizePlan` 建议字段(对齐 MP `TransferPlanCheckpoint` 的冻结思想): + +```ts +interface OrganizePlan { + planVersion: 1; + requestFingerprint: string; // 对应 TransferPlanningInput.fingerprint + downloadTaskId: string | null; + mode: "copy" | "move"; + items: Array<{ + sequence: number; + source: { instanceId?: string; torrentFileIndex?: number; absPath: string; size: number; mtimeMs: number; dev: number; ino: number }; + target: { libraryRootId: string; relativePath: string; bindingId: string; bindingIsNew: boolean }; + role: "main" | "subtitle" | "audio" | "bluray_dir" | "extra"; + mainRef?: number; // 附属文件指向主文件 sequence + contentUnits: string[]; // 覆盖的 content_unit UUID + slotId: string | null; + conflict: ConflictDecision; + skipReason?: string; + }>; + requiredGrants: GrantKind[]; + preconditions: Array<{ kind: "free_space" | "hnr_satisfied" | "no_external_reference"; detail: JsonObject }>; +} +``` + +--- + +## 5. 验证样本 + +样本均为自造、脱敏,放入 `packages/testkit/fixtures/organize/`。"MP 预期"列为按源码推断的上游行为,"本项目预期"为验收口径。 + +### 5.1 单文件电影 + +| # | 输入 | 条件 | MP 预期 | 本项目预期 | +| --- | --- | --- | --- | --- | +| M1 | `/downloads/movie/Dune.Part.Two.2024.2160p.UHD.BluRay.REMUX.DV.HDR.HEVC.TrueHD.7.1.Atmos-FraMeSToR.mkv` | copy,库根 `/media/movies`,无旧目录 | `/media/movies/沙丘2 (2024)/沙丘2 (2024) - 2160p.mkv` | 新建绑定 `沙丘2 (2024)/`,文件同名;partial → fsync → size 校验 → rename;`organization_file_result=succeeded` | +| M2 | 同 M1,TMDB 中文名改为「沙丘:第二部」 | 已有绑定 `沙丘2 (2024)/` | `SCRAPE_FOLLOW_TMDB=True` 时建新目录「沙丘:第二部 (2024)」(`:` 转全角) | 仍写入 `沙丘2 (2024)/`;NFO 可更新标题 | +| M3 | `/downloads/movie/Oppenheimer.2023.1080p.BluRay.x264-SPARKS/` 内含 `…mkv` + `…chs.srt` + `…eng.srt` + `sample.mkv`(40MB) | min_filesize=100MB | 主视频入库;字幕按 stem 剥离 `.chs`/`.eng` 后匹配 → `….chi.zh-cn.default.srt`、`….eng.srt`;sample 被体积过滤 | 同左;sample 记 `skipped_by_rule(min_size)` | +| M4 | `/downloads/collection/The.Matrix.Trilogy.1080p/` 内 `The.Matrix.1999…mkv`、`The.Matrix.Reloaded.2003…mkv` | 下载历史记录 TMDB=603(1999) | 1999 文件用历史 ID;2003 文件年份冲突 → 逐文件重新识别 | 同左;每文件独立结果 | +| M5 | `/downloads/movie/Some.Movie.2021.1080p.WEB-DL.mkv` | 目标同名已存在,大小更小,无授权 | `size` 模式:覆盖;`never`:失败 + `overwrite_skipped` | `skip` + 待处理决策"发现同名已有文件";hash 相同则直接关联为已在库 | +| M6 | 同 M5 | 目标已存在,`overwrite_mode` 未设置 | 持久路径推断**静默覆盖** | 执行器 no-clobber 拒绝,结果 `attention` | + +### 5.2 剧集 / 整季包 + +| # | 输入 | 条件 | MP 预期 | 本项目预期 | +| --- | --- | --- | --- | --- | +| T1 | `/downloads/tv/The.Last.of.Us.S01.2160p.MAX.WEB-DL.DDP5.1.Atmos.DV.HDR.H.265-FLUX/` 内 E01-E09 共 9 个 mkv | copy,库内无该剧 | 9 个独立任务,`/media/tv/最后生还者 (2023)/Season 1/最后生还者 - S01E01 - 第 1 集.mkv` … | 1 个 organization_record + 9 条文件结果;新建作品与季两级绑定 | +| T2 | 同 T1,但库中已有 `最后生还者 (2023)/Season 1/…S01E01…`、`…S01E02…` | 同 Slot、同质量 | 历史/目标存在 → E01/E02 按 overwrite_mode 处理 | E01/E02 hash 不同 → `skip` + 决策(升级需授权);E03-E09 正常入库;批次 `partial` | +| T3 | `/downloads/tv/Show.S02E01-E03.1080p.WEB-DL.mkv` | 多集单文件 | `total_episode=3>2` → 截成 `S02E01` 命名 | 文件名 `… - S02E01-E03.mkv`,覆盖 3 个 content_unit | +| T4 | `/downloads/tv/Show.S02E05-E06.1080p.mkv` | 两集单文件 | 保留 `S02E05-E06` | 同左 | +| T5 | `/downloads/tv/Bocchi.the.Rock.S01.1080p.BluRay/` 内 `NCOP01.mkv`、`NCED.mkv`、`Menu.mkv` 无集号 | — | 命中特典正则 → 成功跳过、不通知 | 记 `skipped_extra`,可在 UI 查看,后续附加内容建模(M2) | +| T6 | `/downloads/tv/Show.S01.1080p/Show.S01E07.mkv` 损坏,中途 IO 错误 | copy | 步骤失败 → observe NOT_APPLIED → 30s 后重试,3 次后失败 | 指数退避重试;耗尽后该文件 `failed`,其他集不受影响 | +| T7 | `/downloads/tv/Show.S01.BluRay.1080p/Show.S01D02/BDMV/STREAM/00001.m2ts` | 原盘目录 | 回溯到 `Show.S01D02/` 整体搬到 `…/Season 1/Disc 2/` | 同左;首期可先 `attention`(原盘策略待确认) | +| T8 | 字幕 `Show.S01E03.繁體中文.ass` 与 `Show.S01E03.简体.ass` | 同目录有 `Show.S01E03.mkv` | 主干名剥离后匹配;繁体优先判定 → `.zh-tw.ass`;简体 → `.chi.zh-cn.default.ass` | 同左 | +| T9 | 字幕 `Show.S01E03.mkv.srt` 与两个主视频 `Show.S01E03.mkv`、`Show.S01E03.v2.mkv` | 主干名多义 | 无唯一匹配 → 独立任务,按自身文件名识别 | `orphan`,不搬不删,进入待处理 | + +### 5.3 Move / 跨卷 / 清理 + +| # | 输入 | 条件 | MP 预期 | 本项目预期 | +| --- | --- | --- | --- | --- | +| X1 | `/downloads`(同 FS)→ `/downloads/../media`(同 FS) | move | `os.replace` 原子完成 | `renameNoClobber` → `renamed`;登记后按保留规则决定是否移除 qB 任务 | +| X2 | `/mnt/cache/downloads/…mkv` → `/mnt/array/media/…`(不同 FS) | move | `os.replace` 抛 EXDEV → 复制 → 删源;删源失败仍报成功 | 返回 `exdev` → 显式三步 `copy/verify/delete_source`,事件"跨卷搬运";删源失败 → `attention` | +| X3 | X2 中 copy 完成、verify 前进程被 kill | 重启 | materialize 步骤 STARTED;observe:源在 + 目标在 → CONFLICT(move 语义)→ 人工复核 | copy 语义观察:目标 size/hash = 源 → APPLIED,继续 verify/delete_source;只有 partial 残留 → NOT_APPLIED,清理 partial 后重做 | +| X4 | 文件已搬运 + 登记事务失败 | 重启 | settling 检查点存在 → 直接结算,不重搬 | `register_asset` 步骤重试,目标校验一致即补登记 | +| X5 | M-Team 种子,H&R 要求做种 72h,已做 10h | move | 全部成功即 `remove_torrents(delete_file=True)` | 不执行 Move 的删源;等待或(已授权)改 Copy;不移除 qB 任务 | +| X6 | 保存根 `/downloads/tv/`,种子 A = `Show.S01E01.mkv`,种子 B 为其他剧同目录 | move 完 A | `_can_delete_torrent` 仅看 A 的文件清单 | 只删除 A 清单内、无其他引用的文件;不递归删 `/downloads/tv/` | +| X7 | 整季包 10 集,8 集成功、2 集失败 | move | 该 hash 有失败任务 → `is_torrent_success=False` → 不删种 | 同左 + 批次 `partial`;qB 任务保留,失败集可重试 | +| X8 | Copy 入库 7 天前的种子,缓存盘可用 < 低水位 | cleanupDownloadCache | MP 无此功能 | 候选:已验证入库 + 保留期满 + H&R 满足;清至高水位停止;qB 失联 → 不清理 | + +--- + +## 6. 未核验 / 待补 + +| 项 | 说明 | +| --- | --- | +| 与 33da9ce 的差异 | 本地仅有 `e6820a83`,REQ 附录快照未比对 | +| 推断条目 | 2.5"未设置 overwrite_mode 静默覆盖"、3 表第 7 条"部分入库可能被判可删"——需在参考环境或用 MP 自带测试(`tests/test_transfer_overwrite_guard.py`、`tests/test_filemanager_planning.py`)复现后更新 | +| 可对照的上游测试 | `tests/test_local_storage.py`(跨设备 move、同设备 rename)、`test_transfer_execution_runner.py`、`test_transfer_settling_recovery.py`、`test_transfer_pending_replay.py`、`test_transfer_history_gate.py`、`test_transfer_overwrite_declined.py`、`test_transfer_sync_extra_files.py`、`test_transfer_movie_collection.py`、`test_transfer_mark_torrent_completed.py`、`test_filemanager_season_zero.py`——仅作行为参考,不复制测试代码 | +| 远端存储 | 网盘/SMB/rclone/alist 分支(`storages/*.py`)首期不迁 | +| 插件事件 | `TransferIntercept`、`TransferOverwriteCheck`、`TransferRenameBuild`、`TransferRename` 在本项目由 Capability/策略替代,不迁移插件机制 |