Change qBittorrent lib to qbittorrentapi (#113394)

* Change qBittorrent lib to qbittorrentapi

* Fix tests

* Convert qbittorrent service to new lib

* Add missing translation key

* Catch APIConnectionError in service call

* Replace type ignore by Any typing

* Remove last type: ignore

* Use lib type for torrent_filter

* Change import format

* Fix remaining Any type

---------

Co-authored-by: Erik Montnemery <erik@montnemery.com>
This commit is contained in:
Sébastien Clément 2024-06-10 14:27:20 +02:00 committed by GitHub
parent 960d1289ef
commit 80b2b05bd8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 109 additions and 75 deletions

View File

@ -3,8 +3,7 @@
import logging import logging
from typing import Any from typing import Any
from qbittorrent.client import LoginRequired from qbittorrentapi import APIConnectionError, Forbidden403Error, LoginFailed
from requests.exceptions import RequestException
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ( from homeassistant.const import (
@ -118,10 +117,13 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
config_entry.data[CONF_PASSWORD], config_entry.data[CONF_PASSWORD],
config_entry.data[CONF_VERIFY_SSL], config_entry.data[CONF_VERIFY_SSL],
) )
except LoginRequired as err: except LoginFailed as err:
raise ConfigEntryNotReady("Invalid credentials") from err raise ConfigEntryNotReady("Invalid credentials") from err
except RequestException as err: except Forbidden403Error as err:
raise ConfigEntryNotReady("Failed to connect") from err raise ConfigEntryNotReady("Fail to log in, banned user ?") from err
except APIConnectionError as exc:
raise ConfigEntryNotReady("Fail to connect to qBittorrent") from exc
coordinator = QBittorrentDataCoordinator(hass, client) coordinator = QBittorrentDataCoordinator(hass, client)
await coordinator.async_config_entry_first_refresh() await coordinator.async_config_entry_first_refresh()

View File

@ -5,8 +5,7 @@ from __future__ import annotations
import logging import logging
from typing import Any from typing import Any
from qbittorrent.client import LoginRequired from qbittorrentapi import APIConnectionError, Forbidden403Error, LoginFailed
from requests.exceptions import RequestException
import voluptuous as vol import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
@ -46,9 +45,9 @@ class QbittorrentConfigFlow(ConfigFlow, domain=DOMAIN):
user_input[CONF_PASSWORD], user_input[CONF_PASSWORD],
user_input[CONF_VERIFY_SSL], user_input[CONF_VERIFY_SSL],
) )
except LoginRequired: except (LoginFailed, Forbidden403Error):
errors = {"base": "invalid_auth"} errors = {"base": "invalid_auth"}
except RequestException: except APIConnectionError:
errors = {"base": "cannot_connect"} errors = {"base": "cannot_connect"}
else: else:
return self.async_create_entry(title=DEFAULT_NAME, data=user_input) return self.async_create_entry(title=DEFAULT_NAME, data=user_input)

View File

