mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-08 17:08:35 +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
|
is_paused = True if episodes else False
|
||||||
# 添加任务
|
# 添加任务
|
||||||
state = server.add_torrent(
|
state, added_torrent_ids = server.add_torrent(
|
||||||
content=content,
|
content=content,
|
||||||
download_dir=self.normalize_path(download_dir, downloader),
|
download_dir=self.normalize_path(download_dir, downloader),
|
||||||
is_paused=is_paused,
|
is_paused=is_paused,
|
||||||
@@ -188,6 +188,10 @@ class QbittorrentModule(_ModuleBase, _DownloaderBase[Qbittorrent]):
|
|||||||
return None, None, None, f"添加种子任务失败:{content}"
|
return None, None, None, f"添加种子任务失败:{content}"
|
||||||
else:
|
else:
|
||||||
# 获取种子Hash
|
# 获取种子Hash
|
||||||
|
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)
|
torrent_hash = server.get_torrent_id_by_tag(tags=tag)
|
||||||
if not torrent_hash:
|
if not torrent_hash:
|
||||||
return None, None, None, f"下载任务添加成功,但获取Qbittorrent任务信息失败:{content}"
|
return None, None, None, f"下载任务添加成功,但获取Qbittorrent任务信息失败:{content}"
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import time
|
import time
|
||||||
import traceback
|
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
|
import qbittorrentapi
|
||||||
|
from packaging.version import InvalidVersion, Version
|
||||||
from qbittorrentapi import TorrentDictionary, TorrentFilesList
|
from qbittorrentapi import TorrentDictionary, TorrentFilesList
|
||||||
from qbittorrentapi.client import Client
|
from qbittorrentapi.client import Client
|
||||||
from qbittorrentapi.transfer import TransferInfoDictionary
|
from qbittorrentapi.transfer import TransferInfoDictionary
|
||||||
@@ -17,6 +20,7 @@ class Qbittorrent:
|
|||||||
"""
|
"""
|
||||||
def __init__(self, host: Optional[str] = None, port: int = None,
|
def __init__(self, host: Optional[str] = None, port: int = None,
|
||||||
username: Optional[str] = None, password: Optional[str] = None,
|
username: Optional[str] = None, password: Optional[str] = None,
|
||||||
|
apikey: Optional[str] = None,
|
||||||
category: Optional[bool] = False, sequentail: Optional[bool] = False,
|
category: Optional[bool] = False, sequentail: Optional[bool] = False,
|
||||||
force_resume: Optional[bool] = False, first_last_piece=False,
|
force_resume: Optional[bool] = False, first_last_piece=False,
|
||||||
**kwargs):
|
**kwargs):
|
||||||
@@ -33,12 +37,124 @@ class Qbittorrent:
|
|||||||
return
|
return
|
||||||
self._username = username
|
self._username = username
|
||||||
self._password = password
|
self._password = password
|
||||||
|
self._apikey = str(apikey or "").strip() or None
|
||||||
self._category = category
|
self._category = category
|
||||||
self._sequentail = sequentail
|
self._sequentail = sequentail
|
||||||
self._force_resume = force_resume
|
self._force_resume = force_resume
|
||||||
self._first_last_piece = first_last_piece
|
self._first_last_piece = first_last_piece
|
||||||
self.qbc = self.__login_qbittorrent()
|
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:
|
def is_inactive(self) -> bool:
|
||||||
"""
|
"""
|
||||||
判断是否需要重连
|
判断是否需要重连
|
||||||
@@ -67,14 +183,20 @@ class Qbittorrent:
|
|||||||
port=self._port,
|
port=self._port,
|
||||||
username=self._username,
|
username=self._username,
|
||||||
password=self._password,
|
password=self._password,
|
||||||
|
EXTRA_HEADERS={"Authorization": f"Bearer {self._apikey}"}
|
||||||
|
if self.__use_api_key_auth() else None,
|
||||||
VERIFY_WEBUI_CERTIFICATE=False,
|
VERIFY_WEBUI_CERTIFICATE=False,
|
||||||
REQUESTS_ARGS={'timeout': (15, 60)})
|
REQUESTS_ARGS={'timeout': (15, 60)})
|
||||||
try:
|
try:
|
||||||
|
if self.__use_api_key_auth():
|
||||||
|
qbt.app_version()
|
||||||
|
else:
|
||||||
qbt.auth_log_in()
|
qbt.auth_log_in()
|
||||||
except (qbittorrentapi.LoginFailed, qbittorrentapi.Forbidden403Error) as e:
|
|
||||||
logger.error(f"qbittorrent 登录失败:{str(e).strip() or '请检查用户名和密码是否正确'}")
|
|
||||||
return None
|
|
||||||
except Exception as e:
|
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]
|
stack_trace = "".join(traceback.format_exception(None, e, e.__traceback__))[:2000]
|
||||||
logger.error(f"qbittorrent 登录失败:{str(e)}\n{stack_trace}")
|
logger.error(f"qbittorrent 登录失败:{str(e)}\n{stack_trace}")
|
||||||
return None
|
return None
|
||||||
@@ -241,7 +363,7 @@ class Qbittorrent:
|
|||||||
category: Optional[str] = None,
|
category: Optional[str] = None,
|
||||||
cookie: Optional[str] = None,
|
cookie: Optional[str] = None,
|
||||||
**kwargs
|
**kwargs
|
||||||
) -> bool:
|
) -> Tuple[bool, List[str]]:
|
||||||
"""
|
"""
|
||||||
添加种子
|
添加种子
|
||||||
:param content: 种子urls或文件内容
|
:param content: 种子urls或文件内容
|
||||||
@@ -251,10 +373,10 @@ class Qbittorrent:
|
|||||||
:param download_dir: 下载路径
|
:param download_dir: 下载路径
|
||||||
:param cookie: 站点Cookie用于辅助下载种子
|
:param cookie: 站点Cookie用于辅助下载种子
|
||||||
:param kwargs: 可选参数,如 ignore_category_check 以及 QB相关参数
|
:param kwargs: 可选参数,如 ignore_category_check 以及 QB相关参数
|
||||||
:return: bool
|
:return: 添加是否成功, 新版API返回的种子ID列表
|
||||||
"""
|
"""
|
||||||
if not self.qbc or not content:
|
if not self.qbc or not content:
|
||||||
return False
|
return False, []
|
||||||
|
|
||||||
# 下载内容
|
# 下载内容
|
||||||
if isinstance(content, str):
|
if isinstance(content, str):
|
||||||
@@ -287,6 +409,11 @@ class Qbittorrent:
|
|||||||
is_auto = False
|
is_auto = False
|
||||||
category = None
|
category = None
|
||||||
try:
|
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,
|
qbc_ret = self.qbc.torrents_add(urls=urls,
|
||||||
torrent_files=torrent_files,
|
torrent_files=torrent_files,
|
||||||
@@ -296,13 +423,13 @@ class Qbittorrent:
|
|||||||
use_auto_torrent_management=is_auto,
|
use_auto_torrent_management=is_auto,
|
||||||
is_sequential_download=self._sequentail,
|
is_sequential_download=self._sequentail,
|
||||||
is_first_last_piece_priority=self._first_last_piece,
|
is_first_last_piece_priority=self._first_last_piece,
|
||||||
cookie=cookie,
|
cookie=cookie_to_use,
|
||||||
category=category,
|
category=category,
|
||||||
**kwargs)
|
**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:
|
except Exception as err:
|
||||||
logger.error(f"添加种子出错:{str(err)}")
|
logger.error(f"添加种子出错:{str(err)}")
|
||||||
return False
|
return False, []
|
||||||
|
|
||||||
def start_torrents(self, ids: Union[str, list]) -> bool:
|
def start_torrents(self, ids: Union[str, list]) -> bool:
|
||||||
"""
|
"""
|
||||||
|
|||||||
+1
-1
@@ -28,7 +28,7 @@ APScheduler~=3.11.0
|
|||||||
cryptography~=45.0.4
|
cryptography~=45.0.4
|
||||||
pytz~=2025.2
|
pytz~=2025.2
|
||||||
pycryptodome~=3.23.0
|
pycryptodome~=3.23.0
|
||||||
qbittorrent-api==2025.5.0
|
qbittorrent-api==2026.5.1
|
||||||
plexapi~=4.17.0
|
plexapi~=4.17.0
|
||||||
transmission-rpc~=4.3.0
|
transmission-rpc~=4.3.0
|
||||||
Jinja2~=3.1.6
|
Jinja2~=3.1.6
|
||||||
|
|||||||
@@ -1312,8 +1312,9 @@ def _collect_downloader_config() -> Optional[dict[str, Any]]:
|
|||||||
config_name = _prompt_text("下载器名称", default=downloader_type)
|
config_name = _prompt_text("下载器名称", default=downloader_type)
|
||||||
if downloader_type == "qbittorrent":
|
if downloader_type == "qbittorrent":
|
||||||
host = _prompt_text("qBittorrent 地址", default="http://127.0.0.1:8080")
|
host = _prompt_text("qBittorrent 地址", default="http://127.0.0.1:8080")
|
||||||
username = _prompt_text("qBittorrent 用户名", default="admin")
|
apikey = _prompt_text("qBittorrent API Key(可选,5.2+ 推荐)", allow_empty=True, default="")
|
||||||
password = _prompt_text("qBittorrent 密码", secret=True)
|
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)
|
category = _prompt_yes_no("是否启用 qBittorrent 分类", default=False)
|
||||||
return {
|
return {
|
||||||
"name": config_name,
|
"name": config_name,
|
||||||
@@ -1322,6 +1323,7 @@ def _collect_downloader_config() -> Optional[dict[str, Any]]:
|
|||||||
"enabled": True,
|
"enabled": True,
|
||||||
"config": {
|
"config": {
|
||||||
"host": host,
|
"host": host,
|
||||||
|
"apikey": apikey,
|
||||||
"username": username,
|
"username": username,
|
||||||
"password": password,
|
"password": password,
|
||||||
"category": category,
|
"category": category,
|
||||||
|
|||||||
@@ -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