mirror of
https://github.com/home-assistant/core.git
synced 2025-04-23 16:57:53 +00:00
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:
parent
960d1289ef
commit
80b2b05bd8
@ -3,8 +3,7 @@
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from qbittorrent.client import LoginRequired
|
||||
from requests.exceptions import RequestException
|
||||
from qbittorrentapi import APIConnectionError, Forbidden403Error, LoginFailed
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
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_VERIFY_SSL],
|
||||
)
|
||||
except LoginRequired as err:
|
||||
except LoginFailed as err:
|
||||
raise ConfigEntryNotReady("Invalid credentials") from err
|
||||
except RequestException as err:
|
||||
raise ConfigEntryNotReady("Failed to connect") from err
|
||||
except Forbidden403Error as 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)
|
||||
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
@ -5,8 +5,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from qbittorrent.client import LoginRequired
|
||||
from requests.exceptions import RequestException
|
||||
from qbittorrentapi import APIConnectionError, Forbidden403Error, LoginFailed
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
@ -46,9 +45,9 @@ class QbittorrentConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
user_input[CONF_PASSWORD],
|
||||
user_input[CONF_VERIFY_SSL],
|
||||
)
|
||||
except LoginRequired:
|
||||
except (LoginFailed, Forbidden403Error):
|
||||
errors = {"base": "invalid_auth"}
|
||||
except RequestException:
|
||||
except APIConnectionError:
|
||||
errors = {"base": "cannot_connect"}
|
||||
else:
|
||||
return self.async_create_entry(title=DEFAULT_NAME, data=user_input)
|
||||
|
@ -4,10 +4,16 @@ from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from qbittorrent import Client
|
||||
from qbittorrent.client import LoginRequired
|
||||
from qbittorrentapi import (
|
||||
APIConnectionError,
|
||||
Client,
|
||||
Forbidden403Error,
|
||||
LoginFailed,
|
||||
SyncMainDataDictionary,
|
||||
TorrentInfoList,
|
||||
)
|
||||
from qbittorrentapi.torrents import TorrentStatusesT
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
@ -18,8 +24,8 @@ from .const import DOMAIN
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class QBittorrentDataCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
"""Coordinator for updating qBittorrent data."""
|
||||
class QBittorrentDataCoordinator(DataUpdateCoordinator[SyncMainDataDictionary]):
|
||||
"""Coordinator for updating QBittorrent data."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, client: Client) -> None:
|
||||
"""Initialize coordinator."""
|
||||
@ -39,22 +45,31 @@ class QBittorrentDataCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
update_interval=timedelta(seconds=30),
|
||||
)
|
||||
|
||||
async def _async_update_data(self) -> dict[str, Any]:
|
||||
"""Async method to update QBittorrent data."""
|
||||
async def _async_update_data(self) -> SyncMainDataDictionary:
|
||||
try:
|
||||
return await self.hass.async_add_executor_job(self.client.sync_main_data)
|
||||
except LoginRequired 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:
|
||||
return await self.hass.async_add_executor_job(self.client.sync_maindata)
|
||||
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
|
||||
|
||||
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
|
||||
|
@ -1,17 +1,18 @@
|
||||
"""Helper functions for qBittorrent."""
|
||||
|
||||
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:
|
||||
"""Create a qBittorrent client."""
|
||||
client = Client(url, verify=verify_ssl)
|
||||
client.login(username, password)
|
||||
# Get an arbitrary attribute to test if connection succeeds
|
||||
client.get_alternative_speed_status()
|
||||
|
||||
client = Client(
|
||||
url, username=username, password=password, VERIFY_WEBUI_CERTIFICATE=verify_ssl
|
||||
)
|
||||
client.auth_log_in(username, password)
|
||||
return client
|
||||
|
||||
|
||||
@ -31,23 +32,24 @@ def format_unix_timestamp(timestamp) -> str:
|
||||
return dt_object.isoformat()
|
||||
|
||||
|
||||
def format_progress(torrent) -> str:
|
||||
def format_progress(torrent: TorrentDictionary) -> str:
|
||||
"""Format the progress of a torrent."""
|
||||
progress = torrent["progress"]
|
||||
progress = float(progress) * 100
|
||||
progress = cast(float, torrent["progress"]) * 100
|
||||
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."""
|
||||
value = {}
|
||||
for torrent in torrents:
|
||||
value[torrent["name"]] = format_torrent(torrent)
|
||||
value[str(torrent["name"])] = format_torrent(torrent)
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def format_torrent(torrent) -> dict[str, Any]:
|
||||
def format_torrent(torrent: TorrentDictionary) -> dict[str, Any]:
|
||||
"""Format a single torrent."""
|
||||
value = {}
|
||||
value["id"] = torrent["hash"]
|
||||
@ -55,6 +57,7 @@ def format_torrent(torrent) -> dict[str, Any]:
|
||||
value["percent_done"] = format_progress(torrent)
|
||||
value["status"] = torrent["state"]
|
||||
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
|
||||
|
@ -7,5 +7,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["qbittorrent"],
|
||||
"requirements": ["python-qbittorrent==0.4.3"]
|
||||
"requirements": ["qbittorrent-api==2024.2.59"]
|
||||
}
|
||||
|
@ -2,9 +2,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Callable, Mapping
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import Any, cast
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
@ -35,8 +36,9 @@ SENSOR_TYPE_INACTIVE_TORRENTS = "inactive_torrents"
|
||||
|
||||
def get_state(coordinator: QBittorrentDataCoordinator) -> str:
|
||||
"""Get current download/upload state."""
|
||||
upload = coordinator.data["server_state"]["up_info_speed"]
|
||||
download = coordinator.data["server_state"]["dl_info_speed"]
|
||||
server_state = cast(Mapping, coordinator.data.get("server_state"))
|
||||
upload = cast(int, server_state.get("up_info_speed"))
|
||||
download = cast(int, server_state.get("dl_info_speed"))
|
||||
|
||||
if upload > 0 and download > 0:
|
||||
return STATE_UP_DOWN
|
||||
@ -47,6 +49,18 @@ def get_state(coordinator: QBittorrentDataCoordinator) -> str:
|
||||
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)
|
||||
class QBittorrentSensorEntityDescription(SensorEntityDescription):
|
||||
"""Entity description class for qBittorent sensors."""
|
||||
@ -69,9 +83,7 @@ SENSOR_TYPES: tuple[QBittorrentSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfDataRate.BYTES_PER_SECOND,
|
||||
suggested_display_precision=2,
|
||||
suggested_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND,
|
||||
value_fn=lambda coordinator: float(
|
||||
coordinator.data["server_state"]["dl_info_speed"]
|
||||
),
|
||||
value_fn=get_dl,
|
||||
),
|
||||
QBittorrentSensorEntityDescription(
|
||||
key=SENSOR_TYPE_UPLOAD_SPEED,
|
||||
@ -80,9 +92,7 @@ SENSOR_TYPES: tuple[QBittorrentSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfDataRate.BYTES_PER_SECOND,
|
||||
suggested_display_precision=2,
|
||||
suggested_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND,
|
||||
value_fn=lambda coordinator: float(
|
||||
coordinator.data["server_state"]["up_info_speed"]
|
||||
),
|
||||
value_fn=get_up,
|
||||
),
|
||||
QBittorrentSensorEntityDescription(
|
||||
key=SENSOR_TYPE_ALL_TORRENTS,
|
||||
@ -165,16 +175,12 @@ def count_torrents_in_states(
|
||||
) -> int:
|
||||
"""Count the number of torrents in specified states."""
|
||||
# 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
|
||||
|
||||
if not states:
|
||||
return len(coordinator.data["torrents"])
|
||||
|
||||
return len(
|
||||
[
|
||||
torrent
|
||||
for torrent in coordinator.data["torrents"].values()
|
||||
if torrent["state"] in states
|
||||
]
|
||||
)
|
||||
|
@ -84,6 +84,9 @@
|
||||
},
|
||||
"login_error": {
|
||||
"message": "A login error occured. Please check you username and password."
|
||||
},
|
||||
"cannot_connect": {
|
||||
"message": "Can't connect to QBittorrent, please check your configuration."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -2302,9 +2302,6 @@ python-otbr-api==2.6.0
|
||||
# homeassistant.components.picnic
|
||||
python-picnic-api==1.1.0
|
||||
|
||||
# homeassistant.components.qbittorrent
|
||||
python-qbittorrent==0.4.3
|
||||
|
||||
# homeassistant.components.rabbitair
|
||||
python-rabbitair==0.0.8
|
||||
|
||||
@ -2420,6 +2417,9 @@ pyzbar==0.1.7
|
||||
# homeassistant.components.zerproc
|
||||
pyzerproc==0.4.8
|
||||
|
||||
# homeassistant.components.qbittorrent
|
||||
qbittorrent-api==2024.2.59
|
||||
|
||||
# homeassistant.components.qingping
|
||||
qingping-ble==0.10.0
|
||||
|
||||
|
@ -1799,9 +1799,6 @@ python-otbr-api==2.6.0
|
||||
# homeassistant.components.picnic
|
||||
python-picnic-api==1.1.0
|
||||
|
||||
# homeassistant.components.qbittorrent
|
||||
python-qbittorrent==0.4.3
|
||||
|
||||
# homeassistant.components.rabbitair
|
||||
python-rabbitair==0.0.8
|
||||
|
||||
@ -1893,6 +1890,9 @@ pyyardian==1.1.1
|
||||
# homeassistant.components.zerproc
|
||||
pyzerproc==0.4.8
|
||||
|
||||
# homeassistant.components.qbittorrent
|
||||
qbittorrent-api==2024.2.59
|
||||
|
||||
# homeassistant.components.qingping
|
||||
qingping-ble==0.10.0
|
||||
|
||||
|
@ -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
|
||||
with requests_mock.Mocker() as mock:
|
||||
mock.get(f"{USER_INPUT[CONF_URL]}/api/v2/transfer/speedLimitsMode")
|
||||
mock.get(f"{USER_INPUT[CONF_URL]}/api/v2/app/preferences", status_code=403)
|
||||
mock.head(USER_INPUT[CONF_URL])
|
||||
mock.post(
|
||||
f"{USER_INPUT[CONF_URL]}/api/v2/auth/login",
|
||||
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"}
|
||||
|
||||
# Test flow with proper input, succeed
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], USER_INPUT
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
with requests_mock.Mocker() as mock:
|
||||
mock.head(USER_INPUT[CONF_URL])
|
||||
mock.post(
|
||||
f"{USER_INPUT[CONF_URL]}/api/v2/auth/login",
|
||||
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"] == {
|
||||
CONF_URL: "http://localhost:8080",
|
||||
CONF_USERNAME: "user",
|
||||
|
Loading…
x
Reference in New Issue
Block a user