fix(qbittorrent): restore qBittorrent 5.2 compatibility

Support WebUI API Key auth, newer add responses, and cookie sync so qBittorrent 5.2 can connect reliably while keeping legacy fallback behavior.

Fixes #5724
This commit is contained in:
jxxghp
2026-05-07 07:41:05 +08:00
parent c762628217
commit 62541ffe43
5 changed files with 501 additions and 16 deletions

View File

@@ -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:

View File

@@ -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:
"""

View File

@@ -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

View File

@@ -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,

View File

@@ -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")