diff --git a/app/modules/qbittorrent/qbittorrent.py b/app/modules/qbittorrent/qbittorrent.py index efe24de6..a75b5527 100644 --- a/app/modules/qbittorrent/qbittorrent.py +++ b/app/modules/qbittorrent/qbittorrent.py @@ -153,6 +153,20 @@ class Qbittorrent: logger.error(f"同步下载Cookie出错:{str(err)}") return False + @staticmethod + def __enable_incomplete_file_suffix(qbt: Client) -> None: + """ + 开启未完成文件后缀,避免监控流程提前整理仍在下载的媒体文件。 + """ + try: + preferences = qbt.app_preferences() or {} + if isinstance(preferences, dict) and preferences.get("incomplete_files_ext") is True: + return + qbt.app_set_preferences({"incomplete_files_ext": True}) + logger.info("已开启 qbittorrent 未完成文件追加 .!qB 后缀") + except Exception as err: + logger.warning(f"开启 qbittorrent 未完成文件后缀失败:{str(err)}") + def is_inactive(self) -> bool: """ 判断是否需要重连 @@ -198,6 +212,7 @@ class Qbittorrent: stack_trace = "".join(traceback.format_exception(None, e, e.__traceback__))[:2000] logger.error(f"qbittorrent 登录失败:{str(e)}\n{stack_trace}") return None + self.__enable_incomplete_file_suffix(qbt) return qbt except Exception as err: logger.error(f"qbittorrent 连接出错:{str(err)}") diff --git a/app/modules/transmission/transmission.py b/app/modules/transmission/transmission.py index 0bb94fb7..29a0b99f 100755 --- a/app/modules/transmission/transmission.py +++ b/app/modules/transmission/transmission.py @@ -41,6 +41,25 @@ class Transmission: self._password = password self.trc = self.__login_transmission() + @staticmethod + def __enable_incomplete_file_suffix(trt: Client) -> None: + """ + 开启未完成文件后缀,避免监控流程提前整理仍在下载的媒体文件。 + """ + try: + session = trt.get_session() + getter = getattr(session, "get", None) + if callable(getter): + rename_partial_files = getter("rename-partial-files") + else: + rename_partial_files = getattr(session, "rename_partial_files", None) + if rename_partial_files is True: + return + trt.set_session(rename_partial_files=True) + logger.info("已开启 transmission 未完成文件追加 .part 后缀") + except Exception as err: + logger.warning(f"开启 transmission 未完成文件后缀失败:{str(err)}") + def __login_transmission(self) -> Optional[Client]: """ 连接transmission @@ -57,6 +76,7 @@ class Transmission: username=self._username, password=self._password, timeout=60) + self.__enable_incomplete_file_suffix(trt) return trt except Exception as err: logger.error(f"transmission 连接出错:{str(err)}") diff --git a/tests/test_qbittorrent_compat.py b/tests/test_qbittorrent_compat.py index 95da9492..8028c6ce 100644 --- a/tests/test_qbittorrent_compat.py +++ b/tests/test_qbittorrent_compat.py @@ -208,6 +208,32 @@ class TestQbittorrentCompat(unittest.TestCase): {"Authorization": "Bearer secret-token"}, ) + def test_login_enables_incomplete_file_suffix(self): + """ + 登录成功后应开启未完成文件后缀,避免下载中的媒体文件被提前整理。 + """ + fake_client = MagicMock() + fake_client.app_preferences.return_value = {"incomplete_files_ext": False} + + with patch.object(qbittorrent_module.qbittorrentapi, "Client", return_value=fake_client): + downloader = Qbittorrent(host="http://127.0.0.1", port=8080, username="admin", password="adminadmin") + + self.assertIs(downloader.qbc, fake_client) + fake_client.app_set_preferences.assert_called_once_with({"incomplete_files_ext": True}) + + def test_login_skips_incomplete_file_suffix_when_already_enabled(self): + """ + 远端已开启未完成文件后缀时不重复写入全局偏好。 + """ + fake_client = MagicMock() + fake_client.app_preferences.return_value = {"incomplete_files_ext": True} + + with patch.object(qbittorrent_module.qbittorrentapi, "Client", return_value=fake_client): + downloader = Qbittorrent(host="http://127.0.0.1", port=8080, username="admin", password="adminadmin") + + self.assertIs(downloader.qbc, fake_client) + fake_client.app_set_preferences.assert_not_called() + def test_add_torrent_accepts_structured_success_response(self): fake_client = MagicMock() fake_client.torrents_add.return_value = { diff --git a/tests/test_transmission_compat.py b/tests/test_transmission_compat.py new file mode 100644 index 00000000..a24ddba5 --- /dev/null +++ b/tests/test_transmission_compat.py @@ -0,0 +1,125 @@ +import importlib.util +import sys +import types +import unittest +from pathlib import Path +from unittest.mock import MagicMock, patch + + +def _load_transmission_client_module(): + """ + 使用轻量桩加载 Transmission 客户端封装,避免测试依赖完整应用启动。 + """ + repo_root = Path(__file__).resolve().parents[1] + + app_module = types.ModuleType("app") + app_module.__path__ = [] + log_module = types.ModuleType("app.log") + utils_module = types.ModuleType("app.utils") + utils_module.__path__ = [] + url_module = types.ModuleType("app.utils.url") + transmission_rpc_module = types.ModuleType("transmission_rpc") + transmission_rpc_session_module = types.ModuleType("transmission_rpc.session") + + class _Logger: + """ + 测试日志桩,仅提供被客户端封装调用的方法。 + """ + def info(self, *_args, **_kwargs): + """ + 忽略信息日志。 + """ + pass + + def warning(self, *_args, **_kwargs): + """ + 忽略警告日志。 + """ + pass + + def error(self, *_args, **_kwargs): + """ + 忽略错误日志。 + """ + pass + + class _UrlUtils: + """ + 测试 URL 工具桩,满足按 URL 配置下载器时的解析接口。 + """ + @staticmethod + def parse_url_params(url): + """ + 返回固定的 Transmission 连接参数。 + """ + return "http", url, 9091, "" + + log_module.logger = _Logger() + url_module.UrlUtils = _UrlUtils + transmission_rpc_module.Client = object + transmission_rpc_module.Torrent = object + transmission_rpc_module.File = object + transmission_rpc_session_module.SessionStats = object + transmission_rpc_session_module.Session = object + + app_module.log = log_module + app_module.utils = utils_module + utils_module.url = url_module + + stub_modules = { + "app": app_module, + "app.log": log_module, + "app.utils": utils_module, + "app.utils.url": url_module, + "transmission_rpc": transmission_rpc_module, + "transmission_rpc.session": transmission_rpc_session_module, + } + + transmission_path = repo_root / "app" / "modules" / "transmission" / "transmission.py" + transmission_spec = importlib.util.spec_from_file_location( + "app.modules.transmission.transmission", + transmission_path, + ) + transmission_module = importlib.util.module_from_spec(transmission_spec) + assert transmission_spec and transmission_spec.loader + + with patch.dict(sys.modules, stub_modules): + transmission_spec.loader.exec_module(transmission_module) + + return transmission_module + + +transmission_module = _load_transmission_client_module() +Transmission = transmission_module.Transmission + + +class TestTransmissionCompat(unittest.TestCase): + def test_login_enables_incomplete_file_suffix(self): + """ + 登录成功后应开启未完成文件后缀,避免下载中的媒体文件被提前整理。 + """ + fake_client = MagicMock() + fake_client.get_session.return_value = {"rename-partial-files": False} + + with patch.object(transmission_module.transmission_rpc, "Client", return_value=fake_client): + downloader = Transmission(host="127.0.0.1", port=9091) + + self.assertIs(downloader.trc, fake_client) + fake_client.set_session.assert_called_once_with(rename_partial_files=True) + + def test_login_skips_incomplete_file_suffix_when_already_enabled(self): + """ + 远端已开启未完成文件后缀时不重复写入全局会话配置。 + """ + fake_client = MagicMock() + fake_client.get_session.return_value = types.SimpleNamespace(rename_partial_files=True) + + with patch.object(transmission_module.transmission_rpc, "Client", return_value=fake_client): + downloader = Transmission(host="127.0.0.1", port=9091) + + self.assertIs(downloader.trc, fake_client) + fake_client.set_session.assert_not_called() + + +if __name__ == "__main__": + unittest.main()