Files
UHD_Media_Tool/docs/porting/moviepilot-transfer.md
T
rootandClaude Opus 5.5 b343dddbf6 docs: 清理过程文档,CHANGELOG 补齐 0.3.8–0.3.14
- 删除 docs/plan 下 00–18 的计划、评审、验收、发布记录与升级证据,docs/spikes 与 spikes 验证代码
  (保留 spikes/movieclaw-src 本地克隆),以及 4 份一次性审查 / 调研;全部可在 ceb66f8 查到
- MP 对照移到 docs/porting/moviepilot-v3-comparison.md;经验教训改为 docs/plan/lessons.md(补第 25 条)
- docs/plan/README.md:文档放哪、当前状态、待办
- 引用改写:代码注释指向在维护的文档,其余注明已删除与 git 位置;清掉原有 6 个失效链接
- README 开头改为当前状态与入口;CHANGELOG 补齐 0.3.8–0.3.14 与工程变更

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-02 16:08:09 +08:00

52 KiB
Raw Blame History

MoviePilot 视频文件转移入库:迁移对照记录

格式:REQ §14.1.2 迁移交付规范。本文件只记录行为对照和 TS 重写建议,不复制 MP 源码(MP 为 GPLv3,见 license-and-provenance.md)。 对应需求:REQ §7.1 / §7.1.1(稳定路径)、§8.3(Copy/Move 与缓存清理)、§8.4(qB 标签接管)、§14.1.1(多文件/整季包/目标冲突/中断恢复/源数据清理专项)、§14.1.3(转移入库优先专项)。任务书:00-first-version-plan(已删除,见 git ceb66f8) §3.1 第 7、8 条。

0. 上游版本与核验范围

项 值
上游仓库 https://github.com/jxxghp/MoviePilot,分支 v3
本地路径 /opt/moviepilot-plus/upstream/MoviePilot
本次对照的精确提交 e6820a83ab05e8a00eb0e14adf0f357faa965c7e(2026-08-30 13:32 +0800,"docs(architecture): close residual audit goal";version.py = v3.0.0)
克隆形态 单提交浅克隆(git log 只有 1 条),无法回看历史
与 REQ 附录快照差异 REQ 附录 [S16] 引用 33da9ce65d5020a8e087584a3321c1ff353c1921,本地仓库不含该对象(git cat-file 失败)。本文件所有行号只对 e6820a83 有效;若需对齐 33da9ce,须重新拉取并复核本文"关键分支"各行。
LICENSE GPLv3(本地 LICENSE 首行已核对)
核验方式 只读源码,沿调用链阅读;未运行服务、未跑上游测试。标注"推断"的条目为源码推理结论,须在 testkit 中用样本复现。

V3 目录布局与旧版(V1/V2 app/chain/transfer.py 单文件)不同,本文件对应的实际路径如下:

层 路径 职责
入口/批次 app/chain/transfer/workflow.py do_transfer 公开入口、候选收集、任务构建、同步执行
候选规划 app/chain/transfer/request.py _TransferCandidatePlanner:文件元数据、主视频/附属文件排序与元数据继承
识别与定向 app/chain/transfer/execution.py __perform_transfer:识别媒体、补 TMDB、取集信息、选目标目录
冻结计划 app/chain/transfer/plan.py _plan_checkpoint_and_execute:先持久化计划检查点再执行
文件模块 app/modules/filemanager/module.py plan_transfer / execute_transfer_plan:解析目录配置 → 交给 TransHandler
核心算法 app/modules/filemanager/transhandler.py 目标路径、覆盖裁决、步骤化执行、字幕改名、版本文件删除
本地存储 app/modules/filemanager/storages/local.py copy/move/link/softlink 的实际文件系统操作、临时文件
硬/软链接 app/adapters/system/host.py SystemUtils.link / softlink
持久执行 app/application/transfer/execution.py、app/chain/transfer/execution.py 步骤账本状态机、_DurableTransferStepRunner
准入与恢复 app/application/transfer/workflow.py、app/chain/transfer/queue.py TransferPlanningInput/TransferPlanCheckpoint、租约、__replay_pending
结算 app/chain/transfer/settlement.py __default_callback:写历史、打"已整理"标签、Move 模式删种
历史 app/db/models/transferhistory.py、app/application/history.py TransferHistory 表、add_transfer_success/fail、查重闸 evaluate_history_gate
过滤/清理条件 app/chain/transfer/filter.py、app/chain/transfer/history.py 屏蔽词、_can_delete_torrent、空目录清理、覆盖拒绝判定
附属文件归属 app/chain/transfer/records.py 文件键、字幕主干名、下载历史回查

1. 端到端调用链(自动整理:下载完成 → 入库)

下载器轮询/目录监控
 └─ TransferChain.do_transfer                               chain/transfer/workflow.py:350
     └─ _run_transfer_workflow                              workflow.py:418
         ├─ _normalize_transfer_identity                    workflow.py:221   显式 media_source/media_id 必须成对
         ├─ _collect_transfer_candidates                    workflow.py:273   遍历 + 过滤(后缀/大小/屏蔽词/回收站)
         │   └─ __get_trans_fileitems                       workflow.py:62    蓝光原盘目录整体作为一个候选
         ├─ _TransferCandidatePlanner._plan_file_items      request.py:239    主视频优先,同名字幕/音轨紧随并继承元数据
         ├─ _build_transfer_tasks                           workflow.py:651   历史查重闸、回查下载历史、构建 TransferTask
         │   └─ put_to_queue / __put_to_jobview             queue.py:356      后台 → 持久准入(admission)+内存队列
         └─ _execute_transfer_tasks                         workflow.py:935   同步路径逐任务执行
              └─ __handle_transfer → __perform_transfer     chain/transfer/execution.py:290/304
                  ├─ 识别媒体(下载历史 ID 优先 → 按 meta 识别)
                  ├─ supplement_tmdb_info / scrape_follow_tmdb 标题回溯 execution.py:403/408
                  ├─ tmdb_episodes(集信息,供命名)              execution.py:443
                  ├─ DirectoryHelper.get_dir(选目录配置)       execution.py:453/460
                  └─ _plan_checkpoint_and_execute            plan.py:526
                      ├─ FileManagerModule.plan_transfer     modules/filemanager/module.py:417
                      │   └─ TransHandler.plan_transfer      transhandler.py:263   纯计算,零副作用
                      ├─ __persist_transfer_checkpoint       plan.py:701           计划落库(带输入指纹)
                      └─ FileManagerModule.execute_transfer_plan  module.py:559
                          └─ TransHandler.execute_transfer_plan   transhandler.py:1085
                              ├─ __resolve_overwrite_with_step    :989  (decision/resolve_overwrite)
                              ├─ __intercept_with_step            :1054 (decision/plugin_transfer_intercept)
                              ├─ __cleanup_with_step              :908  (prepare/cleanup_previous_destination)
                              ├─ __ensure_directory_with_step     :875  (prepare/ensure_target_directory)
                              ├─ __delete_version_files_with_steps:1847 (prepare/discover_version_targets, delete_version_target)
                              ├─ __delete_target_with_step        :960  (prepare/delete_overwrite_target)
                              └─ __execute_transfer_with_steps    :786  (transfer/materialize_target, transfer/delete_move_source)
                                  └─ __transfer_command           :1404 → LocalStorage.copy/move/link/softlink
 └─ 结算:TransferChain.__default_callback                   chain/transfer/settlement.py:255
     ├─ add_transfer_success / add_transfer_fail            application/history.py:1366/1421
     ├─ __mark_torrent_completed_if_done → qB 标签“已整理”  settlement.py:620
     └─ move 模式 + 全部成功 → _can_delete_torrent → remove_torrents(delete_file=True)  settlement.py:470-513

