From 303e7ee16e5803a8d67a266e63ca440b205ba5ef Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sat, 13 Jun 2026 08:43:37 +0800 Subject: [PATCH] feat: add downloader incomplete suffix toggles --- app/modules/qbittorrent/qbittorrent.py | 17 +- app/modules/transmission/transmission.py | 19 +- tests/test_qbittorrent_compat.py | 348 ++++++++++++----------- tests/test_transmission_compat.py | 57 ++-- 4 files changed, 240 insertions(+), 201 deletions(-) diff --git a/app/modules/qbittorrent/qbittorrent.py b/app/modules/qbittorrent/qbittorrent.py index a75b5527..11f02a2e 100644 --- a/app/modules/qbittorrent/qbittorrent.py +++ b/app/modules/qbittorrent/qbittorrent.py @@ -23,6 +23,7 @@ class Qbittorrent: apikey: Optional[str] = None, category: Optional[bool] = False, sequentail: Optional[bool] = False, force_resume: Optional[bool] = False, first_last_piece=False, + incomplete_files_ext: Optional[bool] = True, **kwargs): """ 若不设置参数,则创建配置文件设置的下载器 @@ -42,6 +43,7 @@ class Qbittorrent: self._sequentail = sequentail self._force_resume = force_resume self._first_last_piece = first_last_piece + self._incomplete_files_ext = incomplete_files_ext self.qbc = self.__login_qbittorrent() @staticmethod @@ -154,18 +156,19 @@ class Qbittorrent: return False @staticmethod - def __enable_incomplete_file_suffix(qbt: Client) -> None: + def __sync_incomplete_file_suffix(qbt: Client, enabled: bool) -> None: """ - 开启未完成文件后缀,避免监控流程提前整理仍在下载的媒体文件。 + 同步未完成文件后缀开关,避免监控流程提前整理仍在下载的媒体文件。 """ try: preferences = qbt.app_preferences() or {} - if isinstance(preferences, dict) and preferences.get("incomplete_files_ext") is True: + if isinstance(preferences, dict) and preferences.get("incomplete_files_ext") is enabled: return - qbt.app_set_preferences({"incomplete_files_ext": True}) - logger.info("已开启 qbittorrent 未完成文件追加 .!qB 后缀") + qbt.app_set_preferences({"incomplete_files_ext": enabled}) + action = "开启" if enabled else "关闭" + logger.info(f"已{action} qbittorrent 未完成文件追加 .!qB 后缀") except Exception as err: - logger.warning(f"开启 qbittorrent 未完成文件后缀失败:{str(err)}") + logger.warning(f"同步 qbittorrent 未完成文件后缀失败:{str(err)}") def is_inactive(self) -> bool: """ @@ -212,7 +215,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) + self.__sync_incomplete_file_suffix(qbt, enabled=bool(self._incomplete_files_ext)) 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 29a0b99f..21970de3 100755 --- a/app/modules/transmission/transmission.py +++ b/app/modules/transmission/transmission.py @@ -20,7 +20,8 @@ class Transmission: "error", "errorString", "doneDate", "queuePosition", "activityDate", "trackers"] def __init__(self, host: Optional[str] = None, port: Optional[int] = None, - username: Optional[str] = None, password: Optional[str] = None, **kwargs): + username: Optional[str] = None, password: Optional[str] = None, + rename_partial_files: Optional[bool] = True, **kwargs): """ 若不设置参数,则创建配置文件设置的下载器 """ @@ -39,12 +40,13 @@ class Transmission: return self._username = username self._password = password + self._rename_partial_files = rename_partial_files self.trc = self.__login_transmission() @staticmethod - def __enable_incomplete_file_suffix(trt: Client) -> None: + def __sync_incomplete_file_suffix(trt: Client, enabled: bool) -> None: """ - 开启未完成文件后缀,避免监控流程提前整理仍在下载的媒体文件。 + 同步未完成文件后缀开关,避免监控流程提前整理仍在下载的媒体文件。 """ try: session = trt.get_session() @@ -53,12 +55,13 @@ class Transmission: rename_partial_files = getter("rename-partial-files") else: rename_partial_files = getattr(session, "rename_partial_files", None) - if rename_partial_files is True: + if rename_partial_files is enabled: return - trt.set_session(rename_partial_files=True) - logger.info("已开启 transmission 未完成文件追加 .part 后缀") + trt.set_session(rename_partial_files=enabled) + action = "开启" if enabled else "关闭" + logger.info(f"已{action} transmission 未完成文件追加 .part 后缀") except Exception as err: - logger.warning(f"开启 transmission 未完成文件后缀失败:{str(err)}") + logger.warning(f"同步 transmission 未完成文件后缀失败:{str(err)}") def __login_transmission(self) -> Optional[Client]: """ @@ -76,7 +79,7 @@ class Transmission: username=self._username, password=self._password, timeout=60) - self.__enable_incomplete_file_suffix(trt) + self.__sync_incomplete_file_suffix(trt, enabled=bool(self._rename_partial_files)) 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 8028c6ce..04f2e075 100644 --- a/tests/test_qbittorrent_compat.py +++ b/tests/test_qbittorrent_compat.py @@ -1,7 +1,6 @@ import importlib.util import sys import types -import unittest from enum import Enum from pathlib import Path from unittest.mock import MagicMock, patch @@ -192,187 +191,208 @@ Qbittorrent = qbittorrent_module.Qbittorrent QbittorrentModule = qbittorrent_package_module.QbittorrentModule -class TestQbittorrentCompat(unittest.TestCase): - def test_login_uses_api_key_header_without_auth_login(self): - fake_client = MagicMock() - fake_client.app_version.return_value = "v5.2.0" +def test_login_uses_api_key_header_without_auth_login(): + """API Key 登录时应使用 Bearer Header 并跳过用户名密码登录。""" + fake_client = MagicMock() + fake_client.app_version.return_value = "v5.2.0" - with patch.object(qbittorrent_module.qbittorrentapi, "Client", return_value=fake_client) as client_cls: - downloader = Qbittorrent(host="http://127.0.0.1", port=8080, apikey="secret-token") + with patch.object(qbittorrent_module.qbittorrentapi, "Client", return_value=fake_client) as client_cls: + downloader = Qbittorrent(host="http://127.0.0.1", port=8080, apikey="secret-token") - self.assertIs(downloader.qbc, fake_client) - fake_client.auth_log_in.assert_not_called() - fake_client.app_version.assert_called_once_with() - self.assertEqual( - client_cls.call_args.kwargs["EXTRA_HEADERS"], - {"Authorization": "Bearer secret-token"}, + assert downloader.qbc is fake_client + fake_client.auth_log_in.assert_not_called() + fake_client.app_version.assert_called_once_with() + assert client_cls.call_args.kwargs["EXTRA_HEADERS"] == {"Authorization": "Bearer secret-token"} + + +def test_login_enables_incomplete_file_suffix_by_default(): + """ + 登录成功后默认开启未完成文件后缀,避免下载中的媒体文件被提前整理。 + """ + 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") + + assert downloader.qbc is fake_client + fake_client.app_set_preferences.assert_called_once_with({"incomplete_files_ext": True}) + + +def test_login_disables_incomplete_file_suffix_when_configured(): + """ + 用户关闭配置后应同步关闭 qBittorrent 未完成文件后缀偏好。 + """ + 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", + incomplete_files_ext=False, ) - def test_login_enables_incomplete_file_suffix(self): - """ - 登录成功后应开启未完成文件后缀,避免下载中的媒体文件被提前整理。 - """ - fake_client = MagicMock() - fake_client.app_preferences.return_value = {"incomplete_files_ext": False} + assert downloader.qbc is fake_client + fake_client.app_set_preferences.assert_called_once_with({"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_matches(): + """ + 远端未完成文件后缀状态已匹配配置时不重复写入全局偏好。 + """ + fake_client = MagicMock() + fake_client.app_preferences.return_value = {"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") - 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") + assert downloader.qbc is fake_client + fake_client.app_set_preferences.assert_not_called() - 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 = { - "success_count": 1, - "failure_count": 0, - "pending_count": 0, - "added_torrent_ids": ["abc123"], +def test_add_torrent_accepts_structured_success_response(): + """新版 qBittorrent API 结构化成功响应应返回新增种子 ID。""" + fake_client = MagicMock() + fake_client.torrents_add.return_value = { + "success_count": 1, + "failure_count": 0, + "pending_count": 0, + "added_torrent_ids": ["abc123"], + } + + with patch.object(Qbittorrent, "_Qbittorrent__login_qbittorrent", return_value=fake_client): + downloader = Qbittorrent(host="http://127.0.0.1", port=8080, username="admin", password="adminadmin") + + success, added_torrent_ids = downloader.add_torrent(content="https://example.com/test.torrent") + assert success + assert added_torrent_ids == ["abc123"] + + +def test_add_torrent_accepts_pending_success_response_without_ids(): + """新版 qBittorrent API 待处理成功响应没有 ID 时仍应视为添加成功。""" + fake_client = MagicMock() + fake_client.torrents_add.return_value = { + "success_count": 0, + "failure_count": 0, + "pending_count": 1, + "added_torrent_ids": [], + } + + with patch.object(Qbittorrent, "_Qbittorrent__login_qbittorrent", return_value=fake_client): + downloader = Qbittorrent(host="http://127.0.0.1", port=8080, username="admin", password="adminadmin") + + success, added_torrent_ids = downloader.add_torrent(content="https://example.com/test.torrent") + assert success + assert added_torrent_ids == [] + + +def test_add_torrent_uses_cookie_api_for_qbittorrent_52(): + """qBittorrent 5.2 对应 Web API 应通过 Cookie API 同步站点 Cookie。""" + fake_client = MagicMock() + fake_client.app_web_api_version.return_value = "2.11.3" + fake_client.app_cookies.return_value = [ + { + "domain": "old.example.com", + "path": "/", + "name": "old", + "value": "cookie", } + ] + fake_client.torrents_add.return_value = "Ok." - with patch.object(Qbittorrent, "_Qbittorrent__login_qbittorrent", return_value=fake_client): - downloader = Qbittorrent(host="http://127.0.0.1", port=8080, username="admin", password="adminadmin") + with patch.object(Qbittorrent, "_Qbittorrent__login_qbittorrent", return_value=fake_client): + downloader = Qbittorrent(host="http://127.0.0.1", port=8080, username="admin", password="adminadmin") - success, added_torrent_ids = downloader.add_torrent(content="https://example.com/test.torrent") - self.assertTrue(success) - self.assertEqual(added_torrent_ids, ["abc123"]) - - def test_add_torrent_accepts_pending_success_response_without_ids(self): - fake_client = MagicMock() - fake_client.torrents_add.return_value = { - "success_count": 0, - "failure_count": 0, - "pending_count": 1, - "added_torrent_ids": [], - } - - with patch.object(Qbittorrent, "_Qbittorrent__login_qbittorrent", return_value=fake_client): - downloader = Qbittorrent(host="http://127.0.0.1", port=8080, username="admin", password="adminadmin") - - success, added_torrent_ids = downloader.add_torrent(content="https://example.com/test.torrent") - self.assertTrue(success) - self.assertEqual(added_torrent_ids, []) - - def test_add_torrent_uses_cookie_api_for_qbittorrent_52(self): - fake_client = MagicMock() - fake_client.app_web_api_version.return_value = "2.11.3" - fake_client.app_cookies.return_value = [ - { - "domain": "old.example.com", - "path": "/", - "name": "old", - "value": "cookie", - } - ] - fake_client.torrents_add.return_value = "Ok." - - with patch.object(Qbittorrent, "_Qbittorrent__login_qbittorrent", return_value=fake_client): - downloader = Qbittorrent(host="http://127.0.0.1", port=8080, username="admin", password="adminadmin") - - success, added_torrent_ids = downloader.add_torrent( - content="https://tracker.example.com/download?id=1", - cookie="uid=1; passkey=abc", - ) - self.assertTrue(success) - self.assertEqual(added_torrent_ids, []) - set_cookie_call = fake_client.app_set_cookies.call_args.kwargs["cookies"] - self.assertIn( - { - "domain": "tracker.example.com", - "path": "/", - "name": "uid", - "value": "1", - }, - set_cookie_call, - ) - self.assertIn( - { - "domain": "tracker.example.com", - "path": "/", - "name": "passkey", - "value": "abc", - }, - set_cookie_call, - ) - self.assertIsNone(fake_client.torrents_add.call_args.kwargs["cookie"]) - - def test_add_torrent_keeps_legacy_cookie_param_for_old_webapi(self): - fake_client = MagicMock() - fake_client.app_web_api_version.return_value = "2.11.2" - fake_client.torrents_add.return_value = "Ok." - - with patch.object(Qbittorrent, "_Qbittorrent__login_qbittorrent", return_value=fake_client): - downloader = Qbittorrent(host="http://127.0.0.1", port=8080, username="admin", password="adminadmin") - - success, added_torrent_ids = downloader.add_torrent( - content="https://tracker.example.com/download?id=1", - cookie="uid=1", - ) - self.assertTrue(success) - self.assertEqual(added_torrent_ids, []) - fake_client.app_set_cookies.assert_not_called() - self.assertEqual(fake_client.torrents_add.call_args.kwargs["cookie"], "uid=1") + success, added_torrent_ids = downloader.add_torrent( + content="https://tracker.example.com/download?id=1", + cookie="uid=1; passkey=abc", + ) + assert success + assert added_torrent_ids == [] + set_cookie_call = fake_client.app_set_cookies.call_args.kwargs["cookies"] + assert { + "domain": "tracker.example.com", + "path": "/", + "name": "uid", + "value": "1", + } in set_cookie_call + assert { + "domain": "tracker.example.com", + "path": "/", + "name": "passkey", + "value": "abc", + } in set_cookie_call + assert fake_client.torrents_add.call_args.kwargs["cookie"] is None -class TestQbittorrentModuleCompat(unittest.TestCase): - @staticmethod - def _build_module(server): - module = QbittorrentModule.__new__(QbittorrentModule) - module.get_instance = MagicMock(return_value=server) - module.normalize_path = MagicMock(side_effect=lambda path, _downloader: path) - module.get_default_config_name = MagicMock(return_value="default-qb") - return module +def test_add_torrent_keeps_legacy_cookie_param_for_old_webapi(): + """旧版 qBittorrent Web API 不支持 Cookie API 时保留添加种子 Cookie 参数。""" + fake_client = MagicMock() + fake_client.app_web_api_version.return_value = "2.11.2" + fake_client.torrents_add.return_value = "Ok." - def test_download_prefers_added_torrent_ids_before_tag_lookup(self): - fake_server = MagicMock() - fake_server.add_torrent.return_value = (True, ["abc123"]) - fake_server.get_content_layout.return_value = "Original" - fake_server.is_force_resume.return_value = False + with patch.object(Qbittorrent, "_Qbittorrent__login_qbittorrent", return_value=fake_client): + downloader = Qbittorrent(host="http://127.0.0.1", port=8080, username="admin", password="adminadmin") - module = self._build_module(fake_server) - result = module.download( - content="magnet:?xt=urn:btih:123", - download_dir=Path("/downloads"), - cookie="", - downloader="qb", - ) + success, added_torrent_ids = downloader.add_torrent( + content="https://tracker.example.com/download?id=1", + cookie="uid=1", + ) + assert success + assert added_torrent_ids == [] + fake_client.app_set_cookies.assert_not_called() + assert fake_client.torrents_add.call_args.kwargs["cookie"] == "uid=1" - self.assertEqual(result, ("qb", "abc123", "Original", "添加下载成功")) - fake_server.delete_torrents_tag.assert_called_once_with("abc123", "tmp-tag-01") - fake_server.get_torrent_id_by_tag.assert_not_called() - self.assertEqual( - fake_server.add_torrent.call_args.kwargs["tag"], - ["tmp-tag-01", "moviepilot-tag"], - ) - def test_download_falls_back_to_tag_lookup_when_added_ids_missing(self): - fake_server = MagicMock() - fake_server.add_torrent.return_value = (True, []) - fake_server.get_content_layout.return_value = "Original" - fake_server.get_torrent_id_by_tag.return_value = "def456" - fake_server.is_force_resume.return_value = False +def _build_module(server): + """构造仅包含下载所需方法的 QbittorrentModule 测试实例。""" + module = QbittorrentModule.__new__(QbittorrentModule) + module.get_instance = MagicMock(return_value=server) + module.normalize_path = MagicMock(side_effect=lambda path, _downloader: path) + module.get_default_config_name = MagicMock(return_value="default-qb") + return module - module = self._build_module(fake_server) - result = module.download( - content="magnet:?xt=urn:btih:456", - download_dir=Path("/downloads"), - cookie="", - downloader="qb", - ) - self.assertEqual(result, ("qb", "def456", "Original", "添加下载成功")) - fake_server.delete_torrents_tag.assert_not_called() - fake_server.get_torrent_id_by_tag.assert_called_once_with(tags="tmp-tag-01") +def test_download_prefers_added_torrent_ids_before_tag_lookup(): + """添加任务响应包含种子 ID 时应优先使用响应值。""" + fake_server = MagicMock() + fake_server.add_torrent.return_value = (True, ["abc123"]) + fake_server.get_content_layout.return_value = "Original" + fake_server.is_force_resume.return_value = False + + module = _build_module(fake_server) + result = module.download( + content="magnet:?xt=urn:btih:123", + download_dir=Path("/downloads"), + cookie="", + downloader="qb", + ) + + assert result == ("qb", "abc123", "Original", "添加下载成功") + fake_server.delete_torrents_tag.assert_called_once_with("abc123", "tmp-tag-01") + fake_server.get_torrent_id_by_tag.assert_not_called() + assert fake_server.add_torrent.call_args.kwargs["tag"] == ["tmp-tag-01", "moviepilot-tag"] + + +def test_download_falls_back_to_tag_lookup_when_added_ids_missing(): + """添加任务响应缺少种子 ID 时应回退到临时标签查询。""" + fake_server = MagicMock() + fake_server.add_torrent.return_value = (True, []) + fake_server.get_content_layout.return_value = "Original" + fake_server.get_torrent_id_by_tag.return_value = "def456" + fake_server.is_force_resume.return_value = False + + module = _build_module(fake_server) + result = module.download( + content="magnet:?xt=urn:btih:456", + download_dir=Path("/downloads"), + cookie="", + downloader="qb", + ) + + assert result == ("qb", "def456", "Original", "添加下载成功") + fake_server.delete_torrents_tag.assert_not_called() + fake_server.get_torrent_id_by_tag.assert_called_once_with(tags="tmp-tag-01") diff --git a/tests/test_transmission_compat.py b/tests/test_transmission_compat.py index 394a3f40..603c1aa0 100644 --- a/tests/test_transmission_compat.py +++ b/tests/test_transmission_compat.py @@ -1,7 +1,6 @@ import importlib.util import sys import types -import unittest from pathlib import Path from unittest.mock import MagicMock, patch @@ -93,29 +92,43 @@ 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} +def test_login_enables_incomplete_file_suffix_by_default(): + """ + 登录成功后默认开启未完成文件后缀,避免下载中的媒体文件被提前整理。 + """ + 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) + 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) + assert downloader.trc is 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) +def test_login_disables_incomplete_file_suffix_when_configured(): + """ + 用户关闭配置后应同步关闭 Transmission 未完成文件后缀偏好。 + """ + fake_client = MagicMock() + fake_client.get_session.return_value = types.SimpleNamespace(rename_partial_files=True) - self.assertIs(downloader.trc, fake_client) - fake_client.set_session.assert_not_called() + with patch.object(transmission_module.transmission_rpc, "Client", return_value=fake_client): + downloader = Transmission(host="127.0.0.1", port=9091, rename_partial_files=False) + + assert downloader.trc is fake_client + fake_client.set_session.assert_called_once_with(rename_partial_files=False) + + +def test_login_skips_incomplete_file_suffix_when_already_matches(): + """ + 远端未完成文件后缀状态已匹配配置时不重复写入全局会话配置。 + """ + 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) + + assert downloader.trc is fake_client + fake_client.set_session.assert_not_called()