mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-02 21:34:32 +08:00
fix(storage): preserve transfer contents and cleanup identities (#7142)
This commit is contained in:
@@ -1044,13 +1044,14 @@ class FileManagerModule(_ModuleBase):
|
||||
for fileitem in fileitems:
|
||||
file_meta = MetaInfo(fileitem.basename or "")
|
||||
season_index = file_meta.begin_season if file_meta.begin_season is not None else 1
|
||||
episode_index = file_meta.begin_episode
|
||||
if not episode_index:
|
||||
episode_indexes = file_meta.episode_list
|
||||
if not episode_indexes:
|
||||
continue
|
||||
if season_index not in seasons:
|
||||
seasons[season_index] = []
|
||||
if episode_index not in seasons[season_index]:
|
||||
seasons[season_index].append(episode_index)
|
||||
for episode_index in episode_indexes:
|
||||
if episode_index not in seasons[season_index]:
|
||||
seasons[season_index].append(episode_index)
|
||||
logger.info(f"{mediainfo.title_year} 在本地文件系统中找到了这些季集:{seasons}")
|
||||
return ExistMediaInfo(type=MediaType.TV, seasons=seasons)
|
||||
return None
|
||||
|
||||
@@ -135,8 +135,8 @@ class AliPan(StorageBase, metaclass=WeakSingleton):
|
||||
refreshed_tokens = self.__refresh_access_token(refresh_token)
|
||||
if not refreshed_tokens:
|
||||
return None
|
||||
tokens = refreshed_tokens
|
||||
self.set_config({"refresh_time": int(time.time()), **tokens})
|
||||
tokens = {**tokens, **refreshed_tokens}
|
||||
self.set_config({**tokens, "refresh_time": int(time.time())})
|
||||
access_token = tokens.get("access_token")
|
||||
if access_token:
|
||||
self._request_utils.update_headers(
|
||||
@@ -454,7 +454,7 @@ class AliPan(StorageBase, metaclass=WeakSingleton):
|
||||
"""
|
||||
分页列出目录直接子项,文件输入则刷新自身详情。
|
||||
|
||||
每页最多读取 100 项,少于 100 项时停止;无响应抛出 FileNotFoundError。
|
||||
每页最多读取 100 项,没有下一页标记时停止;无响应抛出 FileNotFoundError。
|
||||
业务错误未统一抛出,可能返回已获取的部分结果。
|
||||
|
||||
:param fileitem: 待查询项;非根目录必须带有文件标识和空间标识
|
||||
@@ -497,7 +497,7 @@ class AliPan(StorageBase, metaclass=WeakSingleton):
|
||||
current_items = resp.get("items") or []
|
||||
for item in current_items:
|
||||
items.append(self.__get_fileitem(item, parent=str(fileitem.path)))
|
||||
if len(current_items) < 100:
|
||||
if not next_marker:
|
||||
break
|
||||
return items
|
||||
|
||||
@@ -977,18 +977,16 @@ class AliPan(StorageBase, metaclass=WeakSingleton):
|
||||
"""
|
||||
请求将文件或目录移入云盘回收站。
|
||||
|
||||
当前实现不检查接口返回的业务错误或空响应,True 不能证明目标已删除。
|
||||
|
||||
:param fileitem: 携带空间和文件标识的待删除项
|
||||
:return: 未抛出 HTTP 请求异常时返回 True,否则返回 False
|
||||
:return: 收到无业务错误的 JSON 响应时返回 True;拒绝、无响应或请求异常返回 False
|
||||
"""
|
||||
try:
|
||||
self._request_api(
|
||||
response = self._request_api(
|
||||
"POST",
|
||||
"/adrive/v1.0/openFile/recyclebin/trash",
|
||||
json={"drive_id": fileitem.drive_id, "file_id": fileitem.fileid},
|
||||
)
|
||||
return True
|
||||
return isinstance(response, dict) and not response.get("code")
|
||||
except HttpRequestError:
|
||||
return False
|
||||
|
||||
|
||||
@@ -564,13 +564,13 @@ class Alist(StorageBase, metaclass=WeakSingleton):
|
||||
"""
|
||||
取得下载地址后流式写入本地文件。
|
||||
|
||||
当前捕获传输异常后仍返回目标路径,不能据此断定文件完整甚至已经创建;
|
||||
取消和失败都可能留下部分文件,调用方需另行核验。
|
||||
文件下载不发送 OpenList API 令牌,使用服务端返回的直链或签名地址。
|
||||
只有流正常结束且已知大小匹配才返回成功;中断、取消或截断均删除未完成文件。
|
||||
|
||||
:param fileitem: 源远端文件;name 直接用于拼接保存路径
|
||||
:param fileitem: 源远端文件,提供文件名称和可选的预期大小
|
||||
:param path: 本地保存目录;为空时使用 TEMP_PATH
|
||||
:param password: 详情查询使用的可选访问密码
|
||||
:return: 本地目标路径;详情查询失败或取消返回 None
|
||||
:return: 完整本地目标路径;详情查询、传输、大小校验失败或取消返回 None
|
||||
"""
|
||||
if not fileitem.path:
|
||||
return None
|
||||
@@ -613,22 +613,32 @@ class Alist(StorageBase, metaclass=WeakSingleton):
|
||||
if local_path is None or not download_url:
|
||||
return None
|
||||
|
||||
request_utils = RequestUtils(headers=self.__get_header_with_token())
|
||||
# 下载使用 raw_url 或带 sign 的 /d 地址,API 令牌不属于文件服务及其重定向目标。
|
||||
request_utils = RequestUtils()
|
||||
try:
|
||||
with request_utils.get_stream(download_url, raise_exception=True) as r:
|
||||
if r is None:
|
||||
return None
|
||||
r.raise_for_status()
|
||||
downloaded_size = 0
|
||||
with open(local_path, "wb") as f:
|
||||
for chunk in r.iter_content(chunk_size=8192):
|
||||
if runtime_stop_state.consume_transfer_stop(fileitem.path):
|
||||
logger.info(f"【OpenList】{fileitem.path} 下载已取消!")
|
||||
return None
|
||||
raise IOError("OpenList 下载已取消")
|
||||
f.write(chunk)
|
||||
downloaded_size += len(chunk)
|
||||
expected_size = fileitem.size
|
||||
if expected_size is None:
|
||||
expected_size = result["data"].get("size")
|
||||
if expected_size is None and r.headers.get("Content-Length"):
|
||||
expected_size = int(r.headers["Content-Length"])
|
||||
if expected_size is not None and downloaded_size != expected_size:
|
||||
raise IOError(f"下载大小不一致:预期 {expected_size},实际 {downloaded_size}")
|
||||
except Exception as e:
|
||||
logger.error(f"【OpenList】下载文件 {fileitem.path} 失败:{e}")
|
||||
if local_path.exists():
|
||||
return local_path
|
||||
local_path.unlink(missing_ok=True)
|
||||
return None
|
||||
|
||||
return local_path
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import os
|
||||
import shutil
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, List, Optional
|
||||
|
||||
@@ -70,6 +71,8 @@ class LocalStorage(StorageBase):
|
||||
extension=path.suffix[1:],
|
||||
size=info["size"],
|
||||
modify_time=info["mtime"],
|
||||
modify_time_ns=info.get("mtime_ns"),
|
||||
change_time_ns=info.get("ctime_ns"),
|
||||
fileid=f"{info['dev']}:{info['ino']}" if info.get("ino") else None,
|
||||
)
|
||||
|
||||
@@ -293,14 +296,14 @@ class LocalStorage(StorageBase):
|
||||
@classmethod
|
||||
def _partial_path(cls, dest: Path) -> Path:
|
||||
"""
|
||||
生成同目录、带进程标识的中间写入路径。
|
||||
生成同目录、每次调用独立的中间写入路径。
|
||||
|
||||
同目录便于完成后原子替换;进程标识隔离不同进程,但不保证同进程并发写同一目标互不干扰。
|
||||
同目录便于完成后原子替换;随机标识隔离同进程和跨进程的同目标任务。
|
||||
|
||||
:param dest: 最终文件路径
|
||||
:return: 隐藏的 .mp-partial 文件路径
|
||||
"""
|
||||
return dest.parent / f".{dest.name}.{os.getpid()}{cls.PARTIAL_SUFFIX}"
|
||||
return dest.parent / f".{dest.name}.{uuid.uuid4().hex}{cls.PARTIAL_SUFFIX}"
|
||||
|
||||
@classmethod
|
||||
def _cleanup_stale_partials(cls, directory: Path) -> None:
|
||||
@@ -348,7 +351,7 @@ class LocalStorage(StorageBase):
|
||||
return False
|
||||
src_stat = src.stat()
|
||||
os.utime(dest, ns=(src_stat.st_atime_ns, src_stat.st_mtime_ns))
|
||||
sentinel = dest.parent / f".mode-probe-{os.getpid()}"
|
||||
sentinel = dest.parent / f".mode-probe-{uuid.uuid4().hex}"
|
||||
try:
|
||||
sentinel.write_bytes(b"")
|
||||
dest_mode = sentinel.stat().st_mode & 0o777
|
||||
|
||||
@@ -813,7 +813,9 @@ class TransHandler:
|
||||
清理前比对冻结文件的大小、类型及可用身份;证据不足时保留文件。
|
||||
|
||||
本地链接的查询沿用 stat 语义,硬链接和软链接均比较实际内容的身份。
|
||||
云盘修改时间可能在上传后变化或缺失,不参与校验;旧快照没有 fileid 时使用大小凭据。
|
||||
本地必须核对修改时间,优先纳秒证据;同路径还核对状态时间,防止等长改写后还原 mtime。
|
||||
rename 合法改变状态时间,因此跨路径只比较修改时间。旧本地快照回退到秒级修改时间,
|
||||
缺少时间凭据时转人工复核。云盘修改时间可能在上传后变化或缺失,不参与校验。
|
||||
|
||||
:param expected: 冻结的文件身份,必须包含大小
|
||||
:param current: 当前严格查询结果;不可见时视为冲突
|
||||
@@ -833,6 +835,22 @@ class TransHandler:
|
||||
raise StorageQueryError(f"文件 {expected.path} 缺少 fileid 身份凭据,需人工复核")
|
||||
if expected.fileid != current.fileid:
|
||||
raise TransferExecutionConflictError(f"文件 {expected.path} 已被替换,禁止清理")
|
||||
if expected.storage == "local":
|
||||
expected_time: Optional[int | float]
|
||||
current_time: Optional[int | float]
|
||||
if expected.modify_time_ns is not None:
|
||||
expected_time, current_time = expected.modify_time_ns, current.modify_time_ns
|
||||
else:
|
||||
expected_time, current_time = expected.modify_time, current.modify_time
|
||||
if expected_time is None or current_time is None:
|
||||
raise StorageQueryError(f"文件 {expected.path} 缺少修改时间身份凭据,需人工复核")
|
||||
if expected_time != current_time:
|
||||
raise TransferExecutionConflictError(f"文件 {expected.path} 的修改时间已变化,禁止清理")
|
||||
if expected.path == current.path and expected.change_time_ns is not None:
|
||||
if current.change_time_ns is None:
|
||||
raise StorageQueryError(f"文件 {expected.path} 缺少状态时间身份凭据,需人工复核")
|
||||
if expected.change_time_ns != current.change_time_ns:
|
||||
raise TransferExecutionConflictError(f"文件 {expected.path} 的状态时间已变化,禁止清理")
|
||||
|
||||
@staticmethod
|
||||
def __restore_move_target_identity(source: FileItem, target: FileItem) -> FileItem:
|
||||
@@ -1550,34 +1568,47 @@ class TransHandler:
|
||||
),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def __resolve_leaf_overwrite_with_step(
|
||||
cls,
|
||||
self,
|
||||
*,
|
||||
step_runner: Optional[TransferStepRunner],
|
||||
planned_item: TransferPlanItem,
|
||||
target_oper: StorageBase,
|
||||
transfer_type: str,
|
||||
) -> bool:
|
||||
meta: MetaBase,
|
||||
mediainfo: MediaInfo | MusicInfo,
|
||||
overwrite_mode: Optional[str],
|
||||
need_notify: bool,
|
||||
) -> tuple[bool, Optional[TransferInfo]]:
|
||||
"""
|
||||
冻结目录叶子目标的存在性,恢复时沿用同一操作序列。
|
||||
|
||||
此处只判断叶子目标是否存在,不再次执行单文件覆盖策略。
|
||||
按冻结覆盖策略裁决目录叶目标,保持既有叶子步骤的稳定身份。
|
||||
|
||||
:param step_runner: 步骤执行器;None 时直接查询
|
||||
:param planned_item: 含冻结源快照和目标路径的叶子操作
|
||||
:param target_oper: 负责严格查询的目标适配器
|
||||
:param transfer_type: 整理方式,参与步骤身份
|
||||
:return: 目标已存在且需要替换时为 True
|
||||
:param meta: 目录媒体元数据
|
||||
:param mediainfo: 已识别媒体信息
|
||||
:param overwrite_mode: 检查点冻结的覆盖策略
|
||||
:param need_notify: 拒绝覆盖时是否请求通知
|
||||
:return: 是否覆盖及可选的拒绝结果
|
||||
"""
|
||||
def execute() -> TransferStepResult:
|
||||
"""
|
||||
严格查询当前叶目标,失败时禁止对旧文件执行任何操作。
|
||||
目录叶与单文件共用覆盖合同,并冻结拒绝原因。
|
||||
"""
|
||||
exists = target_oper.get_item_strict(Path(planned_item.target_path)) is not None
|
||||
return TransferStepResult(payload={"over_flag": exists})
|
||||
over_flag, _, failure = self.__resolve_overwrite(
|
||||
fileitem=FileItem.model_validate(planned_item.source_fileitem),
|
||||
meta=meta, mediainfo=mediainfo, target_oper=target_oper,
|
||||
target_storage=planned_item.target_storage, target_file=Path(planned_item.target_path),
|
||||
transfer_type=transfer_type, overwrite_mode=overwrite_mode, need_notify=need_notify,
|
||||
)
|
||||
return TransferStepResult(payload={
|
||||
"over_flag": over_flag, "policy_checked": True,
|
||||
"failure": failure.model_dump(mode="json") if failure else None,
|
||||
})
|
||||
|
||||
decision = cls.__run_persisted_step(
|
||||
decision = self.__run_persisted_step(
|
||||
step_runner, phase="decision", kind="resolve_leaf_overwrite",
|
||||
payload={"source": planned_item.source_fileitem,
|
||||
"target_storage": planned_item.target_storage,
|
||||
@@ -1588,7 +1619,19 @@ class TransHandler:
|
||||
evidence=TransferStepResult(payload={"reason": "read-only leaf decision may be repeated"}),
|
||||
),
|
||||
)
|
||||
return decision.payload.get("over_flag") is True
|
||||
failure = decision.payload.get("failure")
|
||||
if (decision.payload.get("over_flag") is True and overwrite_mode == "never"
|
||||
and decision.payload.get("policy_checked") is not True):
|
||||
# 旧版本只记录存在性,不能用旧回执授权本次禁止的破坏性覆盖。
|
||||
return False, TransferInfo(
|
||||
success=False, overwrite_skipped=True,
|
||||
message="旧目录覆盖裁决未校验不覆盖策略,保留源文件和目标,需人工复核",
|
||||
fileitem=FileItem.model_validate(planned_item.source_fileitem),
|
||||
transfer_type=transfer_type, need_notify=need_notify,
|
||||
)
|
||||
return decision.payload.get("over_flag") is True, (
|
||||
TransferInfo.model_validate(failure) if isinstance(failure, dict) else None
|
||||
)
|
||||
|
||||
def __resolve_overwrite_with_step(
|
||||
self,
|
||||
@@ -1715,6 +1758,53 @@ class TransHandler:
|
||||
)
|
||||
return bool(result.payload.get("allowed")), str(result.payload.get("reason") or "")
|
||||
|
||||
def __execute_directory_leaf(
|
||||
self, *, planned_item: TransferPlanItem, checkpoint: TransferPlanCheckpoint,
|
||||
meta: MetaBase, mediainfo: MediaInfo | MusicInfo,
|
||||
source_oper: StorageBase, target_oper: StorageBase,
|
||||
step_runner: Optional[TransferStepRunner],
|
||||
) -> tuple[Optional[FileItem], Optional[TransferInfo]]:
|
||||
"""
|
||||
校验目录叶操作并消费覆盖裁决,拒绝时不执行文件副作用。
|
||||
|
||||
:param planned_item: 冻结的目录叶整理操作
|
||||
:param checkpoint: 目录计划及其覆盖、通知策略
|
||||
:param meta: 目录媒体元数据
|
||||
:param mediainfo: 目录媒体识别结果
|
||||
:param source_oper: 源存储适配器
|
||||
:param target_oper: 目标存储适配器
|
||||
:param step_runner: 持久步骤执行器;为空时同步执行
|
||||
:return: 新目标及可选失败结果;拒绝覆盖保留 overwrite_skipped 语义
|
||||
"""
|
||||
transfer_type = checkpoint.resolved_transfer_type
|
||||
if planned_item.action != "transfer" or planned_item.target_storage != checkpoint.target_storage:
|
||||
return None, TransferInfo(
|
||||
success=False, message="整理计划包含不支持的操作或目标存储",
|
||||
fileitem=FileItem.model_validate(checkpoint.planning_input.source_fileitem),
|
||||
transfer_type=transfer_type, need_notify=checkpoint.need_notify,
|
||||
)
|
||||
over_flag, failure = self.__resolve_leaf_overwrite_with_step(
|
||||
step_runner=step_runner, planned_item=planned_item,
|
||||
target_oper=target_oper, transfer_type=transfer_type,
|
||||
meta=meta, mediainfo=mediainfo, overwrite_mode=checkpoint.overwrite_mode,
|
||||
need_notify=checkpoint.need_notify,
|
||||
)
|
||||
if failure:
|
||||
return None, failure
|
||||
execute_transfer = self.__replace_target_with_steps if over_flag else self.__execute_transfer_with_steps
|
||||
new_item, error = execute_transfer(
|
||||
step_runner=step_runner, source_fileitem=planned_item.source_fileitem,
|
||||
target_storage=planned_item.target_storage, source_oper=source_oper, target_oper=target_oper,
|
||||
target_file=Path(planned_item.target_path), transfer_type=transfer_type,
|
||||
)
|
||||
if not new_item:
|
||||
return None, TransferInfo(
|
||||
success=False, message=error,
|
||||
fileitem=FileItem.model_validate(checkpoint.planning_input.source_fileitem),
|
||||
transfer_type=transfer_type, need_notify=checkpoint.need_notify,
|
||||
)
|
||||
return new_item, None
|
||||
|
||||
def execute_transfer_plan(
|
||||
self,
|
||||
checkpoint: TransferPlanCheckpoint,
|
||||
@@ -1842,47 +1932,16 @@ class TransHandler:
|
||||
need_notify=checkpoint.need_notify,
|
||||
)
|
||||
for planned_item in checkpoint.items:
|
||||
if (
|
||||
planned_item.action != "transfer"
|
||||
or planned_item.target_storage != target_storage
|
||||
):
|
||||
return TransferInfo(
|
||||
success=False,
|
||||
message="整理计划包含不支持的操作或目标存储",
|
||||
fileitem=fileitem,
|
||||
transfer_type=transfer_type,
|
||||
need_notify=checkpoint.need_notify,
|
||||
)
|
||||
source_item = FileItem.model_validate(planned_item.source_fileitem)
|
||||
over_flag = self.__resolve_leaf_overwrite_with_step(
|
||||
step_runner=step_runner, planned_item=planned_item,
|
||||
target_oper=target_oper, transfer_type=transfer_type,
|
||||
new_item, failure = self.__execute_directory_leaf(
|
||||
planned_item=planned_item, checkpoint=checkpoint, meta=meta, mediainfo=mediainfo,
|
||||
source_oper=source_oper, target_oper=target_oper, step_runner=step_runner,
|
||||
)
|
||||
execute_transfer = (
|
||||
self.__replace_target_with_steps if over_flag
|
||||
else self.__execute_transfer_with_steps
|
||||
)
|
||||
new_item, error = execute_transfer(
|
||||
step_runner=step_runner,
|
||||
source_fileitem=planned_item.source_fileitem,
|
||||
target_storage=planned_item.target_storage,
|
||||
source_oper=source_oper,
|
||||
target_oper=target_oper,
|
||||
target_file=Path(planned_item.target_path),
|
||||
transfer_type=transfer_type,
|
||||
)
|
||||
if not new_item:
|
||||
return TransferInfo(
|
||||
success=False,
|
||||
message=error,
|
||||
fileitem=fileitem,
|
||||
transfer_type=transfer_type,
|
||||
need_notify=checkpoint.need_notify,
|
||||
)
|
||||
if failure:
|
||||
return failure
|
||||
self.__update_result(
|
||||
result=result,
|
||||
file_list=[source_item.path],
|
||||
file_list_new=[new_item.path],
|
||||
file_list=[planned_item.source_fileitem.get("path")],
|
||||
file_list_new=[cast(FileItem, new_item).path],
|
||||
)
|
||||
self.__cleanup_with_step(
|
||||
step_runner=step_runner,
|
||||
|
||||
Reference in New Issue
Block a user