Files
UHD_Media_Tool/docs/porting/moviepilot-rust-transfer.md
T
rootandClaude Opus 5.5 2e3c2098fd docs: S-9 sync READMEs and porting notes with W5 code
- contracts README lists files.ts / auth.ts / browser.ts / redact.ts and auth:"session"
- adapters README fs row: FsProxy and the ./fsworker export
- server README: file-manager module, worker:agent and internal fsworker roles
- moviepilot-rust-transfer §7: compiled-binary fsworker and trash_item done (W5-E)
- 04 S9 row marked fixed (W6-B TOTP entry)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-28 00:50:29 +08:00

196 lines
27 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 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):写到目标同目录的隐藏临时名 `.<name>.<pid>.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`:同目录 `.<name>.<pid>.<rand>.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 原子性);随行文件永远覆盖。
## 7. W4-E4 落地状态(2026-09-27)
按 §4 对照表逐条:
| # | 条目 | 状态 | 落点 |
| --- | --- | --- | --- |
| 1 | onExisting 接线 + replace 分支 | **已做**。`ImportRequest.onExisting/grantReplace`(缺省 `ORGANIZE_ON_EXISTING=conflict`)只透传给域层规划;执行端按冻结计划的 `action` 传 `onExisting`(replace→`replace`,其余→`conflict`)。`copyWithVerify` 的 replace:新文件写临时 → 校验 → 旧文件硬链接留底 → `rename` 原子替换(无空窗)→ 留底按 `FS_REPLACE_OLD` 移到 `<库根>/.umt-trash/replaced-*/`(缺省)或删除;处置失败留原目录并建待处理。只替换普通文件,目录/软链(含失效软链)仍按冲突。move + replace = 替换式复制后删源,删源失败 attention | `adapters/fs/copy.ts replaceCommit`、`organize.ts transferFile` |
| 5 | latest 旧版本判定口径 | **已做**(只作处置候选)。`findOtherVersions`:同目录 + 同季 + 同集 + 同 Part + 仅视频扩展名;升级入库的"旧版本待处置"决策证据带 `otherVersionFiles`,不删 | `domain/organize/versions.ts`、`finalizeRecord` |
| 8 | 写前清 partial | **已做**。`copyWithVerify` 开始前清目标目录 >24h 的 `.uhd-partial`(`sweepPartialsOlderThanMs`,null 关闭);`executeRecord` 对每个目标目录再经 fsproxy 清一次(覆盖同卷 move)。阈值 `FS_PARTIAL_MAX_AGE_MS`,失败吞掉 | `copy.ts`、`organize.ts` |
| 10 | 对账比对异常不冒充 EEXIST | **已做**。`filesMatchSha256`/`filesMatch` 读失败抛 FsError;`transferFile` 对账阶段比对失败按 IO/TIMEOUT 失败并带 `evidence.phase=reconcile`;源/目标 stat 失败按 EACCES/ROOT_OFFLINE/TIMEOUT/IO 分类,只有 missing 才是 SOURCE_MISSING;跨卷 failed 带首个失败步骤的错误码 | `copy.ts`、`organize.ts statFailure` |
| 12 | 不复制权限/属主 | **确认现状**:只 `utimes`。`copyWithVerify` 注释写明理由(继承 ACL),防后人补 chmod/chown | `copy.ts` |
| 13/14 | 进度心跳 + 挂死隔离 | **已做**。`FsProxy` + `fsworker.ts`:每个文件操作一个执行子进程;数据类按进度停滞 `FS_HANG_TIMEOUT_MS`(120 s)判挂死,快操作按 `FS_QUICK_TIMEOUT_MS`(30 s);超时 SIGTERM → `FS_KILL_GRACE_MS` → SIGKILL → `FsError('TIMEOUT')`;心跳间隔 `FS_PROGRESS_INTERVAL_MS`。`checkRoot` 超时 = `offline(timeout)`,`statSafe` 超时 = `error(TIMEOUT)`,都不判缺失。`executeRecord` 的根检查、对账 stat/比对、copy/move/跨卷全部走 fsproxy。测试:worker 测试开关阻塞 JS 线程 + 屏蔽 SIGTERM → SIGKILL;FIFO 上 open 真实阻塞 → SIGTERM 回收 | `adapters/fs/fsproxy.ts`、`fsworker.ts`、`fsops.ts` |
| 16 | 字幕 `.default` | **已做**。`sidecarLanguageSuffix(tags, defaultLang)` / `OrganizeInput.defaultSubtitleLang`;服务端取 `ORGANIZE_DEFAULT_SUBTITLE_LANG`(不设不加) | `domain/organize/sidecars.ts` |
| 19 | TIMEOUT 错误码 | **已做**。`FsErrorCode` 加 `TIMEOUT`(ETIMEDOUT 也映射);`fsErrorClass`:IO/TIMEOUT/ROOT_OFFLINE/SOURCE_CHANGED=retry,EACCES/ENOSPC/EXDEV=attention,EEXIST/VERIFY_FAILED/ENOENT=manual。任务层:retry 类失败且账本尝试次数 < `FS_TRANSFER_MAX_ATTEMPTS`(3)→ 结果不入账本、文件保持 running,整轮结束后抛错交引擎退避重试;到上限 → 按失败入账 + 待处理 `fs_retry_exhausted:<opId>`,记录与下载保留、不重下(Slot 走 verify_failed → attention,不触发重新获取) | `errors.ts`、`organize.ts executeRecord` |
| 4 | 失效软链目标 | 未做(replace 对软链一律冲突,不自动覆盖) | W5 |
| 11 | hardlink | 留 W5 | — |
| 17 | 空目录上溯清理 | 留 W5(边界规则见 §3.3/§6) | — |
| 18 | 蓝光原盘目录 | 留 W5 | — |
新增配置(`apps/server/src/config.ts`):`FS_PROXY_MODE`、`FS_HANG_TIMEOUT_MS`、`FS_QUICK_TIMEOUT_MS`、`FS_PROGRESS_INTERVAL_MS`、`FS_KILL_GRACE_MS`、`FS_PARTIAL_MAX_AGE_MS`、`FS_TRANSFER_MAX_ATTEMPTS`、`FS_REPLACE_OLD`、`ORGANIZE_ON_EXISTING`、`ORGANIZE_DEFAULT_SUBTITLE_LANG`。
遗留:
- ~~**编译二进制**里回退 in-process 看门狗~~ **已做(W5-E,3641ab9)**:`main.ts` 在加载其余模块前分派 `--role fsworker`,compile 放开 `--allow-run=/app/umt-server`,FsProxy 在编译二进制里默认 `<execPath> --role fsworker`(`stats().workerKind = "self"`),容器演练见 `docs/runbook/deploy.md`。二进制不在 `/app/umt-server` 时仍回退 in-process。
- 规划侧(`planImport` 的源/目标 stat 与同尺寸 sha256)、`reconcile.ts`、文件管理(W5-A)仍直接调进程内 fs,未走 fsproxy(文件管理的 move/copy/organize 执行与回收站跨卷复制已经走 fsproxy,见 W5-A2 / W6-B)。
- ~~替换进 `.umt-trash` 的旧文件未登记 `trash_item`~~ **已做(W5-E)**:进 `<root>/.umt-trash/<day>/<uuid>/` 并登记 `trash_item`,到期由 `purgeTrash` 清理。
- 根挂死后没有"标 suspect、停止向该根派发"的根级熔断,目前靠根检查超时 → 整轮重试。
### 基准记录
`deno run -A scripts/bench/fs-bench.ts --base <被测盘> --other <跨卷目标>`,1 GiB 随机数据、10 万个 1 字节文件(100 目录 × 1000)。本机:96 核、125 GB 内存,本地盘 nvme(ext4,`/opt`),`/dev/shm` 为 tmpfs。数据刚写完即测,都在页缓存里(热缓存,代表 CPU/拷贝路径上限而非磁盘冷读)。
| 场景 | base=nvme → other=/dev/shm | base=/dev/shm → other=nvme |
| --- | --- | --- |
| 全量哈希 1 GiB:`sha256sum` 子进程 | 2716 ms / 377 MB/s | 2740 ms / 374 MB/s |
| 全量哈希 1 GiB:进程内流式 | 3411 ms / 300 MB/s | 3265 ms / 314 MB/s |
| 全量哈希 1 GiB:fsproxy 子进程(流式 + 心跳) | 3206 ms / 319 MB/s | 3175 ms / 323 MB/s |
| 跨卷 move 1 GiB,hash=size,进程内 | 1775 ms / 577 MB/s | 3008 ms / 340 MB/s |
| 跨卷 move 1 GiB,hash=size,fsproxy | 2192 ms / 467 MB/s(心跳 2 次) | 2860 ms / 358 MB/s |
| 跨卷 move 1 GiB,hash=sha256,进程内 | 7382 ms / 139 MB/s | 7971 ms / 128 MB/s |
| 跨卷 move 1 GiB,hash=sha256,fsproxy | 7275 ms / 141 MB/s(心跳 8 次) | 8042 ms / 127 MB/s |
| 对照:`cp` 跨卷 | 578 ms / 1770 MB/s | 633 ms / 1618 MB/s |
| 扫描 10 万文件(`scanIncremental`,每批 2000,50 批) | 15.1 s | 17.4 s |
| 扫描 10 万文件(单批) | 11.7 s | 15.7 s |
读法:
- 这台机器的 SHA-256 单核约 300–380 MB/s(无 SHA-NI 加速迹象),`sha256sum` 只比进程内流式快约 20%;fsproxy 子进程里流式哈希与进程内持平,**挂死隔离几乎不损失吞吐**(子进程启动约 50 ms,1 GiB 级文件可忽略)。
- hash=sha256 的跨卷 move 瓶颈是哈希:copy 边写边算 + 回读临时文件再算一遍 ≈ 2× 哈希时间。本次顺带去掉了 `crossDeviceMove` verify 步骤对两边的重复全量哈希(copy 已 sha256 核验时 verify 只比大小),从 103 MB/s 提到约 140 MB/s。
- hash=size 的 Node 流复制约为 `cp` 的 1/3(`cp` 用 copy_file_range),对 HDD/千兆网络盘不是瓶颈;若将来要贴近 `cp`,可在 worker 里改走 `copy_file_range`/`cp --reflink=auto`,仍由心跳看门狗兜底。
- 10 万文件扫描 12–17 s,远低于 §5 建议的"单根全量 < 5 min";分批游标的额外开销约 20%。网络盘上 stat 延迟高一两个数量级,需实测。
- §5 的 B1(50 GB + API 延迟)、B2(并发 1/2/4)、B3(FUSE 冻结注入)仍未跑;B3 的判定逻辑已由 FIFO/阻塞测试覆盖,真实 FUSE 冻结待有环境再测。