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

422 lines
52 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 视频文件转移入库:迁移对照记录
> 格式: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`<br>`export function collectImportCandidates(tree: FileTreeSnapshot, opts: CandidateOptions): ImportCandidate[]`<br>`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`<br>`export function pairSidecars(files: ParsedFile[]): { mains: MainFile[]; sidecars: SidecarLink[]; orphans: ParsedFile[] }`<br>`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`<br>`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`<br>`export function renderFileName(tpl: CompiledTemplate, ctx: NamingContext): string`<br>`export function subtitleLanguageSuffix(fileName: string, defaultLang: SubLang): string`<br>`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`<br>`export type ConflictPolicy = "skip" | "replace_if_larger" | "replace" | "keep_both_as_slot";`<br>`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`<br>`export async function copyToTarget(src: string, dst: string, opts: { signal: AbortSignal; onProgress?: (b: number) => void }): Promise<CopyReceipt>` — `CopyReceipt = { bytes: number; sha256?: string; partialPath: string }`<br>`export async function renameNoClobber(src: string, dst: string): Promise<"renamed" | "exdev" | "exists">`<br>`export async function hardlinkNoClobber(src: string, dst: string): Promise<"linked" | "exdev" | "exists">`<br>`export async function verifyFile(dst: string, expect: { size: number; sha256?: string }): Promise<VerifyResult>`<br>`export async function sweepStalePartials(dir: string, olderThanMs: number): Promise<string[]>` |
| 保留行为 | 同目录临时文件 + 原子 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`<br>`export async function runStep<T>(ctx: StepContext, spec: { phase: StepPhase; kind: StepKind; intent: JsonObject; execute: () => Promise<T>; observe: () => Promise<Observation<T>> }): Promise<T>`<br>`export function operationId(taskId: string, planFingerprint: string, ordinal: number, phase: string, kind: string, intent: JsonObject): string`<br>`packages/tasks/src/import/verify-and-import.ts`<br>`export async function verifyAndImport(ctx: TaskContext, input: VerifyAndImportInput): Promise<ImportOutcome>`<br>表:`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`<br>`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`<br>`export function evaluateSourceRelease(input: { task: DownloadTaskFacts; fileResults: FileResultFacts[]; retention: RetentionFacts; references: PathReferenceFacts; grants: GrantFacts }): SourceReleaseDecision`<br>`packages/tasks/src/cleanup/cleanup-download-cache.ts`<br>`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>` | 2.6;查询异常必须抛出而不是返回 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` 的冻结思想):
```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/策略替代,不迁移插件机制 |