Add get_torrents service to qBittorrent integration (#106501)

* Upgrade QBittorrent integration to show torrents

This brings the QBittorrent integration to be more in line with the Transmission integration. It updates how the integration is written, along with adding sensors for Active Torrents, Inactive Torrents, Paused Torrents, Total Torrents, Seeding Torrents, Started Torrents.

* Remove unused stuff

* Correct name in comments

* Make get torrents a service with a response

* Add new sensors

* remove service

* Add service with response to get torrents list

This adds a service with a response to be able to get the list of torrents within qBittorrent

* update

* update from rebase

* Update strings.json

* Update helpers.py

* Update to satisfy lint

* add func comment

* fix lint issues

* another update attempt

* Fix helpers

* Remove unneccesary part in services.yaml and add translations

* Fix return

* Add tests

* Fix test

* Improve tests

* Fix issue from rebase

* Add icon for get_torrents service

* Make get torrents a service with a response

* remove service

* Add service with response to get torrents list

This adds a service with a response to be able to get the list of torrents within qBittorrent

* Update to satisfy lint

* Handle multiple installed integrations

* fix lint issue

* Set return types for helper methods

* Create the service method in async_setup

* Add CONFIG_SCHEMA

* Add get_all_torrents service

* fix lint issues

* Add return types and ServiceValidationError(s)

* Fix naming

* Update translations

* Fix tests
This commit is contained in:
Joe Neuman 2024-04-18 22:56:37 -07:00 committed by GitHub
parent 6b6324f48e
commit 4cce75177a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 349 additions and 7 deletions

View file

@ -1,29 +1,111 @@
"""The qbittorrent component.""" """The qbittorrent component."""
import logging import logging
from typing import Any
from qbittorrent.client import LoginRequired from qbittorrent.client import LoginRequired
from requests.exceptions import RequestException 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 (
ATTR_DEVICE_ID,
CONF_PASSWORD, CONF_PASSWORD,
CONF_URL, CONF_URL,
CONF_USERNAME, CONF_USERNAME,
CONF_VERIFY_SSL, CONF_VERIFY_SSL,
Platform, Platform,
) )
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse
from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryNotReady, ServiceValidationError
from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN from .const import (
DOMAIN,
SERVICE_GET_ALL_TORRENTS,
SERVICE_GET_TORRENTS,
STATE_ATTR_ALL_TORRENTS,
STATE_ATTR_TORRENTS,
TORRENT_FILTER,
)
from .coordinator import QBittorrentDataCoordinator from .coordinator import QBittorrentDataCoordinator
from .helpers import setup_client from .helpers import format_torrents, setup_client
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
PLATFORMS = [Platform.SENSOR] PLATFORMS = [Platform.SENSOR]
CONF_ENTRY = "entry"
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up qBittorrent services."""
async def handle_get_torrents(service_call: ServiceCall) -> dict[str, Any] | None:
device_registry = dr.async_get(hass)
device_entry = device_registry.async_get(service_call.data[ATTR_DEVICE_ID])
if device_entry is None:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="invalid_device",
translation_placeholders={
"device_id": service_call.data[ATTR_DEVICE_ID]
},
)
entry_id = None
for key, value in device_entry.identifiers:
if key == DOMAIN:
entry_id = value
break
else:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="invalid_entry_id",
translation_placeholders={"device_id": entry_id or ""},
)
coordinator: QBittorrentDataCoordinator = hass.data[DOMAIN][entry_id]
items = await coordinator.get_torrents(service_call.data[TORRENT_FILTER])
info = format_torrents(items)
return {
STATE_ATTR_TORRENTS: info,
}
hass.services.async_register(
DOMAIN,
SERVICE_GET_TORRENTS,
handle_get_torrents,
supports_response=SupportsResponse.ONLY,
)
async def handle_get_all_torrents(
service_call: ServiceCall,
) -> dict[str, Any] | None:
torrents = {}
for key, value in hass.data[DOMAIN].items():
coordinator: QBittorrentDataCoordinator = value
items = await coordinator.get_torrents(service_call.data[TORRENT_FILTER])
torrents[key] = format_torrents(items)
return {
STATE_ATTR_ALL_TORRENTS: torrents,
}
hass.services.async_register(
DOMAIN,
SERVICE_GET_ALL_TORRENTS,
handle_get_all_torrents,
supports_response=SupportsResponse.ONLY,
)
return True
async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Set up qBittorrent from a config entry.""" """Set up qBittorrent from a config entry."""

