mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-30 20:05:42 +08:00
fix(transfer): continue overwrite after target cleanup
## 问题 覆盖裁决确认旧媒体库目标存在后,洗版流程可能在实际备份前删除旧目标。恢复整理时备份步骤把旧目标和备份都不存在视为未知冲突,任务进入人工复核,暂存的新版本无法入库。 ## 修复 - 备份步骤在确认旧目标已被外部删除、且备份目标也不存在时记录为无副作用跳过。 - 继续提升已完整暂存的新版本;移动整理仍在提升成功后清理源文件。 - 查询失败、两端同时存在或存在备份但状态矛盾时继续保持人工复核保护。 - 增加持久整理回归测试,覆盖覆盖裁决后旧目标被删除的场景。 ## 验证 - `tests/test_transfer_safe_overwrite.py`:94 passed - 整理执行、持久化、人工复核、提交、worker、计划测试:202 passed - changed-file Pylint:10.00/10 Fixes #7120
This commit is contained in:
@@ -1211,11 +1211,13 @@ class TransHandler:
|
||||
target: Path,
|
||||
kind: str,
|
||||
expected_source: Optional[FileItem] = None,
|
||||
) -> FileItem:
|
||||
allow_missing_source: bool = False,
|
||||
) -> Optional[FileItem]:
|
||||
"""
|
||||
执行同目录无覆盖重命名,并用两端严格查询恢复中断操作。
|
||||
|
||||
两端同时存在或同时消失视为冲突;查询或身份无法确认时不自动重放。
|
||||
两端同时存在或遗留步骤中同时消失视为冲突;只有覆盖备份在执行前明确
|
||||
发现旧目标缺失时,才记录为无副作用的跳过。查询或身份无法确认时不自动重放。
|
||||
|
||||
:param step_runner: 步骤执行器;None 时同步执行
|
||||
:param storage_oper: 负责查询和重命名的存储适配器
|
||||
@@ -1224,14 +1226,18 @@ class TransHandler:
|
||||
:param target: 新路径,必须不存在且由调用方保持在同一目录
|
||||
:param kind: 步骤操作类别
|
||||
:param expected_source: 可选的冻结源身份;提供时在操作前后核对
|
||||
:return: 重命名后的文件项
|
||||
:param allow_missing_source: 旧目标已被外部删除时是否将重命名记录为跳过
|
||||
:return: 重命名后的文件项;允许旧目标缺失并跳过时返回 None
|
||||
"""
|
||||
def execute() -> TransferStepResult:
|
||||
"""
|
||||
目标必须不存在,避免适配器的 rename 意外替换其它文件。
|
||||
"""
|
||||
source_item = storage_oper.get_item_strict(source)
|
||||
if source_item is None or storage_oper.get_item_strict(target) is not None:
|
||||
target_item = storage_oper.get_item_strict(target)
|
||||
if allow_missing_source and source_item is None and target_item is None:
|
||||
return TransferStepResult(payload={"item": None, "skipped": True})
|
||||
if source_item is None or target_item is not None:
|
||||
raise RuntimeError(f"重命名路径状态冲突:{source} -> {target}")
|
||||
if expected_source is not None:
|
||||
cls.__assert_file_identity(expected_source, source_item)
|
||||
@@ -1285,7 +1291,9 @@ class TransHandler:
|
||||
execute=execute, observe=observe,
|
||||
)
|
||||
item = cls.__restore_step_item(renamed)
|
||||
if item is None:
|
||||
if item is None and not (
|
||||
allow_missing_source and renamed.payload.get("skipped") is True
|
||||
):
|
||||
raise RuntimeError("重命名成功证据缺少目标文件")
|
||||
return item
|
||||
|
||||
@@ -1339,12 +1347,13 @@ class TransHandler:
|
||||
step_runner=step_runner, storage_oper=target_oper,
|
||||
storage=target_storage, source=target_file, target=backup,
|
||||
kind="backup_overwrite_target",
|
||||
allow_missing_source=True,
|
||||
)
|
||||
new_item = cls.__rename_with_step(
|
||||
new_item = cast(FileItem, cls.__rename_with_step(
|
||||
step_runner=step_runner, storage_oper=target_oper,
|
||||
storage=target_storage, source=stage, target=target_file,
|
||||
kind="promote_overwrite_target", expected_source=new_item,
|
||||
)
|
||||
))
|
||||
|
||||
def confirm_replacement() -> None:
|
||||
"""
|
||||
|
||||
@@ -498,6 +498,48 @@ def test_safe_overwrite_preserves_transfer_modes(tmp_path, monkeypatch, transfer
|
||||
assert target.is_symlink() is (transfer_type == "softlink")
|
||||
|
||||
|
||||
def test_overwrite_promotes_new_target_when_old_target_disappears_before_backup(
|
||||
tmp_path, monkeypatch, repository_factory,
|
||||
):
|
||||
"""
|
||||
持久覆盖裁决后旧目标被洗版清理时,跳过无效备份并继续提升新文件。
|
||||
"""
|
||||
source = tmp_path / "download.mkv"
|
||||
target = tmp_path / "Movie.2026.mkv"
|
||||
source.write_bytes(b"new-version")
|
||||
target.write_bytes(b"old-version")
|
||||
checkpoint = _checkpoint(source, target, transfer_type="move")
|
||||
repository = repository_factory(checkpoint)
|
||||
real_get_item_strict = LocalStorage.get_item_strict
|
||||
deleted = False
|
||||
|
||||
def remove_target_after_decision(self, path):
|
||||
"""
|
||||
模拟覆盖裁决完成后洗版流程删除媒体库旧目标。
|
||||
"""
|
||||
nonlocal deleted
|
||||
item = real_get_item_strict(self, path)
|
||||
if path == target and item is not None and not deleted:
|
||||
target.unlink()
|
||||
deleted = True
|
||||
return item
|
||||
|
||||
monkeypatch.setattr(LocalStorage, "get_item_strict", remove_target_after_decision)
|
||||
result = _execute(checkpoint, monkeypatch, _durable_runner(repository, checkpoint))
|
||||
|
||||
assert result.success
|
||||
assert target.read_bytes() == b"new-version"
|
||||
assert not source.exists()
|
||||
assert not list(tmp_path.glob(".mp-*"))
|
||||
backup_step = next(
|
||||
step for step in repository.get_snapshot(task_id="safe-overwrite").steps
|
||||
if step.kind == "backup_overwrite_target"
|
||||
)
|
||||
assert backup_step.state is TransferStepState.SUCCEEDED
|
||||
assert backup_step.result is not None
|
||||
assert backup_step.result.payload["skipped"] is True
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def repository_factory():
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user