@ -4,10 +4,16 @@ from __future__ import annotations
from datetime import timedelta from datetime import timedelta
import logging import logging
from typing import Any
from qbittorrent import Client from qbittorrentapi import (
from qbittorrent.client import LoginRequired APIConnectionError,
Client,
Forbidden403Error,
LoginFailed,
SyncMainDataDictionary,
TorrentInfoList,
)
from qbittorrentapi.torrents import TorrentStatusesT
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
@ -18,8 +24,8 @@ from .const import DOMAIN
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
class QBittorrentDataCoordinator(DataUpdateCoordinator[dict[str, Any]]): class QBittorrentDataCoordinator(DataUpdateCoordinator[SyncMainDataDictionary]):
"""Coordinator for updating qBittorrent data.""" """Coordinator for updating QBittorrent data."""
def __init__(self, hass: HomeAssistant, client: Client) -> None: def __init__(self, hass: HomeAssistant, client: Client) -> None:
"""Initialize coordinator.""" """Initialize coordinator."""
@ -39,22 +45,31 @@ class QBittorrentDataCoordinator(DataUpdateCoordinator[dict[str, Any]]):
update_interval=timedelta(seconds=30), update_interval=timedelta(seconds=30),
) )
async def _async_update_data(self) -> dict[str, Any]: async def _async_update_data(self) -> SyncMainDataDictionary:
"""Async method to update QBittorrent data."""
try: try:
return await self.hass.async_add_executor_job(self.client.sync_main_data) return await self.hass.async_add_executor_job(self.client.sync_maindata)
except LoginRequired as exc: except (LoginFailed, Forbidden403Error) as exc:
raise HomeAssistantError(str(exc)) from exc
async def get_torrents(self, torrent_filter: str) -> list[dict[str, Any]]:
"""Async method to get QBittorrent torrents."""
try:
torrents = await self.hass.async_add_executor_job(
lambda: self.client.torrents(filter=torrent_filter)
)
except LoginRequired as exc:
raise HomeAssistantError( raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="login_error" translation_domain=DOMAIN, translation_key="login_error"
) from exc ) from exc
except APIConnectionError as exc:
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="cannot_connect"
) from exc
async def get_torrents(self, torrent_filter: TorrentStatusesT) -> TorrentInfoList:
"""Async method to get QBittorrent torrents."""
try:
torrents = await self.hass.async_add_executor_job(
lambda: self.client.torrents_info(torrent_filter)
)
except (LoginFailed, Forbidden403Error) as exc:
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="login_error"
) from exc
except APIConnectionError as exc:
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="cannot_connect"
) from exc
return torrents return torrents

View File

@ -1,17 +1,18 @@
"""Helper functions for qBittorrent.""" """Helper functions for qBittorrent."""
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Any from typing import Any, cast
from qbittorrent.client import Client from qbittorrentapi import Client, TorrentDictionary, TorrentInfoList
def setup_client(url: str, username: str, password: str, verify_ssl: bool) -> Client: def setup_client(url: str, username: str, password: str, verify_ssl: bool) -> Client:
"""Create a qBittorrent client.""" """Create a qBittorrent client."""
client = Client(url, verify=verify_ssl)
client.login(username, password) client = Client(
# Get an arbitrary attribute to test if connection succeeds url, username=username, password=password, VERIFY_WEBUI_CERTIFICATE=verify_ssl
client.get_alternative_speed_status() )
client.auth_log_in(username, password)
return client return client
@ -31,23 +32,24 @@ def format_unix_timestamp(timestamp) -> str:
return dt_object.isoformat() return dt_object.isoformat()
def format_progress(torrent) -> str: def format_progress(torrent: TorrentDictionary) -> str:
"""Format the progress of a torrent.""" """Format the progress of a torrent."""
progress = torrent["progress"] progress = cast(float, torrent["progress"]) * 100
progress = float(progress) * 100
return f"{progress:.2f}" return f"{progress:.2f}"
def format_torrents(torrents: list[dict[str, Any]]) -> dict[str, dict[str, Any]]: def format_torrents(
torrents: TorrentInfoList,
) -> dict[str, dict[str, Any]]:
"""Format a list of torrents.""" """Format a list of torrents."""
value = {} value = {}
for torrent in torrents: for torrent in torrents:
value[torrent["name"]] = format_torrent(torrent) value[str(torrent["name"])] = format_torrent(torrent)
return value return value
def format_torrent(torrent) -> dict[str, Any]: def format_torrent(torrent: TorrentDictionary) -> dict[str, Any]:
"""Format a single torrent.""" """Format a single torrent."""
value = {} value = {}
value["id"] = torrent["hash"] value["id"] = torrent["hash"]
@ -55,6 +57,7 @@ def format_torrent(torrent) -> dict[str, Any]:
value["percent_done"] = format_progress(torrent) value["percent_done"] = format_progress(torrent)
value["status"] = torrent["state"] value["status"] = torrent["state"]
value["eta"] = seconds_to_hhmmss(torrent["eta"]) value["eta"] = seconds_to_hhmmss(torrent["eta"])
value["ratio"] = "{:.2f}".format(float(torrent["ratio"])) ratio = cast(float, torrent["ratio"])
value["ratio"] = f"{ratio:.2f}"
return value return value

View File

