Files
UHD_Media_Tool/docs/porting/moviepilot-downloader-and-sites.md
T
rootandClaude Fable 5.1 a6d049db81 feat(adapters): RSS and PT site adapters ported from MoviePilot v3 + mocks (W1-3b)
- rss: port of MP RssHelper._parse_impl/get_rss_link semantics with safe
  XML (entities off, DTD/ENTITY rejected, size/item caps), ETag/Last-Modified,
  backoff + Retry-After, circuit breaker with manual reset, passkey redaction,
  overlap-window GUID+digest dedupe
- sites/spider: TS port of MP SiteSpider (indexer schema, search URL builder,
  field DSL, filters, Jinja templates via nunjucks, login-page detection);
  audiences definition hand-written as JSON; raw category id preserved;
  shared per-site token bucket
- sites/mteam: port of MP MTorrentSpider (params, result mapping, promo
  factors, genDlToken flow) without embedding the API key in enclosures;
  business code/message checks
- testkit: mock RSS server, mock NexusPHP site, mock M-Team API
- docs: W1-3b migration mapping appended to porting ledger

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-27 18:10:34 +08:00

665 lines
73 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 <apikey>"}` 并调 `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下载器"`;未匹配:`"添加种子任务失败:<content>"` |
| 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<QbAuthResult>; ensureSession(): Promise<void>; appWebApiVersion(): Promise<string> }` | Q1-Q4 |
| 同上 | `torrentsInfo(q: { hashes?: string[]; filter?: QbStatusFilter; category?: string; tag?: string }): Promise<QbTorrent[]>` | Q5(**用 qB 原生 `tag` 参数**,不在客户端过滤全量) |
| 同上 | `torrentsAdd(req: QbAddRequest): Promise<QbAddResult>`,`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<QbFile[] \| null>`;`setFilePriority(hash: string, fileIds: number[], priority: 0 \| 1 \| 6 \| 7): Promise<void>` | 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<void>`——**adapter 层不暴露裸 deleteFiles=true**,由 `packages/tasks` 的 cleanupDownloadCache 在通过 `assertSafeToDeleteData` 后传入 | Q16/Q25 |
| `packages/adapters/src/qbittorrent/submit.ts` | `submitTorrent(inst: QbClient, req: SubmitRequest): Promise<SubmitOutcome>`;`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-<ulid>`,并在 `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<number>; totalEpisodes: { value: number; confidence: 'local_ledger' | 'tmdb' | 'unknown' }; requireCompleteCoverage: boolean; allowedEpisodes?: Set<number> }`
- `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<ID>=1` |
| `torrents.list.selector` | `'table.torrents > tr:has("table.torrentname")'` | 行选择器 |
| `torrents.fields.<name>` | `{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` | `<title>` 为"登录"或含":: 登录";正文前 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>观众 :: 登录</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 实例验证 |
---
## W1-3a 实际迁移对照(qB / TMDB / Emby / 文件系统)
上游:`/opt/moviepilot-plus/upstream/MoviePilot`,提交 `e6820a8`。只做行为对照与 TS 重写,不复制代码。
### qBittorrent(`packages/adapters/src/qbittorrent/`)
| 上游函数 | TS | 保留 / 调整 |
| --- | --- | --- |
| `Qbittorrent.__init__` / `__login_qbittorrent` / `is_inactive` / `reconnect` | `QbClient` 构造 + `login()`;401/403 时自动重登一次 | 保留 API Key(Bearer)与账号密码两种认证;**不**调用 `__sync_incomplete_file_suffix`(QA6,只读 `preferences()`) |
| `__parse_add_torrent_response` | `add()` 响应解析 | 保留:文本 `Ok.` 或 5.x JSON(`success_count/pending_count/added_torrent_ids`) |
| `add_torrent` | `add()` | 保留 savepath/category/tags/paused(同时发 `stopped`)/rename/sequential/firstLastPiece(实例默认);调整:`autoTMM` 固定 false、显式 `contentLayout`(QA7);Cookie 同步(Q14)未迁移——.torrent 由站点适配器下载后以字节提交 |
| `get_torrents` | `list({hashes, tag, tags, category, filter})` | 调整:首个标签走 qB 原生 `tag` 参数,其余在客户端做子集过滤(QA4);按 hash 查询不受管理标签限制(QA3) |
| `get_completed_torrents` | `getCompleted()` + `isDownloadComplete()` | 保留严格完成条件与非终态集合(QK4);未知 state 也不算完成 |
| `get_downloading_torrents` | `getDownloading()` | 保留 |
| `__normalize_torrent_state`(`__init__.py`) | `mapQbState()` | 调整:error/missingFiles/unknown 独立状态,不兜底为 completed(QA5) |
| `delete_torrents_tag` / `remove_torrents_tag` / `set_torrents_tag` | `deleteTorrentsTag()` / `removeTags()` / `addTags()`;`deleteTagDefinitions()` | 保留 |
| `torrents_set_force_start` | `setForceStart()` | 保留;是否调用由上层按实例 `force_resume` 配置决定 |
| `get_torrent_id_by_tag` / `__get_last_add_torrentid_by_tag` | `submit()` 内探针标签轮询 | 保留 9 次 × 3s 默认(首查不等待)、用后删标签与全局定义;调整:探针前缀 `uhd-probe-`(QA13),hash 已知时不用探针(QK2) |
| `start_torrents` / `stop_torrents` | `resume()` / `pause()` | 5.x 端点改名(stop/start)时自动切换 |
| `delete_torrents` | `delete(hashes, { deleteFiles })` | 调整:`deleteFiles` 必须显式,删数据前的引用检查在上层(QA11/QA12) |
| `get_files` | `files(hash, { retries, intervalMs })` | 保留:空列表视为未就绪继续重试 |
| `set_files` | `setFilePriority()` | 保留:无 hash/无文件 ID 返回 false 不发请求 |
| `recheck_torrents` / `set_torrent_location` / `set_torrent_category` / `change_torrent` | `recheck()` / `setLocation()` / `setCategory()` / `changeTorrent()` | 保留 KB/s→B/s 换算、仅做种时长时 ratio=-2 |
| `get_content_layout` / `transfer_info` | `contentLayout()` / `transferInfo()` | 只读 |
| `QbittorrentModule.download` 步骤 1-7 | `submit()` | 调整:本地 infohash 预查已存在 → `already_exists` 不改其路径/标签(QA1/QA2);add 超时/断连 → 按 hash 回查(QA8),返回判别联合(QA14)。选集(步骤 8-9)属 domain `selectTorrentFiles`,本适配器只提供 `files`/`setFilePriority` |
| `_get_torrent_info`(`_base/downloader.py`) | `parseTorrent()` / `parseMagnet()` | 新增 v1/v2 infohash(原始 info 字节切片求哈希) |
| `normalize_path` / `normalize_return_path` | `toDownloaderPath()` / `fromDownloaderPath()` | 保留首条命中 |
### TMDB(`packages/adapters/src/tmdb/`)
| 上游函数 | TS | 保留 / 调整 |
| --- | --- | --- |
| `search_movies` / `search_tvs` / `search_multiis` | `search()`(多语言合并)/ `searchRaw()`(首选语言)/ `searchMulti()` | 电影年份参数 `year`,剧集 `first_air_date_year`;multi 只保留 movie/tv |
| `__compare_names` + `foundation/text.py remove_punctuation` | `compareNames()` / `normalizeTitleForCompare()` | 保留同一标点集、去零宽字符与空白、大写后**完全相等** |
| `_generate_year_range` | `yearRange()` | 保留 [year, year+1, year-1] |
| `match` / `_build_match_plan` / `_match_resolution` | `matchTmdb()` | 保留步骤顺序(季年份优先,再名称+年份容差)与首个命中短路;调整:查询异常抛 `TmdbError`,不当作"未找到"继续 |
| `__search_movie_by_name` / `__search_tv_by_name` / `_build_name_candidate_plan` / `_name_resolution` / `_alias_matches` / `__get_names` | `matchTmdb()` 内 `searchByName` | 保留:按日期降序、年份前缀过滤、标题/原标题直达,否则逐个拉详情用别名(alternative_titles)+ 译名(translations)比较 |
| `__search_tv_by_season` / `_season_candidate_matches` / `_season_year_matches` | `matchTmdb()` 内 `searchBySeason` | 保留;剧集组 `group_seasons` 未迁移 |
| `match_multi` / `_sort_multi_results` | `matchTmdbMulti()` | 保留电影优先、日期降序 |
| `__get_movie_detail` / `__get_tv_detail` | `details()` | 附带 `alternative_titles,external_ids,translations`;输出归一化 `MetadataCandidate` |
| `get_tv_season_detail` | `season()` | 保留 |
| `tmdbv3api/tmdb.py request` 的 `@cached` | `TtlLruCache` + 并发合并 | 保留 maxsize 1024 / TTL 24h(可配)/ 空结果 30min / 失败不缓存;新增并发合并与 429 Retry-After 退避 |
| `cache.py TmdbCache`(识别结果持久缓存) | 未迁移 | 识别结果持久化归 db/domain(`external_identity`) |
### Emby / Jellyfin(`packages/adapters/src/emby/`)
| 上游函数 | TS | 保留 / 调整 |
| --- | --- | --- |
| `Emby.get_user` | `resolveUserId()` | 保留:用户名匹配 → 管理员回退 |
| `__get_emby_series_id_by_name` | `findSeriesIdByName()` | 保留 SearchTerm + 名称完全相等 + 年份;调整:连不通 → `unknown`,未找到 → `ok(null)` |
| `get_movies` | `getMovies()` | 保留名称完全相等 + 年份 + 身份兼容 |
| `get_tv_episodes` | `getTvEpisodes()` | 保留 `IsMissing=false`、按 ParentIndexNumber/IndexNumber 汇总、缓存 itemId 失效时按标题重查;新增 Virtual 过滤与多集文件(IndexNumberEnd)展开 |
| `get_iteminfo` | `getItem()` | 单条目 404 → `ok(null)`(ID 失效),其他失败 → `unknown` |
| `MediaServerIdentityHelper.from_provider_ids` / `are_compatible` | `providerIdentity()` / `identitiesCompatible()` | 保留来源优先级与"同来源不同 ID 才冲突" |
| (新增) | `searchItems()` / `findByProviderId()` / `seasons()` / `episodes()` / `normalizeMediaSource()` | MediaSources 版本信息;查询截断时不给"不存在" |
| `jellyfin.py` 认证 | `flavor: "jellyfin"` | 保留 `Authorization: MediaBrowser Token=`、无 `/emby` 前缀;调整:Emby 也用请求头 `X-Emby-Token`,不把 api_key 放进 URL |
### 文件系统(`packages/adapters/src/fs/`)
对照 `docs/porting/moviepilot-transfer.md` §2.6:`_write_atomically`/`_partial_path`/`_cleanup_stale_partials` → `copyWithVerify`/`partialPathFor`/`sweepStalePartials`(新增 fsync、校验、no-clobber);`LocalStorage.move` → `moveSafe`(EXDEV 不降级)+ `crossDeviceMove`(三步可重入)。`statSafe`/`scanIncremental`/`ffprobe`/`diskUsage` 为本项目新增。
## 10. W1-3b 实际迁移对照(RSS / 站点)
按用户指示"照抄 MoviePilot v3 实现",以 MP 提交 `e6820a8` 为蓝本迁移为 TS。只有安全、数据保真与去重相关的偏离,逐条列在"调整"列。MovieClaw 仍只参考字段结构(data-ref)。
### 10.1 通用站点 spider(`packages/adapters/src/sites/spider/`)
| 上游函数 | TS 函数 | 保留 / 调整 |
| --- | --- | --- |
| `spider/__init__.py select_media_categories` | `selectMediaCategories` | 保留 |
| `resolve_category_media_type` | `resolveCategoryMediaType` | 保留(投影值仍写 `category`) |
| `SiteSpider.__init__` 读取的 indexer 键 | `IndexerDefinition`(`schema.ts`) | 保留全部键;新增 `umt` 扩展(版本、时区、限流、登录标志、详情页、分类名) |
| `SiteSpider.__get_search_url` / `__format_search_word` | `SiteSpider.getSearchUrl` / `formatSearchWord` | 保留(默认参数、search_area 仅 IMDb 保留、batch、typed path、category field/param/catN、browse.start、result_media_type) |
| `UrlUtils.combine_url` | `combineUrl` | 保留(doseq) |
| `__can_search` | `canSearch` | 保留 |
| `get_info` + `__get_title … __get_category` | `getInfo` + 同名私有方法 | 保留字段顺序与缺省值(size/seeders/peers/grabs 缺失为 0,相对时间不写 pubdate,case 顺序首个命中);**调整**:系数 selector 模式浮点(SA4);`__get_category` 额外输出 `category_id` 原值 |
| `_safe_query / __remove / __attribute_or_text / __index / __filter_text` | 同名方法 | 保留(re_search/split(含负下标)/replace/dateparse/strip/appendleft/querystring,单个过滤器失败跳过) |
| `__is_login_or_permission_page` | `SiteSpider.isLoginOrPermissionPage` | 保留判定;**调整**:adapter 抛 `auth_expired` 而不是返回空列表 |
| `parse`(PyQuery 分支) | `SiteSpider.parse` | 保留;htmlparser2 HTML 模式对齐 lxml(不插 tbody);`:has("x")` 去引号;**未移植** Rust 加速与字幕分支 |
| jinja2 模板 | `template.ts`(nunjucks) | 保留 Jinja 语法 |
| `foundation/size.parse_size` | `parseSizeMp` | 保留(round) |
| `IndexerModule.__search_check` / `__clear_search_text` | `search` 前检查 / `clearSearchText` | 保留(英文站不搜中文、去标点) |
| `SitesHelper.check(domain)`(闭源) | `createSiteRateLimiter` | 以令牌桶实现(SA6) |
| `submission.download_torrent` | `downloadTarget` | 保留(Cookie GET、磁力原样);**调整**:不跟随 3xx、校验 bencode 并计算 infohash、登录页识别 |
| — | `detail.ts parseDetailPage` | 新增(MP 通用 spider 无详情页) |
| MP 站点定义(加密 `user.sites.v3.bin`) | `definitions/audiences.json` | 按 MP schema 手写,待真实页面核验 |
### 10.2 M-Team(`packages/adapters/src/sites/mteam/`)
| 上游函数 | TS 函数 | 保留 / 调整 |
| --- | --- | --- |
| `mtorrent.py _movie_category/_tv_category/_music_category/_labels` | `MOVIE_CATEGORY/TV_CATEGORY/MUSIC_CATEGORY/LABELS` | 保留 |
| `MTorrentSpider.__get_params` | `mteamSearchParams` | 保留(`tt` 前缀改写 IMDb URL、pageNumber=page+1、pageSize 100、visible 1、无 mode);**调整**:显式 `categoryIds` 覆盖 mtype 分类组 |
| `__parse_result` | `parseMteamResult` | 保留字段映射、promotionRule 覆盖下载系数、mallSingleFree ONGOING → 0、freedate 取值顺序;**调整**:不生成含 apikey 的 base64 enclosure(MT3);输出 `category_id` |
| `__find_imdbid / __get_downloadvolumefactor / __get_uploadvolumefactor` | `findImdbid / getDownloadVolumeFactor / getUploadVolumeFactor` | 保留 |
| `search` / `__process_response` | `mteamPost` + adapter `list/search` | 保留 header(x-api-key、UA、JSON);**调整**:检查业务 code/message 区分凭据/限流/不存在(MT2) |
| `__get_download_url` + `submission._resolve_indirect_download_url` | adapter `downloadTarget` | 保留(POST genDlToken,id 走 query,取 `data`,不带 Cookie GET);令牌不入库不入日志 |
| `parser/mtorrent.py`(api/member/profile) | `healthCheck` | 只用作登录有效性 |
| — | `detail`(`/api/torrent/detail`) | 新增,字段结构参考 MovieClaw(data-ref) |
### 10.3 RSS(`packages/adapters/src/rss/`)
| 上游函数 | TS 函数 | 保留 / 调整 |
| --- | --- | --- |
| `RssHelper.MAX_RSS_SIZE / MAX_RSS_ITEMS` | fetcher `maxBytes` / parser `maxItems` 默认值 | 保留 50MB / 1000;大小按解压后字节流式限制 |
| `RssHelper._parse_impl` 条目循环 | `parseFeed` / `itemFields` | 保留字段语义(见 rss/README);**调整**:fast-xml-parser 关闭实体处理并预检拒绝 ENTITY/内部 DTD;新增 guid/categories/rawDigest |
| `_rss_expired_msg` | `RSS_EXPIRED_MESSAGES` → `FeedError('expired')` | 保留识别,返回值改为错误类型 |
| `RssHelper.parse`(GET,非 200 失败) | `createRssFetcher().fetchFeed` | 保留;新增 ETag/Last-Modified、退避 + Retry-After、熔断 + 手动恢复、passkey 脱敏 |
| `rss_link_conf` / `_get_site_domain` / `get_rss_link` | `RSS_LINK_CONF` / `rssSiteDomain` / `buildRssLinkRequest` + `extractRssLink` | 只迁移 default 与 audiences.me;xpath 改等价 CSS |
| — | `applyFeedItems` / `FeedCursor` | 新增重叠窗口去重(REQ §4.2/§4.3) |
### 10.4 许可提示
本节模块是对 MP(GPL-3.0)实现的逐函数 TS 移植,性质上比 `behavior-ref` 更接近衍生作品。项目目前为 internal-only 使用;若将来对外分发,需按 `license-and-provenance.md` §2 重新评估(GPL-3.0 同许可与源码提供义务)。