# 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(转移入库优先专项)。任务书:`docs/plan/00-first-version-plan.md` §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|Disk|DVD|CD n` / `S01D02` 提取,否则用原目录名);⑥ 电影原盘目录:`get_media_root_path` 按模板层数反推"标题所在层"作为目录目标;⑦ 字幕:按文件名识别语言 → 追加 `.zh-tw` / `.chi.zh-cn` / `.ja` / `.eng`,与 `DEFAULT_SUB` 相同时再加 `.default`,繁中优先于简中判断;⑧ 非法字符 `\/:*?"<>|` 转全角(`__convert_invalid_characters` message.py:520);⑨ 插件事件 `TransferRenameBuild`/`TransferRename` 可改写上下文或最终字符串 | | 对应 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" | "replace_if_larger" | "replace" | "keep_both_as_slot";`
`export function decideConflict(src: FileFacts, dst: FileFacts | null, ctx: ConflictContext): ConflictDecision` — `ConflictDecision = { kind: "proceed" } | { kind: "replace"; requiresGrant: GrantKind } | { kind: "skip"; reason } | { kind: "attention"; reason }` | | 保留行为 | 目标状态查询失败 → 不写;覆盖判定结果在执行前冻结;附属文件可随主视频覆盖;覆盖拒绝不毁掉原成功记录;"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 模块只负责文件层 | ### 2.6 转移模式与文件系统执行(copy / move / link / softlink、跨盘、临时文件) | 项 | 内容 | | --- | --- | | 上游 | `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 = { bytes: number; sha256?: string; partialPath: string }`
`export async function renameNoClobber(src: string, dst: string): Promise<"renamed" | "exdev" | "exists">`
`export async function hardlinkNoClobber(src: string, dst: string): Promise<"linked" | "exdev" | "exists">`
`export async function verifyFile(dst: string, expect: { size: number; sha256?: string }): Promise`
`export async function sweepStalePartials(dir: string, olderThanMs: number): Promise` | | 保留行为 | 同目录临时文件 + 原子 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(ctx: StepContext, spec: { phase: StepPhase; kind: StepKind; intent: JsonObject; execute: () => Promise; observe: () => Promise> }): Promise`
`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`
表:`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 | null, current: FileFingerprint, retry: { count: number; max: number }): ImportGateAction` | | 保留行为 | 指纹变化才放行;失败有界重试;重试耗尽后停止对该种子空转;成功记录不被覆盖拒绝改写 | | 调整项 | ① 唯一键从"源路径"改为 `(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` | | 保留行为 | 打"已整理"类标签前确认种子整体下载完成、所有文件任务已终结;根目录/一级目录不可删;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` | 2.6;查询异常必须抛出而不是返回 null | | `packages/adapters/src/fs/observe.ts` | `observeMaterialize(step): Promise`;`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` | 编排:加载冻结计划 → 逐文件 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` 的冻结思想): ```ts 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/策略替代,不迁移插件机制 |