@ -7,5 +7,5 @@
"integration_type": "service", "integration_type": "service",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["qbittorrent"], "loggers": ["qbittorrent"],
"requirements": ["python-qbittorrent==0.4.3"] "requirements": ["qbittorrent-api==2024.2.59"]
} }

View File

@ -2,9 +2,10 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable from collections.abc import Callable, Mapping
from dataclasses import dataclass from dataclasses import dataclass
import logging import logging
from typing import Any, cast
from homeassistant.components.sensor import ( from homeassistant.components.sensor import (
SensorDeviceClass, SensorDeviceClass,
@ -35,8 +36,9 @@ SENSOR_TYPE_INACTIVE_TORRENTS = "inactive_torrents"
def get_state(coordinator: QBittorrentDataCoordinator) -> str: def get_state(coordinator: QBittorrentDataCoordinator) -> str:
"""Get current download/upload state.""" """Get current download/upload state."""
upload = coordinator.data["server_state"]["up_info_speed"] server_state = cast(Mapping, coordinator.data.get("server_state"))
download = coordinator.data["server_state"]["dl_info_speed"] upload = cast(int, server_state.get("up_info_speed"))
download = cast(int, server_state.get("dl_info_speed"))
if upload > 0 and download > 0: if upload > 0 and download > 0:
return STATE_UP_DOWN return STATE_UP_DOWN
@ -47,6 +49,18 @@ def get_state(coordinator: QBittorrentDataCoordinator) -> str:
return STATE_IDLE return STATE_IDLE
def get_dl(coordinator: QBittorrentDataCoordinator) -> int:
"""Get current download speed."""
server_state = cast(Mapping, coordinator.data.get("server_state"))
return cast(int, server_state.get("dl_info_speed"))
def get_up(coordinator: QBittorrentDataCoordinator) -> int:
"""Get current upload speed."""
server_state = cast(Mapping[str, Any], coordinator.data.get("server_state"))
return cast(int, server_state.get("up_info_speed"))
@dataclass(frozen=True, kw_only=True) @dataclass(frozen=True, kw_only=True)
class QBittorrentSensorEntityDescription(SensorEntityDescription): class QBittorrentSensorEntityDescription(SensorEntityDescription):
"""Entity description class for qBittorent sensors.""" """Entity description class for qBittorent sensors."""
@ -69,9 +83,7 @@ SENSOR_TYPES: tuple[QBittorrentSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfDataRate.BYTES_PER_SECOND, native_unit_of_measurement=UnitOfDataRate.BYTES_PER_SECOND,
suggested_display_precision=2, suggested_display_precision=2,
suggested_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND, suggested_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND,
value_fn=lambda coordinator: float( value_fn=get_dl,
coordinator.data["server_state"]["dl_info_speed"]
),
), ),
QBittorrentSensorEntityDescription( QBittorrentSensorEntityDescription(
key=SENSOR_TYPE_UPLOAD_SPEED, key=SENSOR_TYPE_UPLOAD_SPEED,
@ -80,9 +92,7 @@ SENSOR_TYPES: tuple[QBittorrentSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfDataRate.BYTES_PER_SECOND, native_unit_of_measurement=UnitOfDataRate.BYTES_PER_SECOND,
suggested_display_precision=2, suggested_display_precision=2,
suggested_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND, suggested_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND,
value_fn=lambda coordinator: float( value_fn=get_up,
coordinator.data["server_state"]["up_info_speed"]
),
), ),
QBittorrentSensorEntityDescription( QBittorrentSensorEntityDescription(
key=SENSOR_TYPE_ALL_TORRENTS, key=SENSOR_TYPE_ALL_TORRENTS,
@ -165,16 +175,12 @@ def count_torrents_in_states(
) -> int: ) -> int:
"""Count the number of torrents in specified states.""" """Count the number of torrents in specified states."""
# When torrents are not in the returned data, there are none, return 0. # When torrents are not in the returned data, there are none, return 0.
if "torrents" not in coordinator.data: try:
torrents = cast(Mapping[str, Mapping], coordinator.data.get("torrents"))
if not states:
return len(torrents)
return len(
[torrent for torrent in torrents.values() if torrent.get("state") in states]
)
except AttributeError:
return 0 return 0
if not states:
return len(coordinator.data["torrents"])
return len(
[
torrent
for torrent in coordinator.data["torrents"].values()
if torrent["state"] in states
]
)

View File

@ -84,6 +84,9 @@
}, },
"login_error": { "login_error": {
"message": "A login error occured. Please check you username and password." "message": "A login error occured. Please check you username and password."
},
"cannot_connect": {
"message": "Can't connect to QBittorrent, please check your configuration."
} }
} }
} }

