diff --git a/docs/porting/moviepilot-rust-transfer.md b/docs/porting/moviepilot-rust-transfer.md new file mode 100644 index 00000000..9fc272e4 --- /dev/null +++ b/docs/porting/moviepilot-rust-transfer.md @@ -0,0 +1,142 @@ +# MoviePilot v3 的 Rust 部分与整理/转移:核查与借鉴清单 + +> 上游:`/opt/moviepilot-plus/upstream/MoviePilot`,提交 `e6820a8`(单提交浅克隆,`git log` 只有 1 条,**无法用 `git log -S/--grep` 追溯动机**,动机证据只能取自仓内文档)。 +> 只读核查,未运行上游。行为对照,不复制源码(GPLv3,见 `license-and-provenance.md`)。转移主流程的完整对照见 `moviepilot-transfer.md`,本文只回答"Rust 在转移里干了什么"以及由此引出的差异。 + +## 0. 结论先说 + +**MP v3 的文件复制/移动/链接没有下沉到 Rust,全部是 Python。** "整理模块走 Rust"这个前提不成立,需要纠正: + +1. Rust 扩展是独立 PyPI 包 `moviepilot-rust~=0.3.3`(`pyproject.toml:49`,PyO3 + maturin,仓外 `MoviePilot-Rust` 仓库构建),本机只有编译好的 `moviepilot_rust.abi3.so`(10 MB),**没有 Rust 源码、没有 `.pyi`**。 +2. 包 README 自述的模块:`filter/ indexer/ metainfo/ metamusic/ rss/ support/`,**没有 fs/io 模块**。 +3. `.venv/.../native_internal.cpython-314-*.so` 属于 **mypy 1.18.2**(`mypy-*.dist-info/RECORD` 登记,符号是 `PyInit_native_internal` + mypyc 运行时字符串),与 MP 业务无关。 +4. 转移链路上唯一经过 Rust 的是**文件名识别**:`MetaInfoPath` → `parse_metainfo_path_fast`。它在转移里用于目标路径规划和 `latest` 覆盖模式下"找同季同集的其它版本"。 +5. MP 为转移做的"原生级"加固其实是另一件事:**`fsproxy` 常驻子进程**(`app/adapters/system/fsproxy.py` + `fsworker.py`),目的是让挂死在 FUSE/网络挂载上的系统调用能被 SIGKILL 回收。这是本文最值得借鉴的部分。 + +## 1. Rust 导出函数清单 + +来源:`.venv/bin/python -c "import moviepilot_rust; dir(...)"` 实测 + `app/adapters/system/rust.py` 包装层。参数和返回结构按 Python 调用侧反推(**推断**,无 `.pyi`)。 + +| 导出函数 | Python 包装(rust.py) | 参数 | 返回 | 业务 | 与转移的关系 | +| --- | --- | --- | --- | --- | --- | +| `is_available()` | `is_available/status` | — | bool | 探活 | 无 | +| `parse_metainfo_fast` | `parse_metainfo` | title, subtitle?, options(dict,含自定义识别词与缓存键) | dict / None(None 表示回退 Python) | 种子标题 → 影视元信息 | 间接(识别阶段) | +| `parse_metainfo_path_fast` | `parse_metainfo_path` | path, options | dict / None | **文件路径 → 季/集/Part/年份等** | **直接**:`MetaInfoPath` 用于转移规划、`__delete_version_files` 判同季同集同 Part | +| `find_metainfo_fast` | `find_metainfo` | title | `{title, metainfo}`,metainfo 含 tmdbid/doubanid/type/begin_season 等 | 标题里的 `{[tmdbid=…]}` 类内嵌 ID | 间接(识别阶段) | +| `parse_metamusic_fast` | `parse_metamusic`(getattr,可缺) | 标题等 | dict | 音乐元信息 | 间接:音乐 `size` 覆盖模式用 `audio_quality_score` | +| `parse_filter_rule_fast` | `parse_filter_rule` | expression | list(AST) | 过滤规则解析 | 无(订阅/搜索) | +| `filter_torrents_fast` / `_with_trace_fast` | `filter_torrents` | 规则 + 种子快照 | 命中序号列表(+trace) | 资源过滤 | 无 | +| `parse_indexer_torrents_fast` | `parse_indexer_torrents` | HTML + 选择器配置 | list[dict] | 站点列表页解析 | 无 | +| `parse_indexer_subtitles_fast` | `parse_indexer_subtitles` | HTML + 配置 | list[dict] | 站点字幕页解析 | 无 | +| `parse_rss_items_fast` | `parse_rss_items` | xml, max_items | list[dict] / None | RSS/Atom 流式解析 | 无 | +| `jieba_cut` | `app/foundation/text.py` | text, hmm, cut_all | list[str] | 中文分词 | 无 | +| `zhconv_fast` | `app/foundation/text.py` | text, target | str | 繁简转换 | 间接(标题归一) | + +包装层的通用语义(`rust.py`):开关 `RUST_ACCEL` 可关;V3t(free-threaded)下 `is_required()` 为真、不许关;Rust 返回 `None` 或抛非 panic 异常 → **回退 Python 实现**,panic 类异常单独处理(`_raise_non_rust_panic`)。即 Rust 只是"同语义的快路径",Python fallback 是兼容基线。 + +## 2. Rust 下沉的动机(证据) + +git 历史不可用。仓内文档给出的证据: + +- `docs/v2-to-v3-overview.md` §5.6:Rust "主要加速:影视和音乐标题解析、站点索引结果解析、RSS/Atom 解析、资源过滤和表达式处理、中文转换等高频文本操作"。**列表里没有文件操作。** +- 包 README:"业务模块只接收和返回纯 Rust 类型…核心解析器可以独立单测,并可在转换完成后**释放 Python GIL**"。 +- `docs/v3t-runtime-governance.md` §6.2:应用识别热点三组中位耗时 Python fallback 0.0688s / V3+Rust 0.0353s / V3t+Rust 0.0355s,Rust 约 **0.51x**;"三组业务结果校验和一致"(强调语义等价)。§6.1:32 线程直接调用 Rust ABI 探针 V3 40.8k ops/s → V3t 61.6k ops/s。 + +判断:动机是 **CPU 密集文本解析的吞吐 + 释放 GIL 以配合 V3t 多线程**,并以"与 Python fallback 结果一致"为硬约束。文件 IO 本身在 CPython 里 `read/write` 已释放 GIL,瓶颈在磁盘/挂载,下沉 Rust 无收益,所以 MP 没做。它为 IO 做的是**故障隔离**(fsproxy),不是提速。 + +## 3. Python 侧保留的转移逻辑(即 MP 真正的转移语义) + +以下全部是 Python,行号对 `e6820a8`。 + +### 3.1 本地存储 `storages/local.py` + +- **临时文件与原子提交**(`_write_atomically` 278–322):写到目标同目录的隐藏临时名 `...mp-partial`,完成后 `os.replace` 覆盖到目标;失败路径 finally 删临时文件。注释点明:临时文件必须同目录,否则 replace 退化为拷贝;带 PID 防多进程互踩。 +- **残留清理**(`_cleanup_stale_partials` 257–276):每次写入前只清**本目标目录**里 mtime 超过 24h 的 `*.mp-partial`;不做全库扫描(网络挂载上代价不可接受)。 +- **move**(416–446):先 `os.replace`,EXDEV 等 OSError → 降级为"原子复制 + 删源";**删源失败只记 warn,返回成功**。`src == dest` 直接成功。 +- **权限**(`_copy_with_target_permissions` 324–340 及 fsworker `_copy`):**只复制内容和 atime/mtime,不复制权限/属主**,理由是目标目录的默认权限和继承 ACL 是媒体库访问策略,用源权限覆盖会清掉继承 ACL。 +- **进度**:只在"源、目标都是网络盘且同盘"时向 UI 回调(`__should_show_progress` 395–403),但 worker 内部始终每秒上报一次作为心跳。 +- **取消**:父进程在每次收到进度时检查取消标记,要取消直接杀 worker。 +- **hardlink**(`host.py:500` `SystemUtils.link`):先 link 到 `dest + ".mp"` 临时名,再 `shutil.move` 到最终名。**softlink**:直接 `symlink_to`,无临时名。 + +### 3.2 fsproxy / fsworker(挂死隔离) + +- worker 是只依赖标准库、按文件路径直接执行的常驻子进程(毫秒级启动),stdin/stdout 逐行 JSON。 +- 快操作(stat/exists/listdir/rename/unlink/rmtree/count_entries)默认 30s 超时;复制按"**两次进度心跳间隔**"判挂死,默认 120s(`FS_PROXY_STALL_TIMEOUT`),大文件复制几小时不会误杀。 +- 超时 → SIGKILL worker → 抛 `FileSystemTimeout(OSError)`,复用上层已有的 OSError 退避/登记重试。下一次请求自动拉起新 worker。 +- `exists` 用 `os.stat` 而非 `os.path.exists`:挂载抖动不能被误判成"文件不存在"。 +- 放行白名单按"被强杀后的副作用"划分:只读、同设备 rename、unlink 原子;rmtree 非原子但后果轻于"写一半"。跨设备 rename 在 worker 里显式拒绝(errno 18)。 +- 请求严格串行(一把锁),即同一时刻只有一个 fs 操作在代理里。 + +### 3.3 TransHandler 与链路(`transhandler.py`、`chain/`) + +- **覆盖模式**(`__resolve_overwrite` 494–634):`always` / `latest` / `never` / `size`;另有插件事件可直接裁决覆盖并改写 source/target size。 + - 随行文件(字幕、外挂音轨)**永远覆盖**,不走模式判断。 + - 目标查询失败(`StorageQueryError`)→ **跳过并报失败**,"避免误覆盖"。 + - 目标是指向不存在文件的**失效软链** → 视为可覆盖。 + - `size`:目标比源小才覆盖;音乐改比 `audio_quality_score`。 + - `latest`:覆盖本文件,并删除同目录**同季同集同 Part** 的其它视频版本(只删 `RMT_MEDIAEXT` 视频扩展名,issue 5449/5862)。 + - `never`/不覆盖:返回 `overwrite_skipped=True`,结算时若同路径此前已成功入库,不把历史改写为失败(`settlement.py _is_overwrite_declined`)。 +- **步骤账本**(680–1000):每个副作用是一个持久步骤(`ensure_target_directory` / `cleanup_previous_destination` / `discover_version_targets` / 逐个版本删除 / `delete_overwrite_target` / `materialize_target` / `delete_move_source`),每步配一个 observe 函数用于中断后判定 APPLIED / NOT_APPLIED / CONFLICT / UNKNOWN;证据不足(UNKNOWN)时**禁止自动重放**。 + - copy 的已应用判定用 `filecmp.cmp(shallow=False)` 全量比对;link 用 `samefile`;softlink 比 `resolve()`;move 看"源无目标有"。 + - 跨存储 move 被拆成 materialize(copy) + delete_move_source 两步。 +- **随行字幕改名**(`__rename_subtitles` 1614):按正则识别繁中/简中/日/英 → `.zh-tw` / `.chi.zh-cn` / `.ja` / `.eng`,与 `DEFAULT_SUB` 设置一致时加 `.default`。繁中先于简中判定。 +- **空目录清理**(`chain/storage.py delete_media_file` 161–222):向上逐级删除"不含媒体文件"的目录;一级以内路径禁止删除;遇到已配置的下载目录/媒体库目录的祖先即停;蓝光原盘目录整体删除。move 全部成功后才触发,且挂载盘可配置关闭(`MountedLocalDiskDeleteEmptyDirs`,默认开;关闭时对本地源探测是否网络/FUSE 盘,结果按目录缓存)。 +- **扫描**:`SystemUtils.list_files` 用 `os.scandir` 递归、不跟随符号链接、按扩展名正则和最小体积过滤,单条目 OSError 跳过。**没有增量/游标**。目录计数在 worker 内一次性 `os.walk` 并设上限 10000,避免上万次 IPC 往返。 + +## 4. 对照我们的实现 + +我们的文件:`packages/adapters/src/fs/{copy,move,scan,stat,errors}.ts`(任务里提到的 `ports.ts` 不存在,错误分类在 `errors.ts`)、`packages/domain/src/organize/{plan,sidecars}.ts`、`apps/server/src/modules/acquisition-organize/services/organize.ts`。 + +| # | MP 语义 | 我们现状 | 判定 | 建议 | +| --- | --- | --- | --- | --- | +| 1 | 覆盖模式 always/latest/never/size + 插件裁决 | 域层 `ExistingPolicy = skip / replace_with_verify / conflict`,但**服务端从不传 `onExisting`**,恒为 `conflict`;执行端 `copyWithVerify` 遇已存在一律 EEXIST;没有 `replace` 执行分支 | 不同 + 部分缺失 | 保持"默认冲突转待处理"(比 MP 默认更保守,合理)。W5 待办:把 `skip`/`replace_with_verify` 接到设置与执行端;`replace` 实现为"新文件写临时名 → 校验 → 旧目标先改名留底 → 提交 → 删留底",不做无备份覆盖 | +| 2 | 随行文件永远覆盖 | 随行跟随主文件 action(主 skip/hold 则随行同样),目标存在走同一冲突逻辑 | 不同 | 保持不同:随行覆盖会静默替换用户手放的字幕。可加一条:主文件是 replace 时随行也 replace | +| 3 | 目标查询失败 → 跳过,防误覆盖 | `lstat` 非 ENOENT 抛 FsError;`statSafe` 区分 missing/其它 | 相同 | — | +| 4 | 失效软链目标视为可覆盖 | 未处理,`lstat` 存在即 EEXIST | 缺失 | W5:目标是软链且 `stat` ENOENT 时,在冲突证据里标 `dangling_symlink`,由 `replace_with_verify` 处理,不自动覆盖 | +| 5 | `latest` 删同季同集同 Part 旧版本,只删视频扩展名 | 升级时旧资产保留,只开"旧版本待处置"决策 | 不同 | 保持不同(我们有资产模型和保留策略,删旧版应走处置流程)。但**照抄判定口径**:同季+同集+同 Part+仅视频扩展名,作为处置候选的筛选条件 | +| 6 | 临时文件同目录、隐藏名、带 PID | `partialPathFor`:同目录 `....uhd-partial` | 相同(更强,加随机) | — | +| 7 | 原子提交 `os.replace`(会覆盖) | `commitNoClobber`:link(tmp,dst) 原子失败于已存在,不支持时退回 lstat+rename;fsync 文件和目录;提交前比源 size+mtime 防源变更 | 不同(更严) | 保持 | +| 8 | 写前顺带清本目录 24h 以上 partial | `sweepStalePartials` 已实现但**没有调用方** | 缺失 | **照抄**:`copyWithVerify` 开始前对目标目录调用一次,阈值 24h,失败吞掉 | +| 9 | move 先 rename,EXDEV 静默降级为复制+删源;删源失败仍报成功 | `moveSafe` 用 link+unlink(no-clobber,支持中断续删);EXDEV 显式返回,需 `allowCrossVolumeMove`;跨卷三步 copy→verify→delete_source,删源失败 = attention + 决策 | 不同(更严) | 保持;MP 的"删源失败报成功"不照抄 | +| 10 | 步骤账本 + observe 判定 APPLIED/NOT_APPLIED/CONFLICT/UNKNOWN,UNKNOWN 禁止重放 | `asset_operation` + `withAttemptToken`;`transferFile` 开头对账:源无目标有且大小对→成功;两边都在→sha256 比对 | 大体相同 | 照抄一点:把"对账无法判定"显式建成 `unknown` 状态进待处理,而不是落到 `failed`(现在 `filesMatchSha256` 抛错被 catch 成 `match:false` → 报 EEXIST,错误分类不准) | +| 11 | hardlink:先链到临时名再改名;softlink | 契约有 `OrganizationAction.hardlink` 和 `LocationKind.hardlink`,执行端只有 copy/move | 缺失 | W5:hardlink 直接 `link(src,dst)`(本身 no-clobber、原子),EEXIST 时比 ino/dev 判"已完成";**不需要** MP 的临时名 + move(那一步把原子 link 变成了可覆盖)。softlink 暂不做 | +| 12 | 只拷内容+时间戳,不拷权限/属主,尊重目标目录继承 ACL | 只 `utimes`,新文件权限由 umask/目录决定 | 相同 | 在 copy.ts 注释里写明"故意不拷权限"的理由,防后人补上 | +| 13 | 进度:worker 每秒心跳,UI 仅网络盘显示;父进程借心跳检查取消 | `onProgress` 每块回调但**无调用方**;取消走 AbortSignal 贯穿 pipeline | 部分缺失 | W5:organize 接 `onProgress`,按 ≥1s 节流写 `organization_file_result` 进度/事件;同时作为卡死检测的心跳(见 #14) | +| 14 | fsproxy:挂死系统调用放进可 SIGKILL 的子进程,快操作 30s、复制按 120s 无进度判挂死,转成普通 OSError | 只有库根哨兵 `checkRoot`(事前检查);Deno 的 fs 调用挂死时占住 libuv/tokio 阻塞线程池,**无法取消也无法回收**,AbortSignal 对已发出的 syscall 无效 | **缺失(最大差距)** | W5 首要:给 fs 操作加"看门狗"——复制按进度停滞 N 秒判挂死、stat/readdir 设超时;超时后标记库根 `suspect`、停止向该根派发、进 `ROOT_OFFLINE` 待处理。彻底回收需把大文件复制放子进程(`cp --reflink=auto` 或自带小 worker),超时 kill。见 §5 基准 3 | +| 15 | exists 用 stat,抖动不等于不存在 | `statSafe` 分 ok/missing/其它;`toFsError` 只把 ENOENT 当不存在 | 相同 | — | +| 16 | 字幕语言后缀 `.zh-tw/.chi.zh-cn/.ja/.eng`,繁先于简,默认字幕 `.default` | `sidecarLanguageSuffix` 同样的后缀、繁先于简,另加 `.ko`;**无 `.default`** | 大体相同 | 照抄 `.default` 标记(设置项 defaultSubtitleLang),Jellyfin/Plex 都认 | +| 17 | move 成功后向上清空目录:不删一级路径、不越过下载/库根、有媒体文件即停、挂载盘可关 | 无任何空目录清理 | 缺失 | W5,照抄边界规则;额外要求:仅在该记录全部成功时、只清本次源路径链上的目录、网络/FUSE 源默认关;删目录用 `rmdir`(非递归,天然只删空目录),而不是 MP 的"判定无媒体再递归删" | +| 18 | 蓝光原盘目录整体搬/删 | 未见 BDMV 专门处理 | 缺失 | W5,另立条目(见 `moviepilot-transfer.md`) | +| 19 | 错误分类:所有 IO 故障归 OSError,超时也伪装成 OSError 复用重试 | `FsErrorCode`:EXDEV/EEXIST/ENOENT/EACCES/ENOSPC/VERIFY_FAILED/SOURCE_CHANGED/ROOT_OFFLINE/ABORTED/IO | 我们更细 | 补一个 `TIMEOUT`(挂死),重试策略:TIMEOUT/IO 可退避重试,EACCES/ENOSPC 直接进待处理不重试,EEXIST/VERIFY_FAILED 需人工 | +| 20 | 扫描:scandir 递归、不跟软链、按扩展/体积过滤、无增量;计数在 worker 内一次性完成并设上限 | `scanIncremental`:游标分页、按 size+mtime 标 new/changed/unchanged、统计 stat/dir 数、错误收集 | 我们更强 | 已有每批 lstat 上限 2000、不跟随软链(`lstat`);只需照抄"整体超时"(见 #14) | +| 21 | 校验:copy 已应用判定用全量逐字节比较 | size/sample/sha256 可配,sha256 走 `sha256sum` 子进程;跨卷已存在目标强制 sha256 | 我们更强 | — | + +## 5. 性能:Deno 单线程下我们够不够 + +MP 的 Rust 不是给 IO 用的,所以"Rust 提速转移"不构成我们要追的指标。IO 这一侧,MP 用的就是 Python 分块 read/write(chunk 可配)+ 子进程代理,并不比我们快。我们的做法: + +- 复制:Node 流 `pipeline`,1 MiB 块,read/write 在 libuv 线程池,事件循环只做每块一次 Transform 回调(进度 + 可选增量 sha256)。 +- 全量 sha256:`sha256sum` 子进程,完全不占事件循环;不可用时回退进程内流式。 +- 组织执行在 `io` worker 组,与 agent 组分开;有 loop lag probe。 + +潜在瓶颈只有两处:(a) `hash: "sha256"` 时 Transform 里的**增量 `hasher.update` 在主线程**,1 GB 约 1000 次、每次 1 MiB,单核 SHA-256 约 1–2 GB/s,会在大文件复制期间持续占用事件循环;(b) libuv 默认线程池 4 个,多个并发复制加上 stat/readdir 时,挂死的挂载会把线程池占满,**连其它根的 fs 调用也会排队**(这是 #14 的性能面)。 + +需要基准验证的 3 个场景与建议阈值: + +| # | 场景 | 测量 | 建议阈值 | +| --- | --- | --- | --- | +| B1 | 同机不同卷复制 1 个 50 GB 文件,`hash=sha256`,同时跑 API 请求 | 吞吐 MB/s;loop lag p99;API p95 | 吞吐 ≥ `cp` 同条件的 85%;loop lag p99 < 50 ms;API p95 不超过空载 1.25x(同 MP 的 1.25x 门禁)。不达标则把增量哈希挪到 `sha256sum` 读 tmp 的一次性子进程(已有路径),Transform 里只计数 | +| B2 | 一季 24 集 × 2–5 GB,io worker 并发 1/2/4 | 总时长;每文件 mbps 分布;线程池排队(stat 延迟) | 并发 2 相对 1 的总时长下降 ≥ 30% 才值得开;任一并发下 stat p99 < 200 ms;HDD 目标盘默认并发 1 | +| B3 | 挂死注入:FUSE 挂载(或 `dm-delay`/SIGSTOP 的 sshfs)在复制中途冻结 | 从冻结到检测到的时间;其它根上的 organize/扫描是否被拖住;恢复后是否留下最终名半截文件 | 检测时间 ≤ 进度停滞阈值 + 10 s(阈值建议 120 s,同 MP);其它根操作 p99 不受影响(否则必须引入子进程复制);目标目录永不出现最终名半成品,partial 在 24 h 内被 #8 清走 | + +扫描增量(`scanIncremental`)在 10 万文件、网络盘上的 stat 速率可作为 B2 的附带观测:建议单根全量扫描 < 5 min,超过则启用游标分片 + 限速。 + +## 6. 值得照抄的业务语义(按优先级) + +1. **挂死隔离**:把"不可中断的 block"转成"可重试的 crash"——按进度心跳而非总时长判定复制挂死,超时回收并归为普通 IO 错误(我们补 `TIMEOUT` 码 + 根 suspect)。 +2. **写前顺带清本目录旧 partial**(24h,只清本目录、不全库扫)。 +3. **空目录上溯清理的边界**:不删一级路径、不越过下载/库根、有媒体即停、网络/FUSE 可关、仅全部成功后执行。 +4. **`latest` 旧版本的判定口径**:同季 + 同集 + 同 Part + 仅视频扩展名(我们用作处置候选,不直接删)。 +5. **不复制权限/属主,尊重目标目录继承 ACL**;以及字幕 `.default` 标记。 + +MP 做法中明确**不照抄**的:删源失败仍报成功;move 遇 EXDEV 静默降级;`os.replace` 覆盖式提交;hardlink 先临时名再 move(破坏 link 的 no-clobber 原子性);随行文件永远覆盖。