Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Change qBittorrent lib to qbittorrentapi #113394

Merged
merged 16 commits into from
Jun 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions homeassistant/components/qbittorrent/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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
Comment on lines +122 to +123
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't this cause us to start a reauth flow instead? But maybe that's for a follow-up?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe in another PR because I need to take a better look to the documentation, as I have seen, we need to update the Config flow to make this work right ?

Copy link
Contributor

@emontnemery emontnemery Jun 10, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, that's right, the config flow needs to be updated. It's fine to do that in a separate PR.

except APIConnectionError as exc:
raise ConfigEntryNotReady("Fail to connect to qBittorrent") from exc

coordinator = QBittorrentDataCoordinator(hass, client)

await coordinator.async_config_entry_first_refresh()
Expand Down
7 changes: 3 additions & 4 deletions homeassistant/components/qbittorrent/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,7 @@
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
Expand Down Expand Up @@ -46,9 +45,9 @@ async def async_step_user(
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)
Expand Down
41 changes: 28 additions & 13 deletions homeassistant/components/qbittorrent/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,16 @@

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
Expand All @@ -18,8 +24,8 @@
_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."""
Expand All @@ -39,22 +45,31 @@ def __init__(self, hass: HomeAssistant, client: Client) -> None:
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
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"
edenhaus marked this conversation as resolved.
Show resolved Hide resolved
) from exc
except APIConnectionError as exc:
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="cannot_connect"
Sebclem marked this conversation as resolved.
Show resolved Hide resolved
) from exc

async def get_torrents(self, torrent_filter: str) -> list[dict[str, Any]]:
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(filter=torrent_filter)
lambda: self.client.torrents_info(torrent_filter)
)
except LoginRequired as exc:
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
29 changes: 16 additions & 13 deletions homeassistant/components/qbittorrent/helpers.py
Original file line number Diff line number Diff line change
@@ -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


Expand All @@ -31,30 +32,32 @@ 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"]
value["added_date"] = format_unix_timestamp(torrent["added_on"])
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
2 changes: 1 addition & 1 deletion homeassistant/components/qbittorrent/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
}
48 changes: 27 additions & 21 deletions homeassistant/components/qbittorrent/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -35,8 +36,9 @@

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
Expand All @@ -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."""
Expand All @@ -69,9 +83,7 @@ class QBittorrentSensorEntityDescription(SensorEntityDescription):
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,
Expand All @@ -80,9 +92,7 @@ class QBittorrentSensorEntityDescription(SensorEntityDescription):
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,
Expand Down Expand Up @@ -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
]
)
3 changes: 3 additions & 0 deletions homeassistant/components/qbittorrent/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
}
}
6 changes: 3 additions & 3 deletions requirements_all.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
6 changes: 3 additions & 3 deletions requirements_test_all.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
20 changes: 13 additions & 7 deletions tests/components/qbittorrent/test_config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down