mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-30 03:48:03 +08:00
fix(qbittorrent): preserve safe disconnected client state
This commit is contained in:
@@ -25,17 +25,20 @@ class Qbittorrent:
|
||||
qbittorrent下载器
|
||||
"""
|
||||
|
||||
def __init__(self, host: Optional[str] = None, port: int = None,
|
||||
def __init__(self, host: Optional[str] = None, port: Optional[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,
|
||||
force_resume: Optional[bool] = False, first_last_piece: bool = False,
|
||||
incomplete_files_ext: Optional[bool] = True,
|
||||
**kwargs):
|
||||
**kwargs: object) -> None:
|
||||
"""
|
||||
若不设置参数,则创建配置文件设置的下载器
|
||||
"""
|
||||
self.qbc = None
|
||||
self.qbc: Optional[Client] = None
|
||||
self._host: Optional[str] = None
|
||||
self._port: Optional[int] = None
|
||||
self._force_resume = force_resume
|
||||
if host and port:
|
||||
self._host, self._port = host, port
|
||||
elif host:
|
||||
@@ -48,7 +51,6 @@ class Qbittorrent:
|
||||
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._incomplete_files_ext = incomplete_files_ext
|
||||
self.qbc = self.__login_qbittorrent()
|
||||
@@ -68,7 +70,7 @@ class Qbittorrent:
|
||||
pass
|
||||
return getattr(data, key, None)
|
||||
|
||||
def __normalize_cookie(self, cookie: Any) -> dict:
|
||||
def __normalize_cookie(self, cookie: Any) -> dict[str, object]:
|
||||
"""投影 Cookie API 支持的非空字段,统一不同 SDK 返回形状。"""
|
||||
result = {}
|
||||
for key in ("domain", "path", "name", "value", "expirationDate"):
|
||||
@@ -78,7 +80,7 @@ class Qbittorrent:
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def __cookie_key(cookie: dict) -> Optional[tuple]:
|
||||
def __cookie_key(cookie: dict[str, object]) -> Optional[tuple[object, object, object]]:
|
||||
"""以域、路径和名称标识 Cookie;缺少必要身份时返回空。"""
|
||||
name = cookie.get("name")
|
||||
domain = cookie.get("domain")
|
||||
@@ -88,7 +90,7 @@ class Qbittorrent:
|
||||
return domain, path, name
|
||||
|
||||
@staticmethod
|
||||
def __build_site_cookies(url: str, cookie_header: str) -> List[dict]:
|
||||
def __build_site_cookies(url: str, cookie_header: str) -> List[dict[str, object]]:
|
||||
"""把站点 Cookie 请求头转换为绑定目标主机的 Cookie API 条目。"""
|
||||
domain = urlparse(url).hostname
|
||||
if not domain:
|
||||
@@ -135,7 +137,8 @@ class Qbittorrent:
|
||||
return False
|
||||
try:
|
||||
web_api_version = self.qbc.app_web_api_version()
|
||||
return Version(str(web_api_version)) >= Version("2.11.3")
|
||||
supported: bool = Version(str(web_api_version)) >= Version("2.11.3")
|
||||
return supported
|
||||
except (InvalidVersion, TypeError, ValueError):
|
||||
return False
|
||||
except Exception as err:
|
||||
@@ -193,7 +196,7 @@ class Qbittorrent:
|
||||
return False
|
||||
return True if not self.qbc else False
|
||||
|
||||
def reconnect(self):
|
||||
def reconnect(self) -> None:
|
||||
"""
|
||||
重连
|
||||
"""
|
||||
@@ -243,9 +246,9 @@ class Qbittorrent:
|
||||
logger.error(f"qbittorrent 连接出错:{str(err)}")
|
||||
return None
|
||||
|
||||
def get_torrents(self, ids: Optional[Union[str, list]] = None,
|
||||
def get_torrents(self, ids: Optional[Union[str, list[str]]] = None,
|
||||
status: Optional[str] = None,
|
||||
tags: Optional[Union[str, list]] = None) -> Tuple[List[TorrentDictionary], bool]:
|
||||
tags: Optional[Union[str, list[str]]] = None) -> Tuple[List[TorrentDictionary], bool]:
|
||||
"""
|
||||
获取种子列表
|
||||
return: 种子列表, 是否发生异常
|
||||
@@ -273,8 +276,8 @@ class Qbittorrent:
|
||||
logger.error(f"获取种子列表出错:{str(err)}")
|
||||
return [], True
|
||||
|
||||
def get_completed_torrents(self, ids: Union[str, list] = None,
|
||||
tags: Union[str, list] = None) -> Optional[List[TorrentDictionary]]:
|
||||
def get_completed_torrents(self, ids: Optional[Union[str, list[str]]] = None,
|
||||
tags: Optional[Union[str, list[str]]] = None) -> Optional[List[TorrentDictionary]]:
|
||||
"""
|
||||
获取已完成的种子
|
||||
return: 种子列表, 如发生异常则返回None
|
||||
@@ -310,8 +313,8 @@ class Qbittorrent:
|
||||
ret_torrents.append(torrent)
|
||||
return ret_torrents
|
||||
|
||||
def get_downloading_torrents(self, ids: Union[str, list] = None,
|
||||
tags: Union[str, list] = None) -> Optional[List[TorrentDictionary]]:
|
||||
def get_downloading_torrents(self, ids: Optional[Union[str, list[str]]] = None,
|
||||
tags: Optional[Union[str, list[str]]] = None) -> Optional[List[TorrentDictionary]]:
|
||||
"""
|
||||
获取正在下载的种子
|
||||
return: 种子列表, 如发生异常则返回None
|
||||
@@ -323,7 +326,7 @@ class Qbittorrent:
|
||||
tags=tags)
|
||||
return None if error else torrents or []
|
||||
|
||||
def delete_torrents_tag(self, ids: Optional[Union[str, list]], tag: Union[str, list]) -> bool:
|
||||
def delete_torrents_tag(self, ids: Optional[Union[str, list[str]]], tag: Union[str, list[str]]) -> bool:
|
||||
"""
|
||||
从指定种子移除标签,并删除全局标签定义
|
||||
|
||||
@@ -379,7 +382,7 @@ class Qbittorrent:
|
||||
logger.error(f"删除种子Tag出错:{str(err)}")
|
||||
return False
|
||||
|
||||
def remove_torrents_tag(self, ids: Union[str, list], tag: Union[str, list]) -> bool:
|
||||
def remove_torrents_tag(self, ids: Union[str, list[str]], tag: Union[str, list[str]]) -> bool:
|
||||
"""
|
||||
移除种子Tag
|
||||
:param ids: 种子Hash列表
|
||||
@@ -394,7 +397,7 @@ class Qbittorrent:
|
||||
logger.error(f"移除种子Tag出错:{str(err)}")
|
||||
return False
|
||||
|
||||
def set_torrents_tag(self, ids: Union[str, list], tags: list):
|
||||
def set_torrents_tag(self, ids: Union[str, list[str]], tags: list[str]) -> None:
|
||||
"""
|
||||
设置种子状态为已整理,以及是否强制做种
|
||||
"""
|
||||
@@ -406,13 +409,13 @@ class Qbittorrent:
|
||||
except Exception as err:
|
||||
logger.error(f"设置种子Tag出错:{str(err)}")
|
||||
|
||||
def is_force_resume(self) -> bool:
|
||||
def is_force_resume(self) -> Optional[bool]:
|
||||
"""
|
||||
是否支持强制作种
|
||||
"""
|
||||
return self._force_resume
|
||||
|
||||
def torrents_set_force_start(self, ids: Union[str, list]) -> bool:
|
||||
def torrents_set_force_start(self, ids: Union[str, list[str]]) -> bool:
|
||||
"""
|
||||
设置强制作种,并返回下载器是否确认请求。
|
||||
"""
|
||||
@@ -427,7 +430,7 @@ class Qbittorrent:
|
||||
logger.error(f"设置强制作种出错:{str(err)}")
|
||||
return False
|
||||
|
||||
def __get_last_add_torrentid_by_tag(self, tags: Union[str, list],
|
||||
def __get_last_add_torrentid_by_tag(self, tags: Union[str, list[str]],
|
||||
status: Optional[str] = None) -> Optional[str]:
|
||||
"""
|
||||
根据种子的下载链接获取下载中或暂停的钟子的ID
|
||||
@@ -439,11 +442,12 @@ class Qbittorrent:
|
||||
logger.error(f"获取种子列表出错:{str(err)}")
|
||||
return None
|
||||
if torrents:
|
||||
return torrents[0].get("hash")
|
||||
torrent_id: Optional[str] = torrents[0].get("hash")
|
||||
return torrent_id
|
||||
else:
|
||||
return None
|
||||
|
||||
def get_torrent_id_by_tag(self, tags: Union[str, list],
|
||||
def get_torrent_id_by_tag(self, tags: Union[str, list[str]],
|
||||
status: Optional[str] = None) -> Optional[str]:
|
||||
"""
|
||||
通过标签多次尝试获取刚添加的种子ID,并确保移除临时标签。
|
||||
@@ -470,10 +474,10 @@ class Qbittorrent:
|
||||
content: Union[str, bytes],
|
||||
is_paused: Optional[bool] = False,
|
||||
download_dir: Optional[str] = None,
|
||||
tag: Union[str, list] = None,
|
||||
tag: Optional[Union[str, list[str]]] = None,
|
||||
category: Optional[str] = None,
|
||||
cookie: Optional[str] = None,
|
||||
**kwargs
|
||||
**kwargs: object
|
||||
) -> Tuple[bool, List[str]]:
|
||||
"""
|
||||
添加种子
|
||||
@@ -542,7 +546,7 @@ class Qbittorrent:
|
||||
logger.error(f"添加种子出错:{str(err)}")
|
||||
return False, []
|
||||
|
||||
def start_torrents(self, ids: Union[str, list]) -> bool:
|
||||
def start_torrents(self, ids: Union[str, list[str]]) -> bool:
|
||||
"""
|
||||
启动种子
|
||||
"""
|
||||
@@ -555,7 +559,7 @@ class Qbittorrent:
|
||||
logger.error(f"启动种子出错:{str(err)}")
|
||||
return False
|
||||
|
||||
def stop_torrents(self, ids: Union[str, list]) -> bool:
|
||||
def stop_torrents(self, ids: Union[str, list[str]]) -> bool:
|
||||
"""
|
||||
暂停种子
|
||||
"""
|
||||
@@ -568,7 +572,7 @@ class Qbittorrent:
|
||||
logger.error(f"暂停种子出错:{str(err)}")
|
||||
return False
|
||||
|
||||
def delete_torrents(self, delete_file: bool, ids: Union[str, list]) -> bool:
|
||||
def delete_torrents(self, delete_file: bool, ids: Union[str, list[str]]) -> bool:
|
||||
"""
|
||||
删除种子
|
||||
"""
|
||||
@@ -608,7 +612,7 @@ class Qbittorrent:
|
||||
logger.error(f"获取种子文件列表出错:{str(last_error)}")
|
||||
return None
|
||||
|
||||
def set_files(self, **kwargs) -> bool:
|
||||
def set_files(self, **kwargs: object) -> bool:
|
||||
"""
|
||||
设置下载文件的状态,priority为0为不下载,priority为1为下载
|
||||
"""
|
||||
@@ -637,7 +641,7 @@ class Qbittorrent:
|
||||
logger.error(f"获取传输信息出错:{str(err)}")
|
||||
return None
|
||||
|
||||
def set_speed_limit(self, download_limit: float = None, upload_limit: float = None) -> bool:
|
||||
def set_speed_limit(self, download_limit: Optional[float] = None, upload_limit: Optional[float] = None) -> bool:
|
||||
"""
|
||||
设置速度限制
|
||||
:param download_limit: 下载速度限制,单位KB/s
|
||||
@@ -673,7 +677,7 @@ class Qbittorrent:
|
||||
|
||||
return download_limit / 1024, upload_limit / 1024
|
||||
|
||||
def recheck_torrents(self, ids: Union[str, list]) -> bool:
|
||||
def recheck_torrents(self, ids: Union[str, list[str]]) -> bool:
|
||||
"""
|
||||
重新校验种子
|
||||
"""
|
||||
@@ -767,7 +771,7 @@ class Qbittorrent:
|
||||
logger.error(f"设置种子分类出错:{str(err)}")
|
||||
return False
|
||||
|
||||
def update_tracker(self, hash_string: str, tracker_list: list) -> bool:
|
||||
def update_tracker(self, hash_string: str, tracker_list: list[str]) -> bool:
|
||||
"""
|
||||
添加tracker
|
||||
"""
|
||||
@@ -808,4 +812,5 @@ class Qbittorrent:
|
||||
# 获取下载器全局设置
|
||||
application = self.qbc.application.preferences
|
||||
# 获取种子内容布局: `Original: 原始, Subfolder: 创建子文件夹, NoSubfolder: 不创建子文件夹`
|
||||
return application.get("torrent_content_layout", "Original")
|
||||
layout: Optional[str] = application.get("torrent_content_layout", "Original")
|
||||
return layout
|
||||
|
||||
37
tests/test_qbittorrent_client_state.py
Normal file
37
tests/test_qbittorrent_client_state.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""qBittorrent 未配置状态和强制作种开关兼容性。"""
|
||||
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
import app.modules.qbittorrent.qbittorrent as client_module
|
||||
from app.modules.qbittorrent.qbittorrent import Qbittorrent
|
||||
|
||||
|
||||
@pytest.mark.parametrize("host", [None, ""])
|
||||
@pytest.mark.parametrize("force_resume", [None, False, True])
|
||||
def test_missing_host_keeps_state_and_reconnect_safe(monkeypatch, host, force_resume):
|
||||
"""缺少地址时状态查询及重连不应崩溃或尝试网络连接。"""
|
||||
factory = Mock()
|
||||
monkeypatch.setattr(client_module.qbittorrentapi, "Client", factory)
|
||||
client = Qbittorrent(host=host, force_resume=force_resume)
|
||||
assert client.qbc is None
|
||||
assert client.is_inactive() is False
|
||||
assert client.is_force_resume() is force_resume
|
||||
assert client.reconnect() is None
|
||||
assert client.qbc is None
|
||||
assert client.get_torrents() == ([], True)
|
||||
assert client.get_completed_torrents() is None
|
||||
assert client.get_downloading_torrents() is None
|
||||
factory.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("force_resume", [None, False, True])
|
||||
def test_configured_force_resume_preserves_raw_flag(monkeypatch, force_resume):
|
||||
"""保留显式 None、False 和 True,不在类型收紧时隐式改变开关值。"""
|
||||
sdk = Mock()
|
||||
monkeypatch.setattr(Qbittorrent, "_Qbittorrent__login_qbittorrent", lambda _self: sdk)
|
||||
client = Qbittorrent(host="https://qb.invalid", port=443, force_resume=force_resume)
|
||||
assert client.qbc is sdk
|
||||
assert client.is_inactive() is False
|
||||
assert client.is_force_resume() is force_resume
|
||||
Reference in New Issue
Block a user