From d11bf319538b7d593ccf6ec72aab579e79ab6965 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sat, 17 Oct 2026 06:19:42 +0800 Subject: [PATCH] fix(transmission): initialize disconnected client state and type SDK contracts --- app/modules/transmission/transmission.py | 78 ++++++++++++++---------- tests/test_transmission_client_state.py | 72 ++++++++++++++++++++++ 2 files changed, 118 insertions(+), 32 deletions(-) create mode 100644 tests/test_transmission_client_state.py diff --git a/app/modules/transmission/transmission.py b/app/modules/transmission/transmission.py index f24fe5059..2c387dfdc 100755 --- a/app/modules/transmission/transmission.py +++ b/app/modules/transmission/transmission.py @@ -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)}") diff --git a/tests/test_transmission_client_state.py b/tests/test_transmission_client_state.py new file mode 100644 index 000000000..852093aca --- /dev/null +++ b/tests/test_transmission_client_state.py @@ -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), + )