- qbittorrent: WebUI API v2 client (cookie/API-key auth, add/info/files/filePrio, pause/resume with 5.x stop/start fallback, tags, delete with explicit deleteFiles, maindata), error classes, idempotent submit with local infohash (own bencode) and post-timeout hash/probe-tag lookup; state mapping per MoviePilot - fs: copyWithVerify (tmp + fsync + size/sha256/sample + no-clobber commit), moveSafe (EXDEV surfaced), resumable crossDeviceMove, statSafe/checkRoot with root sentinel, budgeted incremental scan with cursor, ffprobe, statfs usage - tmdb: multi-language search/details/seasons, LRU+TTL cache with coalescing, rate-limit backoff, MoviePilot match chain (name normalization, year +-1) - emby: Emby/Jellyfin items/seasons/episodes/MediaSources, failures -> unknown - testkit: mock qB / TMDB / Emby servers, torrent builder, temp-tree fixtures - docs: W1-3a porting table appended to downloader-and-sites ledger Co-Authored-By: Claude Fable 5.1 <[email protected]>
68 KiB
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 |
| 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; }): SeasonPackVerdicttype 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` |
| 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 为本项目新增。