mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-05-11 18:10:15 +08:00
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:
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
352
tests/test_qbittorrent_compat.py
Normal file
352
tests/test_qbittorrent_compat.py
Normal 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")
|
||||
Reference in New Issue
Block a user