View file

@ -7,6 +7,13 @@ DOMAIN: Final = "qbittorrent"
DEFAULT_NAME = "qBittorrent" DEFAULT_NAME = "qBittorrent"
DEFAULT_URL = "http://127.0.0.1:8080" DEFAULT_URL = "http://127.0.0.1:8080"
STATE_ATTR_TORRENTS = "torrents"
STATE_ATTR_ALL_TORRENTS = "all_torrents"
STATE_UP_DOWN = "up_down" STATE_UP_DOWN = "up_down"
STATE_SEEDING = "seeding" STATE_SEEDING = "seeding"
STATE_DOWNLOADING = "downloading" STATE_DOWNLOADING = "downloading"
SERVICE_GET_TORRENTS = "get_torrents"
SERVICE_GET_ALL_TORRENTS = "get_all_torrents"
TORRENT_FILTER = "torrent_filter"

View file

@ -10,7 +10,7 @@ from qbittorrent import Client
from qbittorrent.client import LoginRequired from qbittorrent.client import LoginRequired
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import DOMAIN from .const import DOMAIN
@ -19,11 +19,18 @@ _LOGGER = logging.getLogger(__name__)
class QBittorrentDataCoordinator(DataUpdateCoordinator[dict[str, Any]]): class QBittorrentDataCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""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."""
self.client = client self.client = client
# self.main_data: dict[str, int] = {}
self.total_torrents: dict[str, int] = {}
self.active_torrents: dict[str, int] = {}
self.inactive_torrents: dict[str, int] = {}
self.paused_torrents: dict[str, int] = {}
self.seeding_torrents: dict[str, int] = {}
self.started_torrents: dict[str, int] = {}
super().__init__( super().__init__(
hass, hass,
@ -33,7 +40,21 @@ class QBittorrentDataCoordinator(DataUpdateCoordinator[dict[str, Any]]):
) )
async def _async_update_data(self) -> dict[str, Any]: async def _async_update_data(self) -> dict[str, Any]:
"""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_main_data)
except LoginRequired as exc: except LoginRequired as exc:
raise ConfigEntryError("Invalid authentication") from 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(
translation_domain=DOMAIN, translation_key="login_error"
) from exc
return torrents

View file

@ -1,5 +1,8 @@
"""Helper functions for qBittorrent.""" """Helper functions for qBittorrent."""
from datetime import UTC, datetime
from typing import Any
from qbittorrent.client import Client from qbittorrent.client import Client
@ -10,3 +13,48 @@ def setup_client(url: str, username: str, password: str, verify_ssl: bool) -> Cl
# Get an arbitrary attribute to test if connection succeeds # Get an arbitrary attribute to test if connection succeeds
client.get_alternative_speed_status() client.get_alternative_speed_status()
return client return client
def seconds_to_hhmmss(seconds) -> str:
"""Convert seconds to HH:MM:SS format."""
if seconds == 8640000:
return "None"
minutes, seconds = divmod(seconds, 60)
hours, minutes = divmod(minutes, 60)
return f"{int(hours):02}:{int(minutes):02}:{int(seconds):02}"
def format_unix_timestamp(timestamp) -> str:
"""Format a UNIX timestamp to a human-readable date."""
dt_object = datetime.fromtimestamp(timestamp, tz=UTC)
return dt_object.isoformat()
def format_progress(torrent) -> str:
"""Format the progress of a torrent."""
progress = torrent["progress"]
progress = float(progress) * 100
return f"{progress:.2f}"
def format_torrents(torrents: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
"""Format a list of torrents."""
value = {}
for torrent in torrents:
value[torrent["name"]] = format_torrent(torrent)
return value
def format_torrent(torrent) -> 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"]))
return value

View file

@ -8,5 +8,9 @@
"default": "mdi:cloud-upload" "default": "mdi:cloud-upload"
} }
} }
},
"services": {
"get_torrents": "mdi:file-arrow-up-down-outline",
"get_all_torrents": "mdi:file-arrow-up-down-outline"
} }
} }