2. 模块对照

2.1 候选文件收集与过滤

项 内容
上游 app/chain/transfer/workflow.py _collect_transfer_candidates(:273)、__get_trans_fileitems(:62);app/chain/transfer/filter.py _is_media_file(:125)、_is_allow_filesize(:354)、_is_subtitle_file(:101)、_is_audio_file(:111);app/chain/transfer/history.py _is_blocked_by_exclude_words(:286)
输入 源 FileItem(文件或目录)、批次媒体类型、min_filesize(MB)、自定义集数模板 EpisodeFormat、屏蔽词列表(系统配置 TransferExcludeWords)
输出 List[(FileItem, is_bluray_dir)] + 是否命中过集数模板
关键分支 ① 路径含 BDMV/STREAM → 回溯到 BDMV 的父目录作为一个目录候选(is_bluray_dir=True);② 目录的直接子项含原盘结构 → 整个目录作为一个候选,不递归;③ 普通目录 → 递归,所有叶子文件各自成为候选;④ 过滤:蓝光目录/字幕/音轨不受"媒体后缀+最小体积"约束,其余必须命中 RMT_MEDIAEXT 且 ≥ min_filesize;⑤ 路径含 /@Recycle/、/#recycle/、/.(任意隐藏段)、/@eaDir 一律排除;⑥ 屏蔽词为正则,对完整路径 re.search(..., IGNORECASE);⑦ 设置了集数模板但没有文件匹配 → 返回成功且不整理(workflow.py:558 "未匹配到集数定位模板,跳过整理")
默认后缀 RMT_MEDIAEXT:.mp4 .mkv .ts .iso .rmvb .avi .mov .mpeg .mpg .wmv .3gp .asf .m4v .flv .m2ts .strm .tp .f4v;RMT_SUBEXT:.srt .ass .ssa .sup;RMT_AUDIOEXT:.aac .ac3 .m4a … (app/runtime/config.py:345-380)
对应 TS packages/domain/src/organize/candidates.ts
export function collectImportCandidates(tree: FileTreeSnapshot, opts: CandidateOptions): ImportCandidate[]
export function isBlurayRoot(entries: FileEntry[]): boolean
保留行为 原盘目录整体搬运;隐藏/回收站路径排除;字幕/音轨不受体积下限;屏蔽词作用于完整相对路径
调整项 ① 文件树由 adapters 先生成只读快照(路径、size、mtime、inode/dev)再交给 domain,domain 不做 IO(任务书 §2 约定);② 屏蔽词改为结构化规则(正则需预编译 + 超时保护),命中结果写入 organization_file_result.skip_reason,不静默丢弃(REQ §14.1.1"跳过文件如何记账");③ 候选以 torrent 文件清单为准(qB files API)而不是只扫目录,防止同一共享保存根下的其他种子文件被误纳入(REQ §8.3 共享保存根)

2.2 主视频 / 附属文件排序与元数据继承(多文件、整季包)

项 内容
上游 app/chain/transfer/request.py _TransferCandidatePlanner._plan_file_items(:239)、_build_directory_index(:181)、_get_single_file_sibling_items(:206)、_apply_meta_overrides(:108);app/chain/transfer/records.py _get_related_main_file_key(:309)、_get_subtitle_media_stem(:284)、SUBTITLE_STEM_TAGS(:33);_is_same_media_meta(约 :255)
输入 候选列表、批次 meta(可空)、手动季号 season、集数模板 FormatParser、sync_extra_files 开关
输出 有序列表(主视频 → 其同名附属 → 剩余文件)+ inherited_meta_map(附属文件键 → 主视频 meta 副本)
关键分支 ① 同目录内按主干名(小写 stem)精确匹配:字幕会逐级剥掉 .chi/.chs/.cht/.default/.cn/.cc 等语言标签后再比;唯一匹配才关联(多个主视频同 stem → 不关联);② 关联上的附属文件直接复用主视频 meta(季集、身份一致);③ 未关联的附属文件仅在 sync_extra_files=True 且非单文件模式时,单独按路径解析 meta 并用 _is_same_media_meta 比较(类型、季、集、年份等"任一侧有值则必须相等"),相同才跟随;④ 单文件整理:额外读取一次父目录找同名字幕/音轨;若用户选中的是字幕本身,则找其主视频的 meta 继承;⑤ 剩余文件按原顺序最后处理,各自独立识别
整季包行为 每个视频文件都是独立任务(TransferTask),各自识别季集、各自冻结计划、各自写一条 TransferHistory;没有"整季包事务"。部分成功就部分入库,失败文件单独失败记录
对应 TS packages/domain/src/organize/sidecars.ts
export function pairSidecars(files: ParsedFile[]): { mains: MainFile[]; sidecars: SidecarLink[]; orphans: ParsedFile[] }
export function subtitleMediaStem(name: string): string
保留行为 主干名精确匹配 + 唯一性要求;字幕语言标签剥离表;附属文件继承主视频 meta;逐文件独立结果
调整项 ① 无法确认归属的附属文件进入 orphans,不搬也不删,记录为待处理(REQ §14.1.3"无法确认归属时不误搬或误删");MP 会把剩余文件当独立任务尝试识别,可能把孤立字幕按自身文件名识别到别的作品;② 整季包在本项目额外生成 organization_record(批次)+ organization_file_result(逐文件),批次状态 = 聚合,部分成功显式为 partial;③ 附属文件关联结果写回 asset_location 的 sidecar 关系,便于后续删除/迁移时一起处理

2.3 识别、目标目录选择与集信息

