diff --git a/app/modules/qbittorrent/__init__.py b/app/modules/qbittorrent/__init__.py index c335a4a5..d004e6e7 100644 --- a/app/modules/qbittorrent/__init__.py +++ b/app/modules/qbittorrent/__init__.py @@ -148,7 +148,7 @@ class QbittorrentModule(_ModuleBase, _DownloaderBase[Qbittorrent]): # 如果要选择文件则先暂停 is_paused = True if episodes else False # 添加任务 - state = server.add_torrent( + state, added_torrent_ids = server.add_torrent( content=content, download_dir=self.normalize_path(download_dir, downloader), is_paused=is_paused, @@ -188,7 +188,11 @@ class QbittorrentModule(_ModuleBase, _DownloaderBase[Qbittorrent]): return None, None, None, f"添加种子任务失败:{content}" else: # 获取种子Hash - torrent_hash = server.get_torrent_id_by_tag(tags=tag) + torrent_hash = next(iter(added_torrent_ids), None) + if torrent_hash: + server.delete_torrents_tag(torrent_hash, tag) + else: + torrent_hash = server.get_torrent_id_by_tag(tags=tag) if not torrent_hash: return None, None, None, f"下载任务添加成功,但获取Qbittorrent任务信息失败:{content}" else: diff --git a/app/modules/qbittorrent/qbittorrent.py b/app/modules/qbittorrent/qbittorrent.py index f6e91b59..f61a7b74 100644 --- a/app/modules/qbittorrent/qbittorrent.py +++ b/app/modules/qbittorrent/qbittorrent.py @@ -1,8 +1,11 @@ import time import traceback -from typing import Optional, Union, Tuple, List +from http.cookies import SimpleCookie +from typing import Any, Optional, Union, Tuple, List +from urllib.parse import urlparse import qbittorrentapi +from packaging.version import InvalidVersion, Version from qbittorrentapi import TorrentDictionary, TorrentFilesList from qbittorrentapi.client import Client from qbittorrentapi.transfer import TransferInfoDictionary @@ -17,6 +20,7 @@ class Qbittorrent: """ def __init__(self, host: Optional[str] = None, port: int = None, username: Optional[str] = None, password: Optional[str] = None, + apikey: Optional[str] = None, category: Optional[bool] = False, sequentail: Optional[bool] = False, force_resume: Optional[bool] = False, first_last_piece=False, **kwargs): @@ -33,12 +37,124 @@ class Qbittorrent: return self._username = username self._password = password + self._apikey = str(apikey or "").strip() or None self._category = category self._sequentail = sequentail self._force_resume = force_resume self._first_last_piece = first_last_piece self.qbc = self.__login_qbittorrent() + @staticmethod + def __get_mapping_value(data: Any, key: str) -> Any: + if data is None: + return None + if isinstance(data, dict): + return data.get(key) + getter = getattr(data, "get", None) + if callable(getter): + try: + return getter(key) + except Exception: + pass + return getattr(data, key, None) + + @staticmethod + def __normalize_cookie(cookie: Any) -> dict: + result = {} + for key in ("domain", "path", "name", "value", "expirationDate"): + value = Qbittorrent.__get_mapping_value(cookie, key) + if value not in (None, ""): + result[key] = value + return result + + @staticmethod + def __cookie_key(cookie: dict) -> Optional[tuple]: + name = cookie.get("name") + domain = cookie.get("domain") + path = cookie.get("path") or "/" + if not name or not domain: + return None + return domain, path, name + + @staticmethod + def __build_site_cookies(url: str, cookie_header: str) -> List[dict]: + domain = urlparse(url).hostname + if not domain: + return [] + + raw_cookies = SimpleCookie() + raw_cookies.load(cookie_header) + return [ + { + "domain": domain, + "path": "/", + "name": morsel.key, + "value": morsel.value, + } + for morsel in raw_cookies.values() + ] + + @staticmethod + def __parse_add_torrent_response(response: Any) -> Tuple[bool, List[str]]: + if not response: + return False, [] + if isinstance(response, str): + return "Ok" in response, [] + + success_count = Qbittorrent.__get_mapping_value(response, "success_count") or 0 + pending_count = Qbittorrent.__get_mapping_value(response, "pending_count") or 0 + added_torrent_ids = Qbittorrent.__get_mapping_value(response, "added_torrent_ids") or [] + if not isinstance(added_torrent_ids, list): + added_torrent_ids = list(added_torrent_ids) + added_torrent_ids = [str(torrent_id) for torrent_id in added_torrent_ids if torrent_id] + if added_torrent_ids: + return True, added_torrent_ids + if success_count or pending_count: + return True, [] + return "Ok" in str(response), [] + + def __use_api_key_auth(self) -> bool: + return bool(self._apikey) + + def __supports_cookie_api(self) -> bool: + if not self.qbc: + return False + try: + web_api_version = self.qbc.app_web_api_version() + return Version(str(web_api_version)) >= Version("2.11.3") + except (InvalidVersion, TypeError, ValueError): + return False + except Exception as err: + logger.warn(f"获取 qbittorrent Web API 版本失败,跳过 Cookie API 兼容:{err}") + return False + + def __sync_download_cookies(self, url: str, cookie_header: str) -> bool: + if not self.qbc or not url or not cookie_header or not self.__supports_cookie_api(): + return False + + try: + site_cookies = self.__build_site_cookies(url=url, cookie_header=cookie_header) + if not site_cookies: + return False + + merged_cookies = {} + for cookie in self.qbc.app_cookies() or []: + normalized = self.__normalize_cookie(cookie) + cookie_key = self.__cookie_key(normalized) + if cookie_key: + merged_cookies[cookie_key] = normalized + + for cookie in site_cookies: + cookie_key = self.__cookie_key(cookie) + if cookie_key: + merged_cookies[cookie_key] = cookie + + self.qbc.app_set_cookies(cookies=list(merged_cookies.values())) + return True + except Exception as err: + logger.error(f"同步下载Cookie出错:{str(err)}") + return False + def is_inactive(self) -> bool: """ 判断是否需要重连 @@ -67,14 +183,20 @@ class Qbittorrent: port=self._port, username=self._username, password=self._password, + EXTRA_HEADERS={"Authorization": f"Bearer {self._apikey}"} + if self.__use_api_key_auth() else None, VERIFY_WEBUI_CERTIFICATE=False, REQUESTS_ARGS={'timeout': (15, 60)}) try: - qbt.auth_log_in() - except (qbittorrentapi.LoginFailed, qbittorrentapi.Forbidden403Error) as e: - logger.error(f"qbittorrent 登录失败:{str(e).strip() or '请检查用户名和密码是否正确'}") - return None + if self.__use_api_key_auth(): + qbt.app_version() + else: + qbt.auth_log_in() except Exception as e: + if e.__class__.__name__ in {"LoginFailed", "Forbidden403Error", "Unauthorized401Error"}: + error_hint = "请检查 API Key 是否正确" if self.__use_api_key_auth() else "请检查用户名和密码是否正确" + logger.error(f"qbittorrent 登录失败:{str(e).strip() or error_hint}") + return None stack_trace = "".join(traceback.format_exception(None, e, e.__traceback__))[:2000] logger.error(f"qbittorrent 登录失败:{str(e)}\n{stack_trace}") return None @@ -241,7 +363,7 @@ class Qbittorrent: category: Optional[str] = None, cookie: Optional[str] = None, **kwargs - ) -> bool: + ) -> Tuple[bool, List[str]]: """ 添加种子 :param content: 种子urls或文件内容 @@ -251,10 +373,10 @@ class Qbittorrent: :param download_dir: 下载路径 :param cookie: 站点Cookie用于辅助下载种子 :param kwargs: 可选参数,如 ignore_category_check 以及 QB相关参数 - :return: bool + :return: 添加是否成功, 新版API返回的种子ID列表 """ if not self.qbc or not content: - return False + return False, [] # 下载内容 if isinstance(content, str): @@ -287,6 +409,11 @@ class Qbittorrent: is_auto = False category = None try: + cookie_to_use = cookie + if urls and cookie and not StringUtils.is_magnet_link(urls): + if self.__sync_download_cookies(url=urls, cookie_header=cookie): + cookie_to_use = None + # 添加下载 qbc_ret = self.qbc.torrents_add(urls=urls, torrent_files=torrent_files, @@ -296,13 +423,13 @@ class Qbittorrent: use_auto_torrent_management=is_auto, is_sequential_download=self._sequentail, is_first_last_piece_priority=self._first_last_piece, - cookie=cookie, + cookie=cookie_to_use, category=category, **kwargs) - return True if qbc_ret and str(qbc_ret).find("Ok") != -1 else False + return self.__parse_add_torrent_response(qbc_ret) except Exception as err: logger.error(f"添加种子出错:{str(err)}") - return False + return False, [] def start_torrents(self, ids: Union[str, list]) -> bool: """ diff --git a/requirements.in b/requirements.in index 8423f0ce..a095b549 100644 --- a/requirements.in +++ b/requirements.in @@ -28,7 +28,7 @@ APScheduler~=3.11.0 cryptography~=45.0.4 pytz~=2025.2 pycryptodome~=3.23.0 -qbittorrent-api==2025.5.0 +qbittorrent-api==2026.5.1 plexapi~=4.17.0 transmission-rpc~=4.3.0 Jinja2~=3.1.6 diff --git a/scripts/local_setup.py b/scripts/local_setup.py index 4e98138a..c71306e2 100644 --- a/scripts/local_setup.py +++ b/scripts/local_setup.py @@ -1312,8 +1312,9 @@ def _collect_downloader_config() -> Optional[dict[str, Any]]: config_name = _prompt_text("下载器名称", default=downloader_type) if downloader_type == "qbittorrent": host = _prompt_text("qBittorrent 地址", default="http://127.0.0.1:8080") - username = _prompt_text("qBittorrent 用户名", default="admin") - password = _prompt_text("qBittorrent 密码", secret=True) + apikey = _prompt_text("qBittorrent API Key(可选,5.2+ 推荐)", allow_empty=True, default="") + username = _prompt_text("qBittorrent 用户名", default="admin") if not apikey else "" + password = _prompt_text("qBittorrent 密码", secret=True, allow_empty=bool(apikey)) if not apikey else "" category = _prompt_yes_no("是否启用 qBittorrent 分类", default=False) return { "name": config_name, @@ -1322,6 +1323,7 @@ def _collect_downloader_config() -> Optional[dict[str, Any]]: "enabled": True, "config": { "host": host, + "apikey": apikey, "username": username, "password": password, "category": category, diff --git a/tests/test_qbittorrent_compat.py b/tests/test_qbittorrent_compat.py new file mode 100644 index 00000000..95da9492 --- /dev/null +++ b/tests/test_qbittorrent_compat.py @@ -0,0 +1,352 @@ +import importlib.util +import sys +import types +import unittest +from enum import Enum +from pathlib import Path +from unittest.mock import MagicMock, patch + + +def _load_qbittorrent_modules(): + repo_root = Path(__file__).resolve().parents[1] + + app_module = types.ModuleType("app") + app_module.__path__ = [] + core_module = types.ModuleType("app.core") + core_module.__path__ = [] + utils_module = types.ModuleType("app.utils") + utils_module.__path__ = [] + modules_module = types.ModuleType("app.modules") + modules_module.__path__ = [] + qbittorrent_package_module = types.ModuleType("app.modules.qbittorrent") + qbittorrent_package_module.__path__ = [] + log_module = types.ModuleType("app.log") + cache_module = types.ModuleType("app.core.cache") + config_module = types.ModuleType("app.core.config") + metainfo_module = types.ModuleType("app.core.metainfo") + schemas_module = types.ModuleType("app.schemas") + schema_types_module = types.ModuleType("app.schemas.types") + string_module = types.ModuleType("app.utils.string") + torrentool_module = types.ModuleType("torrentool") + torrentool_module.__path__ = [] + torrentool_torrent_module = types.ModuleType("torrentool.torrent") + qbittorrentapi_module = types.ModuleType("qbittorrentapi") + qbittorrentapi_client_module = types.ModuleType("qbittorrentapi.client") + qbittorrentapi_transfer_module = types.ModuleType("qbittorrentapi.transfer") + + class _Logger: + def info(self, *_args, **_kwargs): + pass + + def warn(self, *_args, **_kwargs): + pass + + def warning(self, *_args, **_kwargs): + pass + + def error(self, *_args, **_kwargs): + pass + + class _StringUtils: + @staticmethod + def get_domain_address(address, prefix=False): + return address, 8080 + + @staticmethod + def is_magnet_link(value): + if isinstance(value, bytes): + return value.startswith(b"magnet:") + return isinstance(value, str) and value.startswith("magnet:") + + @staticmethod + def generate_random_str(_length): + return "tmp-tag-01" + + @staticmethod + def str_filesize(value): + return str(value) + + @staticmethod + def str_secends(value): + return str(value) + + class _FileCache: + def get(self, *_args, **_kwargs): + return None + + class _MetaInfo: + def __init__(self, name): + self.name = name + self.year = None + self.season_episode = "" + self.episode_list = [] + + class _ModuleBase: + pass + + class _DownloaderBase: + def __class_getitem__(cls, _item): + return cls + + class _Torrent: + @staticmethod + def from_string(content): + return types.SimpleNamespace(name="test", total_size=len(content)) + + class TorrentStatus(Enum): + TRANSFER = "transfer" + DOWNLOADING = "downloading" + + class ModuleType(Enum): + Downloader = "Downloader" + + class DownloaderType(Enum): + Qbittorrent = "Qbittorrent" + + log_module.logger = _Logger() + cache_module.FileCache = _FileCache + config_module.settings = types.SimpleNamespace(TORRENT_TAG="moviepilot-tag") + metainfo_module.MetaInfo = _MetaInfo + schemas_module.DownloaderInfo = object + schemas_module.TransferTorrent = object + schemas_module.DownloadingTorrent = object + schema_types_module.TorrentStatus = TorrentStatus + schema_types_module.ModuleType = ModuleType + schema_types_module.DownloaderType = DownloaderType + string_module.StringUtils = _StringUtils + modules_module._ModuleBase = _ModuleBase + modules_module._DownloaderBase = _DownloaderBase + torrentool_torrent_module.Torrent = _Torrent + qbittorrentapi_module.TorrentDictionary = dict + qbittorrentapi_module.TorrentFilesList = list + qbittorrentapi_module.LoginFailed = type("LoginFailed", (Exception,), {}) + qbittorrentapi_module.Forbidden403Error = type("Forbidden403Error", (Exception,), {}) + qbittorrentapi_module.Unauthorized401Error = type("Unauthorized401Error", (Exception,), {}) + qbittorrentapi_module.Client = object + qbittorrentapi_client_module.Client = object + qbittorrentapi_transfer_module.TransferInfoDictionary = dict + + app_module.core = core_module + app_module.log = log_module + app_module.modules = modules_module + app_module.schemas = schemas_module + app_module.utils = utils_module + core_module.cache = cache_module + core_module.config = config_module + core_module.metainfo = metainfo_module + utils_module.string = string_module + schemas_module.types = schema_types_module + modules_module.qbittorrent = qbittorrent_package_module + torrentool_module.torrent = torrentool_torrent_module + + stub_modules = { + "app": app_module, + "app.core": core_module, + "app.core.cache": cache_module, + "app.core.config": config_module, + "app.core.metainfo": metainfo_module, + "app.log": log_module, + "app.modules": modules_module, + "app.modules.qbittorrent": qbittorrent_package_module, + "app.schemas": schemas_module, + "app.schemas.types": schema_types_module, + "app.utils": utils_module, + "app.utils.string": string_module, + "qbittorrentapi": qbittorrentapi_module, + "qbittorrentapi.client": qbittorrentapi_client_module, + "qbittorrentapi.transfer": qbittorrentapi_transfer_module, + "torrentool": torrentool_module, + "torrentool.torrent": torrentool_torrent_module, + } + + for stub_module in stub_modules.values(): + stub_module._qbittorrent_test_stub = True + + qbittorrent_path = repo_root / "app" / "modules" / "qbittorrent" / "qbittorrent.py" + qbittorrent_spec = importlib.util.spec_from_file_location( + "app.modules.qbittorrent.qbittorrent", + qbittorrent_path, + ) + qbittorrent_module = importlib.util.module_from_spec(qbittorrent_spec) + assert qbittorrent_spec and qbittorrent_spec.loader + + module_path = repo_root / "app" / "modules" / "qbittorrent" / "__init__.py" + qbittorrent_module_spec = importlib.util.spec_from_file_location( + "_test_qbittorrent_module", + module_path, + ) + module_package = importlib.util.module_from_spec(qbittorrent_module_spec) + assert qbittorrent_module_spec and qbittorrent_module_spec.loader + + with patch.dict(sys.modules, stub_modules): + sys.modules[qbittorrent_spec.name] = qbittorrent_module + qbittorrent_spec.loader.exec_module(qbittorrent_module) + qbittorrent_package_module.qbittorrent = qbittorrent_module + qbittorrent_module_spec.loader.exec_module(module_package) + + return qbittorrent_module, module_package + + +qbittorrent_module, qbittorrent_package_module = _load_qbittorrent_modules() +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" + + 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"}, + ) + + 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"], + } + + 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") + + +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_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 + + module = self._build_module(fake_server) + result = module.download( + content="magnet:?xt=urn:btih:123", + download_dir=Path("/downloads"), + cookie="", + downloader="qb", + ) + + 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 + + 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")