mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-03 13:55:00 +08:00
fix(transfer): ignore timestamps in cleanup identity checks
This commit is contained in:
@@ -803,24 +803,22 @@ class TransHandler:
|
||||
@staticmethod
|
||||
def __assert_file_identity(expected: FileItem, current: Optional[FileItem]) -> None:
|
||||
"""
|
||||
清理前比对冻结文件的大小、修改时间及可用身份;证据不足时保留文件。
|
||||
清理前比对冻结文件的大小、类型及可用身份;证据不足时保留文件。
|
||||
|
||||
本地链接的查询沿用 stat 语义,硬链接和软链接均比较实际内容的身份。
|
||||
旧快照没有 fileid 时允许用完整大小、时间凭据;缺少这两项则不能自动删除。
|
||||
云盘修改时间可能在上传后变化或缺失,不参与校验;旧快照没有 fileid 时使用大小凭据。
|
||||
|
||||
:param expected: 冻结的文件身份,必须包含大小和修改时间
|
||||
:param expected: 冻结的文件身份,必须包含大小
|
||||
:param current: 当前严格查询结果;不可见时视为冲突
|
||||
:raises TransferExecutionConflictError: 文件消失、身份字段变化或类型不一致
|
||||
:raises StorageQueryError: 缺少必要身份凭据
|
||||
"""
|
||||
if current is None:
|
||||
raise TransferExecutionConflictError(f"文件 {expected.path} 已不可见,保留源文件和旧备份")
|
||||
for field in ("size", "modify_time"):
|
||||
frozen_value, live_value = getattr(expected, field), getattr(current, field)
|
||||
if frozen_value is None or live_value is None:
|
||||
raise StorageQueryError(f"文件 {expected.path} 缺少 {field} 身份凭据,需人工复核")
|
||||
if frozen_value != live_value:
|
||||
raise TransferExecutionConflictError(f"文件 {expected.path} 的 {field} 已变化,禁止清理")
|
||||
if expected.size is None or current.size is None:
|
||||
raise StorageQueryError(f"文件 {expected.path} 缺少 size 身份凭据,需人工复核")
|
||||
if expected.size != current.size:
|
||||
raise TransferExecutionConflictError(f"文件 {expected.path} 的 size 已变化,禁止清理")
|
||||
if expected.type != current.type:
|
||||
raise TransferExecutionConflictError(f"文件 {expected.path} 的类型已变化,禁止清理")
|
||||
if expected.fileid:
|
||||
|
||||
@@ -788,8 +788,9 @@ SMB 同存储整理由 `app.modules.filemanager.storages.smb` 持有协议调用
|
||||
步骤路径从冻结计划推导,DB 适配器逐项校验并要求前驱已持久成功;查询或操作结果未知时保留文件并隔离,
|
||||
禁止删除旧目标后盲目重放。`latest` 旧版本清理和宿主历史目的清理在新目标成功后执行,
|
||||
同存储重合目的不能整块删除新文件,本地符号链接路径在可超时的文件系统代理内规范化,并在清理前再次检查。
|
||||
暂存文件大小必须与源证据一致,清理源或备份前复核大小、修改时间及可用的稳定文件 ID,
|
||||
暂存文件大小必须与源证据一致,清理源或备份前复核大小、类型及可用的稳定文件 ID,
|
||||
新目标还必须匹配提升步骤的持久凭据;旧快照缺少必要证据或文件已变更时保留文件并转人工复核。
|
||||
修改时间可能由云盘延迟更新或缺失,不作为整理执行阶段的文件身份校验依据。
|
||||
本地文件 ID 使用设备号和 inode,验证不额外读取整文件计算哈希。
|
||||
外部 legacy transfer provider 继续遵循原有兼容合同。
|
||||
|
||||
|
||||
@@ -397,9 +397,10 @@ class _ImmediateStepRunner:
|
||||
return execute()
|
||||
|
||||
|
||||
def test_cross_storage_move_materializes_before_independent_source_delete(tmp_path):
|
||||
@pytest.mark.parametrize("frozen_time,live_time", [(1.0, 1.0), (1.0, 5.0), (None, 5.0), (1.0, None)])
|
||||
def test_cross_storage_move_materializes_before_independent_source_delete(tmp_path, frozen_time, live_time):
|
||||
"""
|
||||
跨存储 move 必须先复制目标,再用独立步骤删除源。
|
||||
跨存储 move 先复制再独立删除源,源和目标时间戳变化或缺失均不阻止清理。
|
||||
"""
|
||||
source_path = tmp_path / "source.mkv"
|
||||
source_path.write_bytes(b"movie")
|
||||
@@ -409,7 +410,7 @@ def test_cross_storage_move_materializes_before_independent_source_delete(tmp_pa
|
||||
name=source_path.name,
|
||||
type="file",
|
||||
size=source_path.stat().st_size,
|
||||
modify_time=source_path.stat().st_mtime,
|
||||
modify_time=frozen_time,
|
||||
extension="mkv",
|
||||
)
|
||||
target_item = FileItem(
|
||||
@@ -423,9 +424,10 @@ def test_cross_storage_move_materializes_before_independent_source_delete(tmp_pa
|
||||
)
|
||||
source_oper = Mock()
|
||||
source_oper.delete.return_value = True
|
||||
source_oper.get_item_strict.return_value = source_item
|
||||
live_source = source_item.model_copy(update={"modify_time": live_time})
|
||||
source_oper.get_item_strict.return_value = live_source
|
||||
target_oper = Mock()
|
||||
target_oper.get_item_strict.return_value = target_item
|
||||
target_oper.get_item_strict.return_value = target_item.model_copy(update={"modify_time": live_time})
|
||||
target_oper.get_folder.return_value = FileItem(
|
||||
storage="remote", path="/library", name="library", type="dir"
|
||||
)
|
||||
@@ -450,7 +452,7 @@ def test_cross_storage_move_materializes_before_independent_source_delete(tmp_pa
|
||||
]
|
||||
assert runner.steps[0][2]["transfer_type"] == "copy"
|
||||
target_oper.upload.assert_called_once()
|
||||
source_oper.delete.assert_called_once_with(source_item)
|
||||
source_oper.delete.assert_called_once_with(live_source)
|
||||
|
||||
|
||||
def test_remote_to_local_transfer_creates_target_directory_before_download(tmp_path):
|
||||
|
||||
@@ -640,33 +640,75 @@ def test_completed_promotion_rechecks_live_files_before_destructive_cleanup(
|
||||
assert backup.read_bytes() == expected_backup
|
||||
|
||||
|
||||
def test_completed_promotion_allows_cleanup_after_timestamp_changes(
|
||||
tmp_path, monkeypatch, repository_factory,
|
||||
):
|
||||
"""
|
||||
提升结果已落账后,即使源、目标和备份的时间戳均变化,恢复仍按大小和身份完成清理。
|
||||
"""
|
||||
source, target = tmp_path / "download.mkv", 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)
|
||||
runner = _durable_runner(repository, checkpoint)
|
||||
real_run = runner.run
|
||||
|
||||
class ProcessStopped(BaseException):
|
||||
"""
|
||||
模拟提升结果落账后、清理源文件前进程退出。
|
||||
"""
|
||||
|
||||
def stop_before_cleanup(**kwargs):
|
||||
"""
|
||||
保留真实成功证据,只中断源文件清理步骤。
|
||||
"""
|
||||
if kwargs["kind"] == "delete_overwrite_source":
|
||||
raise ProcessStopped()
|
||||
return real_run(**kwargs)
|
||||
|
||||
monkeypatch.setattr(runner, "run", stop_before_cleanup)
|
||||
with pytest.raises(ProcessStopped):
|
||||
_execute(checkpoint, monkeypatch, runner)
|
||||
backup = next(tmp_path.glob(".mp-backup-*"))
|
||||
for path in (source, target, backup):
|
||||
before = path.stat()
|
||||
os.utime(path, ns=(before.st_atime_ns, before.st_mtime_ns + 4_000_000_000))
|
||||
|
||||
result = _execute(checkpoint, monkeypatch, _durable_runner(repository, checkpoint))
|
||||
assert result.success
|
||||
assert target.read_bytes() == b"new-version"
|
||||
assert not source.exists()
|
||||
assert not backup.exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("missing_evidence", ["source", "target"])
|
||||
def test_overwrite_preserves_all_copies_when_cleanup_identity_evidence_is_missing(
|
||||
tmp_path, monkeypatch, repository_factory, missing_evidence,
|
||||
):
|
||||
"""
|
||||
旧快照或适配器缺少修改时间时,禁止把当前查询结果当作原文件凭据。
|
||||
旧快照或适配器缺少大小时,禁止把当前查询结果当作原文件凭据。
|
||||
"""
|
||||
source, target = tmp_path / "download.mkv", tmp_path / "Movie.2026.mkv"
|
||||
source.write_bytes(b"new-version")
|
||||
target.write_bytes(b"old-version")
|
||||
checkpoint = _checkpoint(source, target, transfer_type="move")
|
||||
if missing_evidence == "source":
|
||||
frozen_source = {**checkpoint.items[0].source_fileitem, "modify_time": None}
|
||||
frozen_source = {**checkpoint.items[0].source_fileitem, "size": None}
|
||||
checkpoint = replace(checkpoint,
|
||||
planning_input=replace(checkpoint.planning_input, source_fileitem=frozen_source),
|
||||
items=(replace(checkpoint.items[0], source_fileitem=frozen_source),))
|
||||
else:
|
||||
real_query = LocalStorage.get_item_strict
|
||||
|
||||
def query_without_time(self, path):
|
||||
def query_without_size(self, path):
|
||||
"""
|
||||
模拟目标适配器未提供可持久验证的修改时间。
|
||||
模拟目标适配器未提供可持久验证的大小。
|
||||
"""
|
||||
item = real_query(self, path)
|
||||
return item.model_copy(update={"modify_time": None}) if item and path == target else item
|
||||
return item.model_copy(update={"size": None}) if item and path == target else item
|
||||
|
||||
monkeypatch.setattr(LocalStorage, "get_item_strict", query_without_time)
|
||||
monkeypatch.setattr(LocalStorage, "get_item_strict", query_without_size)
|
||||
repository = repository_factory(checkpoint)
|
||||
with pytest.raises(execution_module._TransferManualReviewRequired):
|
||||
_execute(checkpoint, monkeypatch, _durable_runner(repository, checkpoint))
|
||||
|
||||
Reference in New Issue
Block a user