mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-05-13 07:26:45 +00:00
fix: prevent storage operations in preview mode and add tests for transfer preview logic
This commit is contained in:
@@ -1974,7 +1974,10 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
|||||||
return False, f"{fileitem.name} 没有找到可整理的媒体文件"
|
return False, f"{fileitem.name} 没有找到可整理的媒体文件"
|
||||||
|
|
||||||
planned_file_count = len(file_items)
|
planned_file_count = len(file_items)
|
||||||
logger.info(f"正在计划整理 {planned_file_count} 个文件...")
|
if preview:
|
||||||
|
logger.info(f"正在预览 {planned_file_count} 个文件的整理路径...")
|
||||||
|
else:
|
||||||
|
logger.info(f"正在计划整理 {planned_file_count} 个文件...")
|
||||||
|
|
||||||
# 整理所有文件
|
# 整理所有文件
|
||||||
transfer_tasks: List[TransferTask] = []
|
transfer_tasks: List[TransferTask] = []
|
||||||
|
|||||||
@@ -860,6 +860,17 @@ class PluginHelper(metaclass=WeakSingleton):
|
|||||||
strategies.append(("直连", base_cmd))
|
strategies.append(("直连", base_cmd))
|
||||||
return strategies
|
return strategies
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def __build_runtime_pip_command(*args: str) -> List[str]:
|
||||||
|
"""
|
||||||
|
优先使用当前解释器同目录的 pip 入口,以便 uv-pip-compat 能接管兼容命令。
|
||||||
|
"""
|
||||||
|
pip_name = "pip.exe" if sys.platform == "win32" else "pip"
|
||||||
|
pip_bin = Path(sys.executable).with_name(pip_name)
|
||||||
|
if pip_bin.exists():
|
||||||
|
return [str(pip_bin), *args]
|
||||||
|
return [sys.executable, "-m", "pip", *args]
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def __format_pkg_name_for_pip(name: str) -> str:
|
def __format_pkg_name_for_pip(name: str) -> str:
|
||||||
"""
|
"""
|
||||||
@@ -1217,7 +1228,7 @@ class PluginHelper(metaclass=WeakSingleton):
|
|||||||
安装完成后立即执行运行环境自检,尽量在插件加载前发现依赖图已被污染。
|
安装完成后立即执行运行环境自检,尽量在插件加载前发现依赖图已被污染。
|
||||||
"""
|
"""
|
||||||
checks = [
|
checks = [
|
||||||
("pip check", [sys.executable, "-m", "pip", "check"]),
|
("pip check", cls.__build_runtime_pip_command("check")),
|
||||||
("核心依赖导入检查", [sys.executable, "-c", cls._runtime_import_probe]),
|
("核心依赖导入检查", [sys.executable, "-c", cls._runtime_import_probe]),
|
||||||
]
|
]
|
||||||
for check_name, command in checks:
|
for check_name, command in checks:
|
||||||
|
|||||||
@@ -63,6 +63,26 @@ class TransHandler:
|
|||||||
current_value = value
|
current_value = value
|
||||||
setattr(result, key, current_value)
|
setattr(result, key, current_value)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def __build_preview_item(
|
||||||
|
storage: str,
|
||||||
|
path: Path,
|
||||||
|
item_type: str,
|
||||||
|
size: Optional[int] = None,
|
||||||
|
) -> FileItem:
|
||||||
|
"""
|
||||||
|
构造预览结果中的文件项,不访问真实存储。
|
||||||
|
"""
|
||||||
|
return FileItem(
|
||||||
|
storage=storage,
|
||||||
|
path=path.as_posix(),
|
||||||
|
name=path.name,
|
||||||
|
basename=path.stem,
|
||||||
|
type=item_type,
|
||||||
|
extension=path.suffix.lstrip(".") if item_type == "file" else None,
|
||||||
|
size=size if item_type == "file" else None,
|
||||||
|
)
|
||||||
|
|
||||||
def transfer_media(
|
def transfer_media(
|
||||||
self,
|
self,
|
||||||
fileitem: FileItem,
|
fileitem: FileItem,
|
||||||
@@ -161,12 +181,10 @@ class TransHandler:
|
|||||||
else:
|
else:
|
||||||
new_path = target_path / fileitem.name
|
new_path = target_path / fileitem.name
|
||||||
if preview:
|
if preview:
|
||||||
preview_diritem = FileItem(
|
preview_diritem = self.__build_preview_item(
|
||||||
storage=target_storage,
|
storage=target_storage,
|
||||||
path=new_path.as_posix(),
|
path=new_path,
|
||||||
name=new_path.name,
|
item_type="dir",
|
||||||
basename=new_path.stem,
|
|
||||||
type="dir",
|
|
||||||
)
|
)
|
||||||
self.__update_result(
|
self.__update_result(
|
||||||
result=result,
|
result=result,
|
||||||
@@ -291,27 +309,47 @@ class TransHandler:
|
|||||||
|
|
||||||
# 目标目录
|
# 目标目录
|
||||||
if preview:
|
if preview:
|
||||||
target_diritem = FileItem(
|
# 预览只做路径推算,不检查目录或同名文件冲突,避免目标存储探测触发真实整理。
|
||||||
|
target_diritem = self.__build_preview_item(
|
||||||
storage=target_storage,
|
storage=target_storage,
|
||||||
path=folder_path.as_posix(),
|
path=folder_path,
|
||||||
name=folder_path.name,
|
item_type="dir",
|
||||||
basename=folder_path.stem,
|
|
||||||
type="dir",
|
|
||||||
)
|
)
|
||||||
else:
|
target_item = self.__build_preview_item(
|
||||||
target_diritem = target_oper.get_folder(folder_path)
|
storage=target_storage,
|
||||||
if not target_diritem:
|
path=new_file,
|
||||||
logger.error(f"目标目录 {folder_path} 获取失败")
|
item_type="file",
|
||||||
self.__update_result(
|
size=fileitem.size,
|
||||||
result=result,
|
)
|
||||||
success=False,
|
self.__update_result(
|
||||||
message=f"目标目录 {folder_path} 获取失败",
|
result=result,
|
||||||
fileitem=fileitem,
|
success=True,
|
||||||
fail_list=[fileitem.path],
|
fileitem=fileitem,
|
||||||
transfer_type=transfer_type,
|
target_item=target_item,
|
||||||
need_notify=need_notify,
|
target_diritem=target_diritem,
|
||||||
)
|
file_list=[fileitem.path],
|
||||||
return result
|
file_list_new=[new_file.as_posix()],
|
||||||
|
file_count=1,
|
||||||
|
total_size=fileitem.size or 0,
|
||||||
|
need_scrape=need_scrape,
|
||||||
|
transfer_type=transfer_type,
|
||||||
|
need_notify=False,
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
target_diritem = target_oper.get_folder(folder_path)
|
||||||
|
if not target_diritem:
|
||||||
|
logger.error(f"目标目录 {folder_path} 获取失败")
|
||||||
|
self.__update_result(
|
||||||
|
result=result,
|
||||||
|
success=False,
|
||||||
|
message=f"目标目录 {folder_path} 获取失败",
|
||||||
|
fileitem=fileitem,
|
||||||
|
fail_list=[fileitem.path],
|
||||||
|
transfer_type=transfer_type,
|
||||||
|
need_notify=need_notify,
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
# 判断是否要覆盖,附加文件强制覆盖
|
# 判断是否要覆盖,附加文件强制覆盖
|
||||||
overflag = False
|
overflag = False
|
||||||
@@ -439,40 +477,11 @@ class TransHandler:
|
|||||||
logger.info(
|
logger.info(
|
||||||
f"当前整理覆盖模式设置为 {overwrite_mode},仅保留最新版本,正在删除已有版本文件 ..."
|
f"当前整理覆盖模式设置为 {overwrite_mode},仅保留最新版本,正在删除已有版本文件 ..."
|
||||||
)
|
)
|
||||||
if not preview:
|
self.__delete_version_files(target_oper, new_file)
|
||||||
self.__delete_version_files(target_oper, new_file)
|
|
||||||
else:
|
else:
|
||||||
# 附加文件 总是需要覆盖
|
# 附加文件 总是需要覆盖
|
||||||
overflag = True
|
overflag = True
|
||||||
|
|
||||||
if preview:
|
|
||||||
target_item = target_oper.get_item(new_file)
|
|
||||||
if not target_item:
|
|
||||||
target_item = FileItem(
|
|
||||||
storage=target_storage,
|
|
||||||
path=new_file.as_posix(),
|
|
||||||
name=new_file.name,
|
|
||||||
basename=new_file.stem,
|
|
||||||
type="file",
|
|
||||||
extension=new_file.suffix.lstrip("."),
|
|
||||||
size=fileitem.size,
|
|
||||||
)
|
|
||||||
self.__update_result(
|
|
||||||
result=result,
|
|
||||||
success=True,
|
|
||||||
fileitem=fileitem,
|
|
||||||
target_item=target_item,
|
|
||||||
target_diritem=target_diritem,
|
|
||||||
file_list=[fileitem.path],
|
|
||||||
file_list_new=[new_file.as_posix()],
|
|
||||||
file_count=1,
|
|
||||||
total_size=fileitem.size or 0,
|
|
||||||
need_scrape=need_scrape,
|
|
||||||
transfer_type=transfer_type,
|
|
||||||
need_notify=False,
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
|
|
||||||
# 整理文件
|
# 整理文件
|
||||||
new_item, err_msg = self.__transfer_file(
|
new_item, err_msg = self.__transfer_file(
|
||||||
fileitem=fileitem,
|
fileitem=fileitem,
|
||||||
|
|||||||
@@ -24,6 +24,21 @@ else
|
|||||||
exit 127
|
exit 127
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
has_environment_option() {
|
||||||
|
while [ "$#" -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
-p|--python|--python=*|-p*|--system)
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
--)
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
case "${COMMAND_NAME}" in
|
case "${COMMAND_NAME}" in
|
||||||
pip|pip3|pip3.*)
|
pip|pip3|pip3.*)
|
||||||
if [ "$#" -eq 0 ]; then
|
if [ "$#" -eq 0 ]; then
|
||||||
@@ -38,6 +53,14 @@ case "${COMMAND_NAME}" in
|
|||||||
shift
|
shift
|
||||||
exec "${UV_BIN}" help pip "$@"
|
exec "${UV_BIN}" help pip "$@"
|
||||||
;;
|
;;
|
||||||
|
check)
|
||||||
|
if [ -x "${SCRIPT_DIR}/python" ] && ! has_environment_option "$@"; then
|
||||||
|
# uv 不会仅凭 pip 软链接位置锁定 venv,显式绑定当前运行态解释器。
|
||||||
|
shift
|
||||||
|
exec "${UV_BIN}" pip check --python "${SCRIPT_DIR}/python" "$@"
|
||||||
|
fi
|
||||||
|
exec "${UV_BIN}" pip "$@"
|
||||||
|
;;
|
||||||
*)
|
*)
|
||||||
exec "${UV_BIN}" pip "$@"
|
exec "${UV_BIN}" pip "$@"
|
||||||
;;
|
;;
|
||||||
|
|||||||
@@ -267,6 +267,7 @@ class PluginHelperTest(TestCase):
|
|||||||
|
|
||||||
repair_commands = []
|
repair_commands = []
|
||||||
healthcheck_failed = False
|
healthcheck_failed = False
|
||||||
|
pip_check_cmd = PluginHelper._PluginHelper__build_runtime_pip_command("check")
|
||||||
|
|
||||||
def fake_execute(cmd):
|
def fake_execute(cmd):
|
||||||
nonlocal healthcheck_failed
|
nonlocal healthcheck_failed
|
||||||
@@ -275,7 +276,7 @@ class PluginHelperTest(TestCase):
|
|||||||
repair_commands.append(cmd)
|
repair_commands.append(cmd)
|
||||||
return True, "repaired"
|
return True, "repaired"
|
||||||
return True, "installed"
|
return True, "installed"
|
||||||
if cmd[:4] == [sys.executable, "-m", "pip", "check"]:
|
if cmd == pip_check_cmd:
|
||||||
if not healthcheck_failed:
|
if not healthcheck_failed:
|
||||||
healthcheck_failed = True
|
healthcheck_failed = True
|
||||||
return False, "broken"
|
return False, "broken"
|
||||||
|
|||||||
133
tests/test_transfer_preview.py
Normal file
133
tests/test_transfer_preview.py
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.core.context import MediaInfo
|
||||||
|
from app.core.metainfo import MetaInfoPath
|
||||||
|
from app.modules.filemanager import FileManagerModule
|
||||||
|
from app.schemas import FileItem, TransferDirectoryConf
|
||||||
|
from app.schemas.types import MediaType
|
||||||
|
|
||||||
|
|
||||||
|
class GuardedStorage:
|
||||||
|
"""
|
||||||
|
用于验证预览模式不会访问可能有副作用的存储整理接口。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get_folder(self, path: Path): # pragma: no cover - 被调用即失败
|
||||||
|
raise AssertionError(f"预览不应创建或获取目标目录:{path}")
|
||||||
|
|
||||||
|
def get_item(self, path: Path): # pragma: no cover - 被调用即失败
|
||||||
|
raise AssertionError(f"预览不应探测目标文件:{path}")
|
||||||
|
|
||||||
|
def copy(self, *args, **kwargs): # pragma: no cover - 被调用即失败
|
||||||
|
raise AssertionError("预览不应复制文件")
|
||||||
|
|
||||||
|
def move(self, *args, **kwargs): # pragma: no cover - 被调用即失败
|
||||||
|
raise AssertionError("预览不应移动文件")
|
||||||
|
|
||||||
|
def rename(self, *args, **kwargs): # pragma: no cover - 被调用即失败
|
||||||
|
raise AssertionError("预览不应重命名文件")
|
||||||
|
|
||||||
|
def delete(self, *args, **kwargs): # pragma: no cover - 被调用即失败
|
||||||
|
raise AssertionError("预览不应删除文件")
|
||||||
|
|
||||||
|
|
||||||
|
def test_cloud_storage_preview_only_calculates_target_path():
|
||||||
|
fileitem = FileItem(
|
||||||
|
storage="alist",
|
||||||
|
path="/downloads/Test.Show.S01E01.mkv",
|
||||||
|
type="file",
|
||||||
|
name="Test.Show.S01E01.mkv",
|
||||||
|
basename="Test.Show.S01E01",
|
||||||
|
extension="mkv",
|
||||||
|
size=1024,
|
||||||
|
)
|
||||||
|
meta = MetaInfoPath(Path(fileitem.path))
|
||||||
|
mediainfo = MediaInfo(
|
||||||
|
type=MediaType.TV,
|
||||||
|
title="Test Show",
|
||||||
|
year="2026",
|
||||||
|
tmdb_id=12345,
|
||||||
|
)
|
||||||
|
target_directory = TransferDirectoryConf(
|
||||||
|
name="cloud-library",
|
||||||
|
transfer_type="copy",
|
||||||
|
overwrite_mode="latest",
|
||||||
|
library_path="/library",
|
||||||
|
library_storage="alist",
|
||||||
|
renaming=True,
|
||||||
|
scraping=True,
|
||||||
|
notify=True,
|
||||||
|
)
|
||||||
|
guarded_storage = GuardedStorage()
|
||||||
|
|
||||||
|
transferinfo = FileManagerModule().transfer(
|
||||||
|
fileitem=fileitem,
|
||||||
|
meta=meta,
|
||||||
|
mediainfo=mediainfo,
|
||||||
|
target_directory=target_directory,
|
||||||
|
source_oper=guarded_storage,
|
||||||
|
target_oper=guarded_storage,
|
||||||
|
preview=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert transferinfo.success is True
|
||||||
|
assert transferinfo.need_notify is False
|
||||||
|
assert transferinfo.need_scrape is True
|
||||||
|
assert transferinfo.target_item.storage == "alist"
|
||||||
|
assert transferinfo.target_item.path.endswith(".mkv")
|
||||||
|
assert transferinfo.target_diritem.path.startswith("/library/")
|
||||||
|
assert transferinfo.file_list == [fileitem.path]
|
||||||
|
assert transferinfo.file_list_new == [transferinfo.target_item.path]
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_storage_preview_skips_target_conflict_checks(tmp_path):
|
||||||
|
source_file = tmp_path / "downloads" / "Test.Show.S01E02.mkv"
|
||||||
|
source_file.parent.mkdir(parents=True)
|
||||||
|
source_file.write_bytes(b"test video")
|
||||||
|
library_path = tmp_path / "library"
|
||||||
|
fileitem = FileItem(
|
||||||
|
storage="local",
|
||||||
|
path=source_file.as_posix(),
|
||||||
|
type="file",
|
||||||
|
name=source_file.name,
|
||||||
|
basename=source_file.stem,
|
||||||
|
extension="mkv",
|
||||||
|
size=source_file.stat().st_size,
|
||||||
|
)
|
||||||
|
meta = MetaInfoPath(source_file)
|
||||||
|
mediainfo = MediaInfo(
|
||||||
|
type=MediaType.TV,
|
||||||
|
title="Test Show",
|
||||||
|
year="2026",
|
||||||
|
tmdb_id=12345,
|
||||||
|
)
|
||||||
|
target_directory = TransferDirectoryConf(
|
||||||
|
name="local-library",
|
||||||
|
transfer_type="copy",
|
||||||
|
overwrite_mode="latest",
|
||||||
|
library_path=library_path.as_posix(),
|
||||||
|
library_storage="local",
|
||||||
|
renaming=True,
|
||||||
|
scraping=True,
|
||||||
|
notify=True,
|
||||||
|
)
|
||||||
|
guarded_storage = GuardedStorage()
|
||||||
|
|
||||||
|
transferinfo = FileManagerModule().transfer(
|
||||||
|
fileitem=fileitem,
|
||||||
|
meta=meta,
|
||||||
|
mediainfo=mediainfo,
|
||||||
|
target_directory=target_directory,
|
||||||
|
source_oper=guarded_storage,
|
||||||
|
target_oper=guarded_storage,
|
||||||
|
preview=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert transferinfo.success is True
|
||||||
|
assert transferinfo.need_notify is False
|
||||||
|
assert transferinfo.need_scrape is True
|
||||||
|
assert transferinfo.target_item.storage == "local"
|
||||||
|
assert transferinfo.target_item.path.startswith(library_path.as_posix())
|
||||||
|
assert transferinfo.target_item.path.endswith(".mkv")
|
||||||
|
assert transferinfo.file_list == [fileitem.path]
|
||||||
|
assert transferinfo.file_list_new == [transferinfo.target_item.path]
|
||||||
Reference in New Issue
Block a user