52 KiB
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.tsexport 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.tsexport 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.tsexport 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.tsexport function renderFileName(tpl: CompiledTemplate, ctx: NamingContext): stringexport function subtitleLanguageSuffix(fileName: string, defaultLang: SubLang): stringexport 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 模块只负责文件层 |
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.tsexport 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.tsexport 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): stringpackages/tasks/src/import/verify-and-import.tsexport 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.tsexport function evaluateSourceRelease(input: { task: DownloadTaskFacts; fileResults: FileResultFacts[]; retention: RetentionFacts; references: PathReferenceFacts; grants: GrantFacts }): SourceReleaseDecisionpackages/tasks/src/cleanup/cleanup-download-cache.tsexport 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/策略替代,不迁移插件机制 |