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:
parent
6b6324f48e
commit
4cce75177a
8 changed files with 349 additions and 7 deletions
|
@ -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."""
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
35
homeassistant/components/qbittorrent/services.yaml
Normal file
35
homeassistant/components/qbittorrent/services.yaml
Normal 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"
|
|
@ -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."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
108
tests/components/qbittorrent/test_helpers.py
Normal file
108
tests/components/qbittorrent/test_helpers.py
Normal 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
|
Loading…
Add table
Reference in a new issue