View file

@ -0,0 +1,35 @@
get_torrents:
fields:
device_id:
required: true
selector:
device:
integration: qbittorrent
torrent_filter:
required: true
example: "all"
default: "all"
selector:
select:
options:
- "active"
- "inactive"
- "paused"
- "all"
- "seeding"
- "started"
get_all_torrents:
fields:
torrent_filter:
required: true
example: "all"
default: "all"
selector:
select:
options:
- "active"
- "inactive"
- "paused"
- "all"
- "seeding"
- "started"

View file

@ -48,5 +48,42 @@
"name": "All torrents" "name": "All torrents"
} }
} }
},
"services": {
"get_torrents": {
"name": "Get torrents",
"description": "Gets a list of current torrents",
"fields": {
"device_id": {
"name": "[%key:common::config_flow::data::device%]",
"description": "Which service to grab the list from"
},
"torrent_filter": {
"name": "Torrent filter",
"description": "What kind of torrents you want to return, such as All or Active."
}
}
},
"get_all_torrents": {
"name": "Get all torrents",
"description": "Gets a list of current torrents from all instances of qBittorrent",
"fields": {
"torrent_filter": {
"name": "Torrent filter",
"description": "What kind of torrents you want to return, such as All or Active."
}
}
}
},
"exceptions": {
"invalid_device": {
"message": "No device with id {device_id} was found"
},
"invalid_entry_id": {
"message": "No entry with id {device_id} was found"
},
"login_error": {
"message": "A login error occured. Please check you username and password."
}
} }
} }

View file

@ -0,0 +1,108 @@
"""Test the qBittorrent helpers."""
from homeassistant.components.qbittorrent.helpers import (
format_progress,
format_torrent,
format_torrents,
format_unix_timestamp,
seconds_to_hhmmss,
)
from homeassistant.core import HomeAssistant
async def test_seconds_to_hhmmss(
hass: HomeAssistant,
) -> None:
"""Test the seconds_to_hhmmss function."""
assert seconds_to_hhmmss(8640000) == "None"
assert seconds_to_hhmmss(3661) == "01:01:01"
async def test_format_unix_timestamp(
hass: HomeAssistant,
) -> None:
"""Test the format_unix_timestamp function."""
assert format_unix_timestamp(1640995200) == "2022-01-01T00:00:00+00:00"
async def test_format_progress(
hass: HomeAssistant,
) -> None:
"""Test the format_progress function."""
assert format_progress({"progress": 0.5}) == "50.00"
async def test_format_torrents(
hass: HomeAssistant,
) -> None:
"""Test the format_torrents function."""
torrents_data = [
{
"name": "torrent1",
"hash": "hash1",
"added_on": 1640995200,
"progress": 0.5,
"state": "paused",
"eta": 86400,
"ratio": 1.0,
},
{
"name": "torrent2",
"hash": "hash1",
"added_on": 1640995200,
"progress": 0.5,
"state": "paused",
"eta": 86400,
"ratio": 1.0,
},
]
expected_result = {
"torrent1": {
"id": "hash1",
"added_date": "2022-01-01T00:00:00+00:00",
"percent_done": "50.00",
"status": "paused",
"eta": "24:00:00",
"ratio": "1.00",
},
"torrent2": {
"id": "hash1",
"added_date": "2022-01-01T00:00:00+00:00",
"percent_done": "50.00",
"status": "paused",
"eta": "24:00:00",
"ratio": "1.00",
},
}
result = format_torrents(torrents_data)
assert result == expected_result
async def test_format_torrent(
hass: HomeAssistant,
) -> None:
"""Test the format_torrent function."""
torrent_data = {
"hash": "hash1",
"added_on": 1640995200,
"progress": 0.5,
"state": "paused",
"eta": 86400,
"ratio": 1.0,
}
expected_result = {
"id": "hash1",
"added_date": "2022-01-01T00:00:00+00:00",
"percent_done": "50.00",
"status": "paused",
"eta": "24:00:00",
"ratio": "1.00",
}
result = format_torrent(torrent_data)
assert result == expected_result