项 内容
上游 app/chain/transfer/execution.py __perform_transfer(:304);app/chain/transfer/workflow.py _build_transfer_tasks(:651,下载历史回查、电影合集年份冲突);app/chain/transfer/records.py _resolve_download_history(:131)、_is_movie_year_conflict(:186);app/application/directory.py DirectoryHelper.get_dir(:117)
输入 TransferTask(fileitem、meta、可选 mediainfo、下载历史、目标目录配置)
输出 补全后的 mediainfo、episodes_info、target_directory、target_storage
关键分支 ① 下载历史回查顺序:显式 hash → 原盘目录路径 → 文件 fullpath 记录 → 逐级父目录(遇到共享下载根只接受明确文件记录匹配,然后停止上溯,workflow.py:150 _get_shared_download_roots);② 下载历史有 media_source+media_id 且无年份冲突 → 按 ID 识别;否则按文件名 meta 识别;③ 电影合集:文件年份 ≠ 下载历史年份 → 放弃历史 ID,逐文件重新识别;④ 识别失败 → 写"未识别到媒体信息"的计划拒绝检查点并结算为失败;⑤ SCRAPE_FOLLOW_TMDB=False 且主源为 TMDB 时,用该 TMDB ID 首条整理历史的 title 覆盖当前 title(execution.py:408-417)——这是 MP 自己的"译名变化不改目录"机制;⑥ 电视剧取 tmdb_episodes(tmdbid, season 默认1, episode_group) 供命名模板用;⑦ 目录配置按"源目录匹配 → 同盘优先 → 媒体类型/类别"选取;⑧ 目录启用了类别子目录但 mediainfo 无 category → 拒绝
对应 TS 识别本身见 moviepilot-metadata-recognition.md。此处:packages/domain/src/organize/target-binding.ts
export function resolveTargetBinding(input: { entity: MediaEntityRef; unit: ContentUnitRef; existing: PathBinding[]; roots: LibraryRoot[]; template: TemplateRevision }): TargetBindingDecision
保留行为 下载历史优先于文件名;共享下载根上溯截止;电影合集年份冲突逐文件识别;"标题回溯到首次入库名"的思想
调整项 ① MP 用"历史 title 覆盖"间接稳定目录,本项目改为显式 path_binding:作品/季目录一旦绑定,后续入库直接用绑定的 library_root_id + relative_path,不再经过标题渲染(REQ §7.1);② 两个历史目录并存时 MP 会随便命中首条历史,本项目建冲突任务、不自动合并(REQ §7.1 关键场景);③ 识别失败不阻断:本地证据充分时进入临时 content_unit/待识别池,而非直接失败(REQ §14.1.3)

2.4 目标路径与重命名模板