View File

@ -2302,9 +2302,6 @@ python-otbr-api==2.6.0
# homeassistant.components.picnic # homeassistant.components.picnic
python-picnic-api==1.1.0 python-picnic-api==1.1.0
# homeassistant.components.qbittorrent
python-qbittorrent==0.4.3
# homeassistant.components.rabbitair # homeassistant.components.rabbitair
python-rabbitair==0.0.8 python-rabbitair==0.0.8
@ -2420,6 +2417,9 @@ pyzbar==0.1.7
# homeassistant.components.zerproc # homeassistant.components.zerproc
pyzerproc==0.4.8 pyzerproc==0.4.8
# homeassistant.components.qbittorrent
qbittorrent-api==2024.2.59
# homeassistant.components.qingping # homeassistant.components.qingping
qingping-ble==0.10.0 qingping-ble==0.10.0

View File

@ -1799,9 +1799,6 @@ python-otbr-api==2.6.0
# homeassistant.components.picnic # homeassistant.components.picnic
python-picnic-api==1.1.0 python-picnic-api==1.1.0
# homeassistant.components.qbittorrent
python-qbittorrent==0.4.3
# homeassistant.components.rabbitair # homeassistant.components.rabbitair
python-rabbitair==0.0.8 python-rabbitair==0.0.8
@ -1893,6 +1890,9 @@ pyyardian==1.1.1
# homeassistant.components.zerproc # homeassistant.components.zerproc
pyzerproc==0.4.8 pyzerproc==0.4.8
# homeassistant.components.qbittorrent
qbittorrent-api==2024.2.59
# homeassistant.components.qingping # homeassistant.components.qingping
qingping-ble==0.10.0 qingping-ble==0.10.0

View File

@ -59,8 +59,7 @@ async def test_flow_user(hass: HomeAssistant, mock_api: requests_mock.Mocker) ->
# Test flow with wrong creds, fail with invalid_auth # Test flow with wrong creds, fail with invalid_auth
with requests_mock.Mocker() as mock: with requests_mock.Mocker() as mock:
mock.get(f"{USER_INPUT[CONF_URL]}/api/v2/transfer/speedLimitsMode") mock.head(USER_INPUT[CONF_URL])
mock.get(f"{USER_INPUT[CONF_URL]}/api/v2/app/preferences", status_code=403)
mock.post( mock.post(
f"{USER_INPUT[CONF_URL]}/api/v2/auth/login", f"{USER_INPUT[CONF_URL]}/api/v2/auth/login",
text="Wrong username/password", text="Wrong username/password",
@ -74,11 +73,18 @@ async def test_flow_user(hass: HomeAssistant, mock_api: requests_mock.Mocker) ->
assert result["errors"] == {"base": "invalid_auth"} assert result["errors"] == {"base": "invalid_auth"}
# Test flow with proper input, succeed # Test flow with proper input, succeed
result = await hass.config_entries.flow.async_configure( with requests_mock.Mocker() as mock:
result["flow_id"], USER_INPUT mock.head(USER_INPUT[CONF_URL])
) mock.post(
await hass.async_block_till_done() f"{USER_INPUT[CONF_URL]}/api/v2/auth/login",
assert result["type"] is FlowResultType.CREATE_ENTRY text="Ok.",
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], USER_INPUT
)
await hass.async_block_till_done()
assert result["type"] == FlowResultType.CREATE_ENTRY
assert result["data"] == { assert result["data"] == {
CONF_URL: "http://localhost:8080", CONF_URL: "http://localhost:8080",
CONF_USERNAME: "user", CONF_USERNAME: "user",