mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-03 05:44:44 +08:00
fix(transmission): initialize disconnected client state and type SDK contracts
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
from typing import Any, List, Optional, Tuple, Union, cast
|
||||
from typing import List, Optional, Sequence, Tuple, Union
|
||||
|
||||
import transmission_rpc
|
||||
from transmission_rpc import Client, File, Torrent
|
||||
@@ -7,6 +7,8 @@ from transmission_rpc.session import Session, SessionStats
|
||||
from app.foundation.url import UrlUtils
|
||||
from app.runtime.log import logger
|
||||
|
||||
TorrentIds = Union[str, int, Sequence[Union[str, int]]]
|
||||
|
||||
|
||||
class Transmission:
|
||||
"""
|
||||
@@ -21,11 +23,14 @@ class Transmission:
|
||||
|
||||
def __init__(self, host: Optional[str] = None, port: Optional[int] = None,
|
||||
username: Optional[str] = None, password: Optional[str] = None,
|
||||
rename_partial_files: Optional[bool] = True, **kwargs):
|
||||
rename_partial_files: Optional[bool] = True, **kwargs: object) -> None:
|
||||
"""
|
||||
若不设置参数,则创建配置文件设置的下载器
|
||||
"""
|
||||
self.trc = None
|
||||
self.trc: Optional[Client] = None
|
||||
self._host: Optional[str] = None
|
||||
self._port: Optional[int] = None
|
||||
self._protocol: object
|
||||
if host and port:
|
||||
self._protocol, self._host, self._port = kwargs.get("protocol", "http"), host, port
|
||||
elif host:
|
||||
@@ -79,7 +84,7 @@ class Transmission:
|
||||
username=self._username,
|
||||
password=self._password,
|
||||
# transmission-rpc supports Requests timeout tuples at runtime.
|
||||
timeout=cast(Any, (3, 60)))
|
||||
timeout=(3, 60))
|
||||
self.__sync_incomplete_file_suffix(trt, enabled=bool(self._rename_partial_files))
|
||||
return trt
|
||||
except Exception as err:
|
||||
@@ -94,14 +99,14 @@ class Transmission:
|
||||
return False
|
||||
return True if not self.trc else False
|
||||
|
||||
def reconnect(self):
|
||||
def reconnect(self) -> None:
|
||||
"""
|
||||
重连
|
||||
"""
|
||||
self.trc = self.__login_transmission()
|
||||
|
||||
def get_torrents(self, ids: Union[str, list] = None, status: Union[str, list] = None,
|
||||
tags: Union[str, list] = None) -> Tuple[List[Torrent], bool]:
|
||||
def get_torrents(self, ids: Optional[TorrentIds] = None, status: Optional[Union[str, list[str]]] = None,
|
||||
tags: Optional[Union[str, list[str]]] = None) -> Tuple[List[Torrent], bool]:
|
||||
"""
|
||||
获取种子列表
|
||||
返回结果 种子列表, 是否有错误
|
||||
@@ -134,8 +139,8 @@ class Transmission:
|
||||
del torrents
|
||||
return ret_torrents, False
|
||||
|
||||
def get_completed_torrents(self, ids: Union[str, list] = None,
|
||||
tags: Union[str, list] = None) -> Optional[List[Torrent]]:
|
||||
def get_completed_torrents(self, ids: Optional[TorrentIds] = None,
|
||||
tags: Optional[Union[str, list[str]]] = None) -> Optional[List[Torrent]]:
|
||||
"""
|
||||
获取已完成的种子列表
|
||||
return 种子列表, 发生错误时返回None
|
||||
@@ -149,8 +154,8 @@ class Transmission:
|
||||
logger.error(f"获取已完成的种子列表出错:{str(err)}")
|
||||
return None
|
||||
|
||||
def get_downloading_torrents(self, ids: Union[str, list] = None,
|
||||
tags: Union[str, list] = None) -> Optional[List[Torrent]]:
|
||||
def get_downloading_torrents(self, ids: Optional[TorrentIds] = None,
|
||||
tags: Optional[Union[str, list[str]]] = None) -> Optional[List[Torrent]]:
|
||||
"""
|
||||
获取正在下载的种子列表
|
||||
return 种子列表, 发生错误时返回None
|
||||
@@ -166,7 +171,7 @@ class Transmission:
|
||||
logger.error(f"获取正在下载的种子列表出错:{str(err)}")
|
||||
return None
|
||||
|
||||
def set_torrent_tag(self, ids: str, tags: list, org_tags: list = None) -> bool:
|
||||
def set_torrent_tag(self, ids: TorrentIds, tags: list[str], org_tags: Optional[list[str]] = None) -> bool:
|
||||
"""
|
||||
设置种子标签,注意TR默认会覆盖原有标签,如需追加需传入原有标签
|
||||
"""
|
||||
@@ -181,7 +186,7 @@ class Transmission:
|
||||
logger.error(f"设置种子标签出错:{str(err)}")
|
||||
return False
|
||||
|
||||
def get_torrent_tags(self, ids: str) -> List[str]:
|
||||
def get_torrent_tags(self, ids: TorrentIds) -> List[str]:
|
||||
"""
|
||||
获取所有种子标签
|
||||
"""
|
||||
@@ -202,8 +207,8 @@ class Transmission:
|
||||
def add_torrent(self, content: Union[str, bytes],
|
||||
is_paused: Optional[bool] = False,
|
||||
download_dir: Optional[str] = None,
|
||||
labels=None,
|
||||
cookie=None) -> Optional[Torrent]:
|
||||
labels: Optional[list[str]] = None,
|
||||
cookie: Optional[str] = None) -> Optional[Torrent]:
|
||||
"""
|
||||
添加下载任务
|
||||
:param content: 种子urls或文件内容
|
||||
@@ -225,7 +230,7 @@ class Transmission:
|
||||
logger.error(f"添加种子出错:{str(err)}")
|
||||
return None
|
||||
|
||||
def start_torrents(self, ids: Union[str, list]) -> bool:
|
||||
def start_torrents(self, ids: TorrentIds) -> bool:
|
||||
"""
|
||||
启动种子
|
||||
"""
|
||||
@@ -238,7 +243,7 @@ class Transmission:
|
||||
logger.error(f"启动种子出错:{str(err)}")
|
||||
return False
|
||||
|
||||
def stop_torrents(self, ids: Union[str, list]) -> bool:
|
||||
def stop_torrents(self, ids: TorrentIds) -> bool:
|
||||
"""
|
||||
停止种子
|
||||
"""
|
||||
@@ -251,7 +256,7 @@ class Transmission:
|
||||
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: Optional[bool], ids: TorrentIds) -> bool:
|
||||
"""
|
||||
删除种子
|
||||
"""
|
||||
@@ -284,13 +289,15 @@ class Transmission:
|
||||
try:
|
||||
get_files = getattr(torrent, "get_files", None)
|
||||
if callable(get_files):
|
||||
return get_files()
|
||||
return torrent.files()
|
||||
files: List[File] = get_files()
|
||||
return files
|
||||
legacy_files: List[File] = torrent.files()
|
||||
return legacy_files
|
||||
except Exception as err:
|
||||
logger.error(f"获取种子文件列表出错:{str(err)}")
|
||||
return None
|
||||
|
||||
def set_files(self, tid: str, file_ids: list) -> bool:
|
||||
def set_files(self, tid: str, file_ids: list[int]) -> bool:
|
||||
"""
|
||||
设置下载文件的状态
|
||||
"""
|
||||
@@ -303,7 +310,7 @@ class Transmission:
|
||||
logger.error(f"设置下载文件状态出错:{str(err)}")
|
||||
return False
|
||||
|
||||
def set_unwanted_files(self, tid: str, file_ids: list) -> bool:
|
||||
def set_unwanted_files(self, tid: str, file_ids: list[int]) -> bool:
|
||||
"""
|
||||
设置下载文件的状态
|
||||
"""
|
||||
@@ -328,14 +335,18 @@ class Transmission:
|
||||
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
|
||||
:param upload_limit: 上传速度限制,单位kB/s
|
||||
两个限速均需提供;缺少参数时沿用失败返回,不修改会话。
|
||||
"""
|
||||
if not self.trc:
|
||||
return False
|
||||
if download_limit is None or upload_limit is None:
|
||||
logger.error("设置速度限制出错:未提供下载或上传限速")
|
||||
return False
|
||||
try:
|
||||
download_limit_enabled = True if download_limit else False
|
||||
upload_limit_enabled = True if upload_limit else False
|
||||
@@ -373,7 +384,7 @@ class Transmission:
|
||||
upload_limit
|
||||
)
|
||||
|
||||
def recheck_torrents(self, ids: Union[str, list]) -> bool:
|
||||
def recheck_torrents(self, ids: TorrentIds) -> bool:
|
||||
"""
|
||||
重新校验种子
|
||||
"""
|
||||
@@ -388,10 +399,10 @@ class Transmission:
|
||||
|
||||
def change_torrent(self,
|
||||
hash_string: str,
|
||||
upload_limit=None,
|
||||
download_limit=None,
|
||||
ratio_limit=None,
|
||||
seeding_time_limit=None) -> bool:
|
||||
upload_limit: Optional[float] = None,
|
||||
download_limit: Optional[float] = None,
|
||||
ratio_limit: Optional[float] = None,
|
||||
seeding_time_limit: Optional[int] = None) -> bool:
|
||||
"""
|
||||
设置种子
|
||||
:param hash_string: ID
|
||||
@@ -403,7 +414,10 @@ class Transmission:
|
||||
"""
|
||||
if not hash_string:
|
||||
return False
|
||||
change_kwargs = {"ids": hash_string}
|
||||
if not self.trc:
|
||||
logger.error("设置种子出错:Transmission 客户端未连接")
|
||||
return False
|
||||
change_kwargs: dict[str, object] = {"ids": hash_string}
|
||||
if upload_limit is not None:
|
||||
change_kwargs["uploadLimited"] = bool(upload_limit)
|
||||
change_kwargs["uploadLimit"] = int(upload_limit)
|
||||
@@ -443,7 +457,7 @@ class Transmission:
|
||||
logger.error(f"设置种子保存目录出错:{str(err)}")
|
||||
return False
|
||||
|
||||
def update_tracker(self, hash_string: str, tracker_list: list = None) -> bool:
|
||||
def update_tracker(self, hash_string: str, tracker_list: Optional[list[str]] = None) -> bool:
|
||||
"""
|
||||
tr4.0及以上弃用直接设置tracker 共用change方法
|
||||
https://github.com/trim21/transmission-rpc/blob/8eb82629492a0eeb0bb565f82c872bf9ccdcb313/transmission_rpc/client.py#L654
|
||||
@@ -478,9 +492,9 @@ class Transmission:
|
||||
return list(tracker_list)
|
||||
trackers = getattr(torrent, "trackers", None) or []
|
||||
return [
|
||||
tracker.get("announce")
|
||||
announce
|
||||
for tracker in trackers
|
||||
if isinstance(tracker, dict) and tracker.get("announce")
|
||||
if isinstance(tracker, dict) and (announce := tracker.get("announce"))
|
||||
]
|
||||
except Exception as err:
|
||||
logger.error(f"获取tracker出错:{str(err)}")
|
||||
|
||||
72
tests/test_transmission_client_state.py
Normal file
72
tests/test_transmission_client_state.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""Transmission 未配置状态和 SDK 参数转发的兼容边界。"""
|
||||
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
import app.modules.transmission.transmission as client_module
|
||||
from app.modules.transmission.transmission import Transmission
|
||||
|
||||
|
||||
@pytest.mark.parametrize("host", [None, "", "invalid-address"])
|
||||
@pytest.mark.parametrize("rename_partial_files", [None, False, True])
|
||||
def test_missing_address_keeps_state_and_reconnect_safe(monkeypatch, host, rename_partial_files):
|
||||
"""未配置或地址解析失败时不连接网络,状态查询与重连均能正常结束。"""
|
||||
factory = Mock()
|
||||
monkeypatch.setattr(client_module.transmission_rpc, "Client", factory)
|
||||
monkeypatch.setattr(client_module.UrlUtils, "parse_url_params", lambda **_kwargs: None)
|
||||
client = Transmission(host=host, rename_partial_files=rename_partial_files)
|
||||
assert client.trc is None
|
||||
assert client.is_inactive() is False
|
||||
assert client.reconnect() is None
|
||||
assert client.get_torrents() == ([], True)
|
||||
assert client.get_completed_torrents() is None
|
||||
assert client.get_downloading_torrents() is None
|
||||
assert client.change_torrent("hash", download_limit=0) is False
|
||||
factory.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("rename_partial_files", [None, False, True])
|
||||
def test_configured_partial_file_flag_preserves_raw_value(monkeypatch, rename_partial_files):
|
||||
"""配置中的空值与布尔值保留原样,继续由登录层决定 SDK 开关。"""
|
||||
sdk = Mock()
|
||||
monkeypatch.setattr(Transmission, "_Transmission__login_transmission", lambda _self: sdk)
|
||||
client = Transmission(host="transmission.invalid", port=9091, rename_partial_files=rename_partial_files)
|
||||
assert client.trc is sdk
|
||||
assert client._rename_partial_files is rename_partial_files
|
||||
assert client.is_inactive() is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("protocol", [None, "http", "https"])
|
||||
def test_explicit_protocol_is_forwarded_without_coercion(monkeypatch, protocol):
|
||||
"""已有 protocol 扩展参数仍原样传给 SDK,包括显式空值。"""
|
||||
sdk = Mock()
|
||||
sdk.get_session.return_value = {"rename-partial-files": True}
|
||||
factory = Mock(return_value=sdk)
|
||||
monkeypatch.setattr(client_module.transmission_rpc, "Client", factory)
|
||||
client = Transmission(host="transmission.invalid", port=9091, protocol=protocol)
|
||||
assert client.trc is sdk
|
||||
assert factory.call_args.kwargs["protocol"] is protocol
|
||||
|
||||
|
||||
@pytest.mark.parametrize("download_limit,upload_limit", [(None, None), (None, 1), (1, None)])
|
||||
def test_incomplete_speed_limits_keep_failure_without_sdk_write(download_limit, upload_limit):
|
||||
"""缺少任一限速时维持失败返回,不能误把缺省参数当作关闭限速。"""
|
||||
client = Transmission.__new__(Transmission)
|
||||
client.trc = Mock()
|
||||
assert client.set_speed_limit(download_limit, upload_limit) is False
|
||||
client.trc.set_session.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("download_limit,upload_limit", [(0, 0), (1.5, 20.9)])
|
||||
def test_explicit_speed_limits_preserve_integer_conversion(download_limit, upload_limit):
|
||||
"""显式零值仍关闭限速,非零值保留原来的整数转换和开关行为。"""
|
||||
client = Transmission.__new__(Transmission)
|
||||
client.trc = Mock()
|
||||
assert client.set_speed_limit(download_limit, upload_limit) is True
|
||||
client.trc.set_session.assert_called_once_with(
|
||||
speed_limit_down=int(download_limit),
|
||||
speed_limit_up=int(upload_limit),
|
||||
speed_limit_down_enabled=bool(download_limit),
|
||||
speed_limit_up_enabled=bool(upload_limit),
|
||||
)
|
||||
Reference in New Issue
Block a user