项 内容
上游 transhandler.py plan_transfer(:263)、get_rename_path(:1961)、get_naming_dict(:1796)、get_dest_dir(:1757)、get_dest_path(:1741)、__get_tv_bluray_dir_path(:72)、__rename_subtitles(:1614);app/application/directory.py get_media_root_path(:194);命名上下文 app/application/messaging/message.py TemplateContextBuilder.build(:111,字段见 :250-415);默认模板 app/runtime/config.py:522-533
输入 目标根目录、Jinja2 模板、meta、mediainfo、episodes_info、文件扩展名
输出 final_target_path(文件)或目录目标;冻结在 TransferPlanCheckpoint
目录结构 library_path [/ 类型(自动=mediainfo.type 或手动 media_type)] [/ 类别(自动=mediainfo.category 或手动)] / 模板渲染结果(get_dest_dir :1757)
默认模板 电影:{{title}} ({{year}})/{{title}} ({{year}})[-{{part}}][ - {{videoFormat}}]{{fileExt}};剧集:{{title}} ({{year}})/Season {{season}}/{{title}} - {{season_episode}}[-{{part}}][ - 第 {{episode}} 集]{{fileExt}}
模板变量 title en_title original_title year title_year season season_fmt season_year episode season_episode total_episodes episode_title episode_date part customization fps resourceType effect edition videoFormat resource_term releaseGroup videoCodec videoBit audioCodec webSource tmdbid imdbid doubanid category type fileExt 等;media_source/media_id 在命名场景被主动剔除(:1814)
关键分支 ① renaming=False → 目标 = 根/原文件名(不改名);② 手动指定 target_path 时强制 need_rename=True、overwrite_mode="never"、notify=False(module.py:487-492);③ 电视剧单文件:begin_episode is None → 若文件名命中特典正则(NCOP/NCED/OP/ED/MENU/PV/CM/TRAILER/SP/OVA/OAD/…,__is_special_extra_file :220)则成功跳过,否则抛"未识别到文件集数";④ 电视剧多集文件:total_episode > 2 时被强制截为单集(end_episode=None),即 E01-E03 只按 E01 命名;E01-E02 保留区间(:387-389);⑤ 电视剧原盘目录:渲染路径的父目录 + Disc N(从 part / 目录名 `Disc
对应 TS packages/domain/src/organize/naming.ts
export function renderFileName(tpl: CompiledTemplate, ctx: NamingContext): string
export function subtitleLanguageSuffix(fileName: string, defaultLang: SubLang): string
export function sanitizePathSegment(s: string): string
保留行为 默认模板结构(作品/Season N/文件名)、字幕语言后缀与 .default 规则、全角替换、原盘 Disc 目录、特典文件无集号跳过
调整项 ① 模板只在"首次建立绑定"时用于生成作品/季目录名,已有 path_binding 时目录部分不渲染(REQ §7.1);② 文件名仍可按模板生成,但模板版本号写入 path_binding.template_version,模板变更不回溯改名;③ 不使用 Jinja2 任意表达式,改为受限模板(白名单变量 + if 片段),避免模板注入与不可预测输出;④ 多集文件不静默截断:E01-E03 保留区间命名并在 content_unit 覆盖关系中登记 3 集(REQ §5 集账本),MP 截断属于必须偏离项;⑤ 无集号且非特典的剧集文件进入待识别,不报错丢弃

2.5 覆盖/冲突策略 overwrite_mode

项 内容
上游 transhandler.py __resolve_overwrite(:494)、__resolve_overwrite_with_step(:989)、__find_version_files(:1821)、__delete_version_files_with_steps(:1847);取值定义 app/schemas/system.py:263(always/size/never/latest)、app/schemas/event.py:825
输入 源文件、目标路径、overwrite_mode、插件 TransferOverwriteCheck 事件结果
输出 (over_flag, delete_versions, failure),并作为 decision/resolve_overwrite 步骤结果冻结

判定表(单文件;目录模式不走此判定,见下方"注意"):

条件 结果
源是字幕/音轨(附属文件) 无条件覆盖(over_flag=True)
严格查询目标失败(StorageQueryError) 失败"无法确认目标文件状态,已跳过整理以避免误覆盖"
目标不存在 不覆盖;delete_versions = (mode == "latest")
本地目标是断链软链接 覆盖
插件返回 overwrite=True / False 覆盖 / 失败并 overwrite_skipped=True
always 或 latest(目标存在) 覆盖(latest 此时不删其他版本)
never 失败 "媒体库存在同名文件,当前覆盖模式为不覆盖",overwrite_skipped=True
size 目标 size < 源 size → 覆盖;否则失败 "媒体库存在同名文件,且质量更好"(注意此分支未设 overwrite_skipped)
其他/未设置(None) 返回 (False, False, None):既不覆盖也不失败 → 进入执行(见下"推断风险")

latest 的"其他版本"定义:同目录内、扩展名属 RMT_MEDIAEXT、按 MetaInfoPath 解析出相同季、相同集、且 part 不冲突的其他视频文件(:1821-1845)。先冻结候选清单(discover_version_targets),再逐个作为独立步骤删除。

项 内容
覆盖拒绝后的历史 filter.py _is_overwrite_declined(:410):仅当 overwrite_skipped 且该源路径已有成功历史时,保留原成功记录、不写失败、不通知、不重试;否则照常写失败记录
推断风险(须复现) 持久路径(step_runner 非空)下,overwrite_mode=None 且目标已存在时:不删除目标、直接 materialize_target → 本地 copy 走 _write_atomically 最后 os.replace(partial, dest) 会静默覆盖;move 同盘 os.replace(src, dest) 同样覆盖;link 的 shutil.move(tmp, dest) 也覆盖;softlink symlink_to 会失败。旧同步路径 __transfer_file(:1672)有"已存在即失败"保护,持久路径没有。
对应 TS packages/domain/src/organize/conflict.ts
`export type ConflictPolicy = "skip"
保留行为 目标状态查询失败 → 不写;覆盖判定结果在执行前冻结;附属文件可随主视频覆盖;覆盖拒绝不毁掉原成功记录;"latest 删其他版本"只按同季同集同 part 限定
调整项 ① 默认策略 = skip + 进入待处理,任何覆盖都需要 automation_grant 或用户显式授权(REQ §14.1.1"不能因 MP 某个默认允许覆盖,就视为用户已授权");② 目标存在但内容 hash 相同 → 视为"已在库",登记关联而非失败;③ 未设置策略不得落入"不判定直接写",执行器写入必须使用 no-clobber 语义(link()+unlink 或 copyFile(COPYFILE_EXCL)/rename 前 lstat 校验),把 MP 的推断风险消除;④ latest 的"删除其他版本"与本项目多 Slot 冲突:1080p 与 4K 属于不同 Slot 的资产,不得因为同季同集就删除(REQ §6.1 双版本互不淘汰);升级只替换同一 Slot 的旧资产,旧文件先移入隔离区而非直接删除;⑤ size 以体积判断质量不可靠,本项目改为由 Slot 满足度/升级阈值给出决策,conflict 模块只负责文件层
项 内容
上游 transhandler.py __transfer_command(:1404)、__execute_transfer_with_steps(:786);storages/local.py copy(:405)、move(:416)、link(:448)、softlink(:459)、_write_atomically(:278)、_partial_path(:244)、_cleanup_stale_partials(:257);app/adapters/system/host.py SystemUtils.link(:500)、softlink(:518);app/adapters/system/fsproxy.py _direct_copy(:198)
输入 源 FileItem、目标路径、transfer_type ∈ {copy, move, link, softlink}(schemas/system.py:261)、源/目标存储
输出 目标 FileItem(本地:重新 stat 得到 size/mtime)

行为矩阵(本地 → 本地):

模式 实现 临时文件 跨文件系统 失败时
copy _write_atomically:同目录写 .{name}.{pid}.mp-partial,分块复制(可取消),utime 复制时间戳,os.replace 到最终名 有,同目录隐藏 + 专用后缀;每次写入前清理该目录下超过 24h 的残留 partial 无差别(总是复制) 删除 partial,返回 False
move 先 os.replace(src, dest);任何 OSError(含 EXDEV)→ 降级为 _write_atomically 复制,成功后 src.unlink();unlink 失败只 warn,仍返回成功 仅跨盘降级时有 自动降级为"复制 → 删源" 复制失败返回 False,源保留
link SystemUtils.link:dest.mp 临时名 hardlink_to(src),再 shutil.move(tmp, dest) .mp 后缀(非隐藏) EXDEV → 返回 -1,整理失败,不降级 失败
softlink dest.symlink_to(src) 无 可跨盘 目标已存在即失败

其他存储组合(:1465-1612):本地→网盘 copy=上传、move=上传后删源;网盘→本地=下载到目标目录再 SystemUtils.move;同一网盘用存储自身 copy/move/link;两个不同网盘之间不支持。

跨存储 move 在持久路径下拆成两步:materialize_target(按 copy 执行)→ delete_move_source(:797-866),中间可被中断并按观察结果恢复。

项 内容
校验 没有内容校验:copy 完成的判断只有"分块读写无异常"(fsproxy._direct_copy 无 size 比对、无 fsync、无 hash);materialize_target 的恢复观察对本地 copy 用 filecmp.cmp(shallow=False) 逐字节比较(__observe_transfer_operation :701),只在重启恢复时发生
对应 TS packages/adapters/src/fs/local-transfer.ts
export async function copyToTarget(src: string, dst: string, opts: { signal: AbortSignal; onProgress?: (b: number) => void }): Promise<CopyReceipt> — CopyReceipt = { bytes: number; sha256?: string; partialPath: string }
`export async function renameNoClobber(src: string, dst: string): Promise<"renamed"
保留行为 同目录临时文件 + 原子 rename;partial 命名隐藏 + 专用后缀(本项目用 .uhd-partial);定期清理 24h 以上残留;复制保留 mtime、不覆盖目标目录继承的权限/ACL(_copy_with_target_permissions :324);跨存储 move 拆两步且先落目标后删源;同盘 move 优先 rename
调整项 ① copy 完成后必须 fsync(file) + fsync(dir) 并做 size 校验,可选 sha256(REQ §8.3"校验并提交后登记");② move 跨盘:MP 由 EXDEV 隐式降级为复制;本项目要求 EXDEV 显式返回,由 verifyAndImport 记录"跨卷搬运"并走可恢复的三步(copy → verify → delete source),日志和 asset_operation 可见(REQ §8.3 "不能假定 rename 原子成立"、"EXDEV 不得静默改变策略");③ 删源失败不能报成功:进入 attention 并保留重试步骤;④ link EXDEV 不降级(与 MP 一致),但作为"兼容登记",首期主工作流不使用 link/softlink(REQ §8.3);⑤ 硬链接产物登记 inode/dev,共享 inode 时禁止原地修改(REQ §8.3 [S12]);⑥ 所有写入 no-clobber,见 2.5

2.7 步骤账本与中断恢复

项 内容
上游 app/application/transfer/execution.py:TransferExecutionState(:18)、TransferStepState(:29)、TransferOperationObservationState(:54)、TransferManualReviewDecision(:63)、build_transfer_operation_id(:94)、build_transfer_checkpoint_fingerprint(:128)、TransferExecutionCommand(:646);app/chain/transfer/execution.py _DurableTransferStepRunner.run(:175)、_execute_attempt(:100)、checkpoint(:256);transhandler.py 各 observe 函数(:647-785);app/chain/transfer/queue.py __replay_pending(:751)、__queue_planned_replay(:1061)、__queue_accepted_replay(:1004);app/chain/transfer/plan.py __restore_settling_transfer_result(:376)
输入 冻结计划 TransferPlanCheckpoint(指纹)、任务 ID、租约 token
输出 每一步持久记录(prepared → started → succeeded/failed/manual_review);最终 TransferExecutionCheckpoint(outcome ∈ succeeded/failed/overwrite_skipped + TransferInfo)

状态与判定:

概念 取值 / 规则
任务执行态 not_started running retry_wait settling failed manual_review
步骤态 prepared started succeeded failed manual_review
操作 ID sha256(canonical_json{schema_version, task_id, checkpoint_fingerprint, ordinal, phase, kind, intent_payload})——同一计划重放得到相同 ID
步骤种类 decision:resolve_overwrite、plugin_transfer_intercept;prepare:cleanup_previous_destination、ensure_target_directory、discover_version_targets、delete_version_target、delete_overwrite_target;transfer:materialize_target、delete_move_source
已 succeeded 的步骤 直接返回持久结果,不再访问外部存储(__restore_step_item :641)
遗留 started(崩溃) 先 observe():APPLIED → 以观察证据补记成功;NOT_APPLIED → 重新执行;UNKNOWN/CONFLICT → manual_review,禁止自动重放
执行异常 先 observe;APPLIED → 记成功;NOT_APPLIED 且重试次数 < TRANSFER_MAX_FAILED_RETRIES(默认 3,config.py:518)→ retry_wait,固定 30s 后重试;达到上限 → exhaust(失败终态);其余 → manual_review
move 观察 源无 + 目标有 = APPLIED;源有 + 目标无 = NOT_APPLIED;其他 = CONFLICT
copy/link/softlink 观察 目标无 = NOT_APPLIED;非本地存储 = UNKNOWN;本地 copy 逐字节比较、link samefile、softlink resolve() 相等 → APPLIED,否则 CONFLICT
插件拦截观察 恒为 UNKNOWN(插件调用无回执)→ 崩溃后转人工
人工复核 人工给出 not_applied / applied / failed + 理由(schemas/transfer.py:165-170)
启动恢复 claim_recoverable 取租约 → 已有计划检查点:只执行冻结路径,不再识别/重命名;只有 accepted 输入:按原始请求重新规划;源已消失且未开始执行 → abandon_unstarted 注销;settling 状态 → 用 execution checkpoint 直接结算,不再执行外部步骤
结算原子性 历史写入 + outbox 事件 + 任务终态由 durable_event_writer.transfer_result 一次提交(settlement.py :321-420);终态结算失去租约 → 任务不算完成,留待恢复
项 内容
对应 TS packages/tasks/src/import/step-ledger.ts
export async function runStep<T>(ctx: StepContext, spec: { phase: StepPhase; kind: StepKind; intent: JsonObject; execute: () => Promise<T>; observe: () => Promise<Observation<T>> }): Promise<T>
export function operationId(taskId: string, planFingerprint: string, ordinal: number, phase: string, kind: string, intent: JsonObject): string
packages/tasks/src/import/verify-and-import.ts
export async function verifyAndImport(ctx: TaskContext, input: VerifyAndImportInput): Promise<ImportOutcome>
表:asset_operation(步骤账本,唯一键 operation_id)、organization_record / organization_file_result
保留行为 整体保留:先冻结计划并持久化指纹、再执行;步骤 ID 由计划确定性派生;崩溃后先观察再决定,UNKNOWN/CONFLICT 转人工;已成功步骤不重做;失败有界重试;终态 + 历史 + 事件同事务提交;租约 fencing
调整项 ① 若 OpenWorkflow spike 通过,step 语义映射到其 durable step,但 asset_operation 业务账本仍独立(REQ §11.2);② 观察函数增加 size + sha256(copy 场景)证据,远端存储(首期不做)默认 UNKNOWN;③ 新增步骤 verify_target(size/hash)与 register_asset(写 media_asset/asset_location/path_binding),文件搬完但登记失败时,重启观察到目标已存在且校验一致 → 只补登记,不重搬(REQ §14.1.1"搬运完成但登记失败");④ 重试退避改为指数退避 + 上限,重试次数按 automation_grant 配置;⑤ manual_review 在控制台"需要我处理"与 Agent task.inspect 中展示证据

2.8 历史记录 TransferHistory 与查重闸

项 内容
上游 app/db/models/transferhistory.py TransferHistory(:22);app/application/history.py add_transfer_success(:1366)、add_transfer_fail(:1421)、HistoryGateAction(:929)、resolve_history(:1221)、evaluate_history_gate(:1244);调用处 workflow.py _build_transfer_tasks(:651 起)

字段(TransferHistory):

字段 说明 本项目去向
id 主键 organization_file_result.id
transfer_task_id / transfer_settlement_revision 失败 pending 映射的任务 ID 与结算版本(唯一索引 task_id) asset_operation.task_id
src / src_storage / src_fileitem 源路径、存储、源文件快照 JSON;(src, src_storage) 唯一 organization_file_result.source_*
dest / dest_storage / dest_fileitem 目标路径、存储、目标文件快照 asset_location(library_root_id + relative_path)
mode move/copy/link/softlink organization_file_result.mode
type / category / title / year 媒体类型、二级分类、标题、年份 media_entity(按 UUID 引用,不冗余标题)
media_source / media_id 识别源与原生 ID external_identity
music_type / total_tracks / audio_* / bit_depth / sample_rate / bitrate 音乐字段 首期不迁
seasons / episodes Sxx / Exx 字符串 content_unit 覆盖关系(结构化)
image 海报 URL 不迁(元数据侧)
downloader / download_hash 下载器与 hash(索引) download_task(实例 ID + torrent 标识 + v1/v2 hash)
status / errmsg 成功布尔、失败信息 organization_file_result.state + error
date 字符串时间 timestamptz
files 本次涉及的源文件清单 JSON organization_file_result 多行
episode_group TMDB 剧集组 external_identity 附属字段

查重闸(evaluate_history_gate :1244):

历史状态 源文件指纹(size/mtime/fileid)变化 动作
无记录 — pass_no_record
失败 变了 pass_failed_version_changed(重试预算重置)
失败 未变,失败次数 < 上限(默认 3) pass_failed
失败 未变,达到上限 skip_retry_exhausted(仍给种子打"已整理"标签,停止轮询)
成功 变了 pass_size_changed(交给 overwrite_mode 裁决)
成功 未变或无法比对 skip

手动整理:reorganize=True 或历史为失败时,先请求 durable 重试或删除旧历史再整理;cleanup_dest_fileitem 用于"重新整理时先删旧目标",只绑定到本批第一个成功入队的任务(workflow.py cleanup_intent_assigned)。

项 内容
对应 TS packages/domain/src/organize/import-gate.ts
`export function evaluateImportGate(prev: FileResultSnapshot
保留行为 指纹变化才放行;失败有界重试;重试耗尽后停止对该种子空转;成功记录不被覆盖拒绝改写
调整项 ① 唯一键从"源路径"改为 (downloader_instance_id, torrent_id, file_index) + 源路径,避免同路径被新种子复用时混淆(REQ §8.4);② 历史不承载作品身份与标题快照,只引用 UUID;③ 整理记录对外经公开 API 增量读取(游标),MP 无此合同(REQ §12.4)

2.9 下载器收尾、源文件清理与删种条件

项 内容
上游 settlement.py __default_callback(:255,move 分支 :470-513)、__mark_torrent_completed_if_done(:620)、__is_torrent_download_completed(:650);history.py _can_delete_torrent(:305);filter.py _should_delete_empty_source_directories(:380);app/chain/storage.py delete_media_file(:161);app/modules/qbittorrent/__init__.py transfer_completed(:369)、remove_torrents(:381);app/application/transfer/workflow.py JobManager.is_success(:1773)、is_torrent_done(:1810)、is_torrent_success(:1824)
动作 MP 条件
打"已整理"标签 该 hash 在内存作业视图中所有任务都 completed/failed,且下载器查询进度 = 100%(查询失败视为未完成);补丁 #6009 防止多集种子部分完成时过早打标签
Move 模式删种(delete_file=True) 当前任务 transfer_type = move 且 同作品同季作业全部成功 且 该 hash 的所有任务成功 且 _can_delete_torrent:种子存在、进度 100%、文件清单可取得、save_path/文件名 中没有任何"未被屏蔽且仍存在的媒体后缀文件"
Move 模式删源空目录 任务无 download_hash(目录监控来源)时,delete_media_file(delete_self=False):向上删除不含媒体文件的空目录,止于配置的下载/媒体库根;根目录与一级目录禁止删;挂载盘(网络/FUSE)可配置不删
Copy / link / softlink 不删种、不删源
项 内容
关键观察 ① 删种判断不看 H&R / 分享率 / 做种时长,也不看是否有其他种子共享同一文件;② 删种使用 delete_file=True,依赖"媒体文件已被 move 走"这一事实,剩下的 nfo/样本/图片随种子一起删除;③ 条件依赖内存 JobManager,进程重启后作业视图丢失,删种只会在下一次完整成功的结算中发生
对应 TS packages/domain/src/organize/source-cleanup.ts
export function evaluateSourceRelease(input: { task: DownloadTaskFacts; fileResults: FileResultFacts[]; retention: RetentionFacts; references: PathReferenceFacts; grants: GrantFacts }): SourceReleaseDecision
packages/tasks/src/cleanup/cleanup-download-cache.ts
export async function cleanupDownloadCache(ctx: TaskContext, input: { instanceId: string; lowWater: number; highWater: number }): Promise<CleanupReport>
保留行为 打"已整理"类标签前确认种子整体下载完成、所有文件任务已终结;根目录/一级目录不可删;Move 后才考虑移除下载器任务
调整项 见第 3 节"必须偏离点" 第 4-8 条

2.10 刮削触发(与转移的边界)

项 内容
上游 app/chain/transfer/scrape.py _record_scrape_target(:112)、_send_metadata_scrape_event(:22)、_flush_scrape_batch_if_ready(:178)
行为 转移成功且 need_scrape → 登记批次刮削目标;批次全部结束后发 EventType.MetadataScrape,由刮削链异步写 NFO/图片。刮削失败不影响转移结果
对应 TS packages/tasks/src/metadata/refresh-metadata.ts refreshMetadata(outbox 事件触发)
保留 / 调整 保留"转移与刮削解耦、刮削失败独立重试、不重做搬运"(REQ §14.1.3);NFO/图片写入受目录策略控制,且刮削不得改动路径绑定

3. 对照 REQ §7.1 / §8.3:本项目必须偏离 MP 的行为

# MP 行为(源码位置) 本项目要求(REQ) 偏离做法
1 目标目录每次按模板 + 当前 mediainfo 渲染;仅在 SCRAPE_FOLLOW_TMDB=False 时用首条历史 title 回填(chain/transfer/execution.py:408) §7.1:已绑定作品/季目录固定,译名变化不改路径;两个历史目录并存建冲突任务 目录来自 path_binding(library_root_id + relative_path),模板只用于首次绑定;存在多个候选绑定 → decision 待处理
2 手动整理/重新整理可以改名、删旧目标(cleanup_dest_fileitem,module.py:578) §7.1.1:主动整理是显式操作,可变更绑定;自动元数据刷新不得改名 自动链路禁用"删旧目标";organize.plan/execute 能力才允许改绑定,须预览 + 授权 + 发布整理记录
3 move 跨盘在 LocalStorage.move 内部由 OSError 隐式降级为复制 + 删源,删源失败仍报成功(storages/local.py:430-446) §8.3:跨盘需"复制、验证、再删源"的可恢复步骤;EXDEV 不得静默改变策略 renameNoClobber 返回 exdev → 任务显式切换为 copy → verify → delete_source 三个账本步骤;删源失败 = attention,不算完成
4 Move 模式全部成功后 remove_torrents(delete_file=True),不检查 H&R、做种时长、分享率(settlement.py:470-507、history.py:305) §8.3:有 H&R 义务不得直接搬走做种源;删数据须基于路径引用检查 Move 前置条件包含 retention 判断:H&R 未满足 → 等待或(预授权时)改 Copy;移除 qB 任务一律 deleteFiles=false,残留文件由路径引用检查后单独删除
5 _can_delete_torrent 只看"剩余未屏蔽媒体文件是否存在",不看共享保存根/其他种子引用/未接管文件 §8.3:共享保存根、未接管文件、媒体库直接做种时不能递归误删 PathReferenceFacts:同一实际路径被其他 torrent、未纳管文件、asset_location 引用 → 禁止删除;只删除本任务 torrent 文件清单内且无其他引用的文件,不递归删目录
6 删种依赖进程内 JobManager(:1773/:1824),重启后状态丢失 §11:持久任务管过程 清理决策只读 PG 中的 organization_file_result 与 download_task,可在任何时刻重算
7 部分文件入库、其余失败时,种子仍可能因"剩余媒体文件已不存在"而被判定可删(推断) §8.3:部分文件入库不能整种按"已完成"清掉 必须 torrent 文件清单中每个被选择的媒体文件都有成功结果或显式 skipped_by_rule,才进入可清理集合
8 Copy 模式无缓存清理机制(源永远留给下载器) §8.3:Copy 后按保留天数/H&R/水位清理下载缓存,清理到恢复阈值停止;下载器失联/条件未知不得清理 新增 cleanupDownloadCache 任务:低水位触发,候选须"已验证入库 + 满足保留 + H&R 满足",到高水位停止;任一事实未知 → 不清理
9 latest 删除同目录同季同集的其他版本(transhandler.py:1821) §6.1:1080p/4K 独立 Slot 互不淘汰 版本删除改为"同 Slot 升级替换",旧资产先隔离;跨 Slot 永不删除
10 未设置 overwrite_mode 时持久路径可能静默覆盖(推断,见 2.5) §8.3:不能覆盖无关资产 执行器一律 no-clobber;覆盖需授权决策冻结在计划中
11 copy 完成不校验内容、不 fsync(fsproxy.py:198) §8.3:写目标侧临时文件,校验并提交后登记 copy 后 fsync + size(必做)+ sha256(可配置,首期对 < N GB 或抽样)校验,校验通过才 rename 与登记
12 硬链接/软链接是一等转移模式,EXDEV 直接失败 §8.3:兼容登记既有硬链接,不设为主工作流 首期执行器只实现 copy/move;link 仅用于识别/登记既有硬链接(inode/dev),不作为新入库模式
13 剧集多集文件 total_episode > 2 截为单集命名(transhandler.py:387) §5.4 集账本:覆盖关系要准确 文件名保留区间,content_unit 覆盖全部集
14 下载完成即按标签"已整理"退出轮询;标签是 MP 私有中文字面值 §8.4:管理标签名可配置,标签不承载身份 使用配置的管理标签 + 独立状态表;不依赖标签判断是否已整理
15 历史唯一键 (src, src_storage) §8.4:业务关联用下载器实例 ID + torrent 标识 结果按 (instance, torrent, file_index) 关联,源路径仅作辅助

4. TS 模块总览(按任务书 §2 包划分)

包 / 文件 导出 说明
packages/domain/src/organize/candidates.ts collectImportCandidates(tree, opts): ImportCandidate[];isBlurayRoot(entries): boolean 2.1
packages/domain/src/organize/sidecars.ts pairSidecars(files): SidecarPairing;subtitleMediaStem(name): string 2.2
packages/domain/src/organize/target-binding.ts resolveTargetBinding(input): TargetBindingDecision 2.3,稳定路径核心
packages/domain/src/organize/naming.ts renderFileName(tpl, ctx): string;subtitleLanguageSuffix(name, defaultLang): string;sanitizePathSegment(s): string 2.4
packages/domain/src/organize/conflict.ts decideConflict(src, dst, ctx): ConflictDecision 2.5
packages/domain/src/organize/plan.ts buildOrganizePlan(input: OrganizePlanInput): OrganizePlan;planFingerprint(plan): string 汇总 2.1-2.5,输出冻结计划(每文件:source、target、mode、conflict decision、sidecars、required grants、preconditions);纯函数
packages/domain/src/organize/import-gate.ts evaluateImportGate(prev, current, retry): ImportGateAction 2.8
packages/domain/src/organize/source-cleanup.ts evaluateSourceRelease(input): SourceReleaseDecision 2.9,含 H&R/保留/引用检查
packages/adapters/src/fs/local-transfer.ts copyToTarget、renameNoClobber、hardlinkNoClobber、verifyFile、sweepStalePartials、`statStrict(path): Promise<FileFacts null>`
packages/adapters/src/fs/observe.ts observeMaterialize(step): Promise<Observation>;observePresence(path, appliedWhenPresent) 2.7 观察函数
packages/tasks/src/import/step-ledger.ts runStep(ctx, spec);operationId(...) 2.7
packages/tasks/src/import/verify-and-import.ts verifyAndImport(ctx, input): Promise<ImportOutcome> 编排:加载冻结计划 → 逐文件 runStep → 结算(organization_record + outbox)同事务
packages/tasks/src/cleanup/cleanup-download-cache.ts cleanupDownloadCache(ctx, input) 2.9
packages/contracts/src/organize.ts zod:OrganizePlan、OrganizeFileResult、ImportOutcome、ConflictDecision 公开整理记录 API 与能力 organize.plan/execute 共用

OrganizePlan 建议字段(对齐 MP TransferPlanCheckpoint 的冻结思想):

interface OrganizePlan {
  planVersion: 1;
  requestFingerprint: string;          // 对应 TransferPlanningInput.fingerprint
  downloadTaskId: string | null;
  mode: "copy" | "move";
  items: Array<{
    sequence: number;
    source: { instanceId?: string; torrentFileIndex?: number; absPath: string; size: number; mtimeMs: number; dev: number; ino: number };
    target: { libraryRootId: string; relativePath: string; bindingId: string; bindingIsNew: boolean };
    role: "main" | "subtitle" | "audio" | "bluray_dir" | "extra";
    mainRef?: number;                   // 附属文件指向主文件 sequence
    contentUnits: string[];             // 覆盖的 content_unit UUID
    slotId: string | null;
    conflict: ConflictDecision;
    skipReason?: string;
  }>;
  requiredGrants: GrantKind[];
  preconditions: Array<{ kind: "free_space" | "hnr_satisfied" | "no_external_reference"; detail: JsonObject }>;
}

5. 验证样本

样本均为自造、脱敏,放入 packages/testkit/fixtures/organize/。"MP 预期"列为按源码推断的上游行为,"本项目预期"为验收口径。

5.1 单文件电影

# 输入 条件 MP 预期 本项目预期
M1 /downloads/movie/Dune.Part.Two.2024.2160p.UHD.BluRay.REMUX.DV.HDR.HEVC.TrueHD.7.1.Atmos-FraMeSToR.mkv copy,库根 /media/movies,无旧目录 /media/movies/沙丘2 (2024)/沙丘2 (2024) - 2160p.mkv 新建绑定 沙丘2 (2024)/,文件同名;partial → fsync → size 校验 → rename;organization_file_result=succeeded
M2 同 M1,TMDB 中文名改为「沙丘:第二部」 已有绑定 沙丘2 (2024)/ SCRAPE_FOLLOW_TMDB=True 时建新目录「沙丘:第二部 (2024)」(: 转全角) 仍写入 沙丘2 (2024)/;NFO 可更新标题
M3 /downloads/movie/Oppenheimer.2023.1080p.BluRay.x264-SPARKS/ 内含 …mkv + …chs.srt + …eng.srt + sample.mkv(40MB) min_filesize=100MB 主视频入库;字幕按 stem 剥离 .chs/.eng 后匹配 → ….chi.zh-cn.default.srt、….eng.srt;sample 被体积过滤 同左;sample 记 skipped_by_rule(min_size)
M4 /downloads/collection/The.Matrix.Trilogy.1080p/ 内 The.Matrix.1999…mkv、The.Matrix.Reloaded.2003…mkv 下载历史记录 TMDB=603(1999) 1999 文件用历史 ID;2003 文件年份冲突 → 逐文件重新识别 同左;每文件独立结果
M5 /downloads/movie/Some.Movie.2021.1080p.WEB-DL.mkv 目标同名已存在,大小更小,无授权 size 模式:覆盖;never:失败 + overwrite_skipped skip + 待处理决策"发现同名已有文件";hash 相同则直接关联为已在库
M6 同 M5 目标已存在,overwrite_mode 未设置 持久路径推断静默覆盖 执行器 no-clobber 拒绝,结果 attention

5.2 剧集 / 整季包

# 输入 条件 MP 预期 本项目预期
T1 /downloads/tv/The.Last.of.Us.S01.2160p.MAX.WEB-DL.DDP5.1.Atmos.DV.HDR.H.265-FLUX/ 内 E01-E09 共 9 个 mkv copy,库内无该剧 9 个独立任务,/media/tv/最后生还者 (2023)/Season 1/最后生还者 - S01E01 - 第 1 集.mkv … 1 个 organization_record + 9 条文件结果;新建作品与季两级绑定
T2 同 T1,但库中已有 最后生还者 (2023)/Season 1/…S01E01…、…S01E02… 同 Slot、同质量 历史/目标存在 → E01/E02 按 overwrite_mode 处理 E01/E02 hash 不同 → skip + 决策(升级需授权);E03-E09 正常入库;批次 partial
T3 /downloads/tv/Show.S02E01-E03.1080p.WEB-DL.mkv 多集单文件 total_episode=3>2 → 截成 S02E01 命名 文件名 … - S02E01-E03.mkv,覆盖 3 个 content_unit
T4 /downloads/tv/Show.S02E05-E06.1080p.mkv 两集单文件 保留 S02E05-E06 同左
T5 /downloads/tv/Bocchi.the.Rock.S01.1080p.BluRay/ 内 NCOP01.mkv、NCED.mkv、Menu.mkv 无集号 — 命中特典正则 → 成功跳过、不通知 记 skipped_extra,可在 UI 查看,后续附加内容建模(M2)
T6 /downloads/tv/Show.S01.1080p/Show.S01E07.mkv 损坏,中途 IO 错误 copy 步骤失败 → observe NOT_APPLIED → 30s 后重试,3 次后失败 指数退避重试;耗尽后该文件 failed,其他集不受影响
T7 /downloads/tv/Show.S01.BluRay.1080p/Show.S01D02/BDMV/STREAM/00001.m2ts 原盘目录 回溯到 Show.S01D02/ 整体搬到 …/Season 1/Disc 2/ 同左;首期可先 attention(原盘策略待确认)
T8 字幕 Show.S01E03.繁體中文.ass 与 Show.S01E03.简体.ass 同目录有 Show.S01E03.mkv 主干名剥离后匹配;繁体优先判定 → .zh-tw.ass;简体 → .chi.zh-cn.default.ass 同左
T9 字幕 Show.S01E03.mkv.srt 与两个主视频 Show.S01E03.mkv、Show.S01E03.v2.mkv 主干名多义 无唯一匹配 → 独立任务,按自身文件名识别 orphan,不搬不删,进入待处理

5.3 Move / 跨卷 / 清理

# 输入 条件 MP 预期 本项目预期
X1 /downloads(同 FS)→ /downloads/../media(同 FS) move os.replace 原子完成 renameNoClobber → renamed;登记后按保留规则决定是否移除 qB 任务
X2 /mnt/cache/downloads/…mkv → /mnt/array/media/…(不同 FS) move os.replace 抛 EXDEV → 复制 → 删源;删源失败仍报成功 返回 exdev → 显式三步 copy/verify/delete_source,事件"跨卷搬运";删源失败 → attention
X3 X2 中 copy 完成、verify 前进程被 kill 重启 materialize 步骤 STARTED;observe:源在 + 目标在 → CONFLICT(move 语义)→ 人工复核 copy 语义观察:目标 size/hash = 源 → APPLIED,继续 verify/delete_source;只有 partial 残留 → NOT_APPLIED,清理 partial 后重做
X4 文件已搬运 + 登记事务失败 重启 settling 检查点存在 → 直接结算,不重搬 register_asset 步骤重试,目标校验一致即补登记
X5 M-Team 种子,H&R 要求做种 72h,已做 10h move 全部成功即 remove_torrents(delete_file=True) 不执行 Move 的删源;等待或(已授权)改 Copy;不移除 qB 任务
X6 保存根 /downloads/tv/,种子 A = Show.S01E01.mkv,种子 B 为其他剧同目录 move 完 A _can_delete_torrent 仅看 A 的文件清单 只删除 A 清单内、无其他引用的文件;不递归删 /downloads/tv/
X7 整季包 10 集,8 集成功、2 集失败 move 该 hash 有失败任务 → is_torrent_success=False → 不删种 同左 + 批次 partial;qB 任务保留,失败集可重试
X8 Copy 入库 7 天前的种子,缓存盘可用 < 低水位 cleanupDownloadCache MP 无此功能 候选:已验证入库 + 保留期满 + H&R 满足;清至高水位停止;qB 失联 → 不清理

6. 未核验 / 待补

项 说明
与 33da9ce 的差异 本地仅有 e6820a83,REQ 附录快照未比对
推断条目 2.5"未设置 overwrite_mode 静默覆盖"、3 表第 7 条"部分入库可能被判可删"——需在参考环境或用 MP 自带测试(tests/test_transfer_overwrite_guard.py、tests/test_filemanager_planning.py)复现后更新
可对照的上游测试 tests/test_local_storage.py(跨设备 move、同设备 rename)、test_transfer_execution_runner.py、test_transfer_settling_recovery.py、test_transfer_pending_replay.py、test_transfer_history_gate.py、test_transfer_overwrite_declined.py、test_transfer_sync_extra_files.py、test_transfer_movie_collection.py、test_transfer_mark_torrent_completed.py、test_filemanager_season_zero.py——仅作行为参考,不复制测试代码
远端存储 网盘/SMB/rclone/alist 分支(storages/*.py)首期不迁
插件事件 TransferIntercept、TransferOverwriteCheck、TransferRenameBuild、TransferRename 在本项目由 Capability/策略替代,不迁移插件机制