mirror of
https://github.com/Electric-Special/ha-core.git
synced 2026-03-21 03:03:17 +01:00
Add Transmission get_torrents service and codeowner (#159211)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
4
CODEOWNERS
generated
4
CODEOWNERS
generated
@@ -1697,8 +1697,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/trafikverket_train/ @gjohansson-ST
|
||||
/homeassistant/components/trafikverket_weatherstation/ @gjohansson-ST
|
||||
/tests/components/trafikverket_weatherstation/ @gjohansson-ST
|
||||
/homeassistant/components/transmission/ @engrbm87 @JPHutchins
|
||||
/tests/components/transmission/ @engrbm87 @JPHutchins
|
||||
/homeassistant/components/transmission/ @engrbm87 @JPHutchins @andrew-codechimp
|
||||
/tests/components/transmission/ @engrbm87 @JPHutchins @andrew-codechimp
|
||||
/homeassistant/components/trend/ @jpbede
|
||||
/tests/components/trend/ @jpbede
|
||||
/homeassistant/components/triggercmd/ @rvmey
|
||||
|
||||
@@ -40,9 +40,12 @@ STATE_ATTR_TORRENT_INFO = "torrent_info"
|
||||
|
||||
ATTR_DELETE_DATA = "delete_data"
|
||||
ATTR_TORRENT = "torrent"
|
||||
ATTR_TORRENTS = "torrents"
|
||||
ATTR_DOWNLOAD_PATH = "download_path"
|
||||
ATTR_TORRENT_FILTER = "torrent_filter"
|
||||
|
||||
SERVICE_ADD_TORRENT = "add_torrent"
|
||||
SERVICE_GET_TORRENTS = "get_torrents"
|
||||
SERVICE_REMOVE_TORRENT = "remove_torrent"
|
||||
SERVICE_START_TORRENT = "start_torrent"
|
||||
SERVICE_STOP_TORRENT = "stop_torrent"
|
||||
@@ -54,3 +57,14 @@ EVENT_DOWNLOADED_TORRENT = "transmission_downloaded_torrent"
|
||||
STATE_UP_DOWN = "up_down"
|
||||
STATE_SEEDING = "seeding"
|
||||
STATE_DOWNLOADING = "downloading"
|
||||
|
||||
FILTER_MODES: dict[str, list[str] | None] = {
|
||||
"started": ["downloading"],
|
||||
"completed": ["seeding"],
|
||||
"paused": ["stopped"],
|
||||
"active": [
|
||||
"seeding",
|
||||
"downloading",
|
||||
],
|
||||
"all": None,
|
||||
}
|
||||
|
||||
45
homeassistant/components/transmission/helpers.py
Normal file
45
homeassistant/components/transmission/helpers.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""Helper functions for Transmission."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from transmission_rpc.torrent import Torrent
|
||||
|
||||
|
||||
def format_torrent(torrent: Torrent) -> dict[str, Any]:
|
||||
"""Format a single torrent."""
|
||||
value: dict[str, Any] = {}
|
||||
|
||||
value["id"] = torrent.id
|
||||
value["name"] = torrent.name
|
||||
value["status"] = torrent.status.value
|
||||
value["percent_done"] = f"{torrent.percent_done * 100:.2f}%"
|
||||
value["ratio"] = f"{torrent.ratio:.2f}"
|
||||
value["eta"] = str(torrent.eta) if torrent.eta else None
|
||||
value["added_date"] = torrent.added_date.isoformat()
|
||||
value["done_date"] = torrent.done_date.isoformat() if torrent.done_date else None
|
||||
value["download_dir"] = torrent.download_dir
|
||||
value["labels"] = torrent.labels
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def filter_torrents(
|
||||
torrents: list[Torrent], statuses: list[str] | None = None
|
||||
) -> list[Torrent]:
|
||||
"""Filter torrents based on the statuses provided."""
|
||||
return [
|
||||
torrent
|
||||
for torrent in torrents
|
||||
if statuses is None or torrent.status in statuses
|
||||
]
|
||||
|
||||
|
||||
def format_torrents(
|
||||
torrents: list[Torrent],
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
"""Format a list of torrents."""
|
||||
value = {}
|
||||
for torrent in torrents:
|
||||
value[torrent.name] = format_torrent(torrent)
|
||||
|
||||
return value
|
||||
@@ -42,6 +42,9 @@
|
||||
"add_torrent": {
|
||||
"service": "mdi:download"
|
||||
},
|
||||
"get_torrents": {
|
||||
"service": "mdi:file-arrow-up-down-outline"
|
||||
},
|
||||
"remove_torrent": {
|
||||
"service": "mdi:download-off"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"domain": "transmission",
|
||||
"name": "Transmission",
|
||||
"codeowners": ["@engrbm87", "@JPHutchins"],
|
||||
"codeowners": ["@engrbm87", "@JPHutchins", "@andrew-codechimp"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/transmission",
|
||||
"integration_type": "service",
|
||||
|
||||
@@ -7,8 +7,6 @@ from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from transmission_rpc.torrent import Torrent
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
@@ -20,6 +18,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.typing import StateType
|
||||
|
||||
from .const import (
|
||||
FILTER_MODES,
|
||||
STATE_ATTR_TORRENT_INFO,
|
||||
STATE_DOWNLOADING,
|
||||
STATE_SEEDING,
|
||||
@@ -28,20 +27,10 @@ from .const import (
|
||||
)
|
||||
from .coordinator import TransmissionConfigEntry, TransmissionDataUpdateCoordinator
|
||||
from .entity import TransmissionEntity
|
||||
from .helpers import filter_torrents
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
MODES: dict[str, list[str] | None] = {
|
||||
"started_torrents": ["downloading"],
|
||||
"completed_torrents": ["seeding"],
|
||||
"paused_torrents": ["stopped"],
|
||||
"active_torrents": [
|
||||
"seeding",
|
||||
"downloading",
|
||||
],
|
||||
"total_torrents": None,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class TransmissionSensorEntityDescription(SensorEntityDescription):
|
||||
@@ -84,7 +73,7 @@ SENSOR_TYPES: tuple[TransmissionSensorEntityDescription, ...] = (
|
||||
translation_key="active_torrents",
|
||||
val_func=lambda coordinator: coordinator.data.active_torrent_count,
|
||||
extra_state_attr_func=lambda coordinator: _torrents_info_attr(
|
||||
coordinator=coordinator, key="active_torrents"
|
||||
coordinator=coordinator, key="active"
|
||||
),
|
||||
),
|
||||
TransmissionSensorEntityDescription(
|
||||
@@ -92,7 +81,7 @@ SENSOR_TYPES: tuple[TransmissionSensorEntityDescription, ...] = (
|
||||
translation_key="paused_torrents",
|
||||
val_func=lambda coordinator: coordinator.data.paused_torrent_count,
|
||||
extra_state_attr_func=lambda coordinator: _torrents_info_attr(
|
||||
coordinator=coordinator, key="paused_torrents"
|
||||
coordinator=coordinator, key="paused"
|
||||
),
|
||||
),
|
||||
TransmissionSensorEntityDescription(
|
||||
@@ -100,27 +89,27 @@ SENSOR_TYPES: tuple[TransmissionSensorEntityDescription, ...] = (
|
||||
translation_key="total_torrents",
|
||||
val_func=lambda coordinator: coordinator.data.torrent_count,
|
||||
extra_state_attr_func=lambda coordinator: _torrents_info_attr(
|
||||
coordinator=coordinator, key="total_torrents"
|
||||
coordinator=coordinator, key="total"
|
||||
),
|
||||
),
|
||||
TransmissionSensorEntityDescription(
|
||||
key="completed_torrents",
|
||||
translation_key="completed_torrents",
|
||||
val_func=lambda coordinator: len(
|
||||
_filter_torrents(coordinator.torrents, MODES["completed_torrents"])
|
||||
filter_torrents(coordinator.torrents, FILTER_MODES["completed"])
|
||||
),
|
||||
extra_state_attr_func=lambda coordinator: _torrents_info_attr(
|
||||
coordinator=coordinator, key="completed_torrents"
|
||||
coordinator=coordinator, key="completed"
|
||||
),
|
||||
),
|
||||
TransmissionSensorEntityDescription(
|
||||
key="started_torrents",
|
||||
translation_key="started_torrents",
|
||||
val_func=lambda coordinator: len(
|
||||
_filter_torrents(coordinator.torrents, MODES["started_torrents"])
|
||||
filter_torrents(coordinator.torrents, FILTER_MODES["started"])
|
||||
),
|
||||
extra_state_attr_func=lambda coordinator: _torrents_info_attr(
|
||||
coordinator=coordinator, key="started_torrents"
|
||||
coordinator=coordinator, key="started"
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -169,21 +158,11 @@ def get_state(upload: int, download: int) -> str:
|
||||
return STATE_IDLE
|
||||
|
||||
|
||||
def _filter_torrents(
|
||||
torrents: list[Torrent], statuses: list[str] | None = None
|
||||
) -> list[Torrent]:
|
||||
return [
|
||||
torrent
|
||||
for torrent in torrents
|
||||
if statuses is None or torrent.status in statuses
|
||||
]
|
||||
|
||||
|
||||
def _torrents_info_attr(
|
||||
coordinator: TransmissionDataUpdateCoordinator, key: str
|
||||
) -> dict[str, Any]:
|
||||
infos = {}
|
||||
torrents = _filter_torrents(coordinator.torrents, MODES[key])
|
||||
torrents = filter_torrents(coordinator.torrents, FILTER_MODES.get(key))
|
||||
torrents = SUPPORTED_ORDER_MODES[coordinator.order](torrents)
|
||||
for torrent in torrents[: coordinator.limit]:
|
||||
info = infos[torrent.name] = {
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
"""Define services for the Transmission integration."""
|
||||
|
||||
from enum import StrEnum
|
||||
from functools import partial
|
||||
import logging
|
||||
from typing import cast
|
||||
from typing import Any, cast
|
||||
|
||||
from transmission_rpc import Torrent
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import CONF_ID
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse, callback
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers import config_validation as cv, selector
|
||||
|
||||
@@ -16,18 +18,34 @@ from .const import (
|
||||
ATTR_DELETE_DATA,
|
||||
ATTR_DOWNLOAD_PATH,
|
||||
ATTR_TORRENT,
|
||||
ATTR_TORRENT_FILTER,
|
||||
ATTR_TORRENTS,
|
||||
CONF_ENTRY_ID,
|
||||
DEFAULT_DELETE_DATA,
|
||||
DOMAIN,
|
||||
FILTER_MODES,
|
||||
SERVICE_ADD_TORRENT,
|
||||
SERVICE_GET_TORRENTS,
|
||||
SERVICE_REMOVE_TORRENT,
|
||||
SERVICE_START_TORRENT,
|
||||
SERVICE_STOP_TORRENT,
|
||||
)
|
||||
from .coordinator import TransmissionDataUpdateCoordinator
|
||||
from .helpers import filter_torrents, format_torrents
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TorrentFilter(StrEnum):
|
||||
"""TorrentFilter model."""
|
||||
|
||||
ALL = "all"
|
||||
STARTED = "started"
|
||||
COMPLETED = "completed"
|
||||
PAUSED = "paused"
|
||||
ACTIVE = "active"
|
||||
|
||||
|
||||
SERVICE_BASE_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_ENTRY_ID): selector.ConfigEntrySelector(
|
||||
@@ -45,6 +63,16 @@ SERVICE_ADD_TORRENT_SCHEMA = vol.All(
|
||||
),
|
||||
)
|
||||
|
||||
SERVICE_GET_TORRENTS_SCHEMA = vol.All(
|
||||
SERVICE_BASE_SCHEMA.extend(
|
||||
{
|
||||
vol.Required(ATTR_TORRENT_FILTER): vol.In(
|
||||
[x.lower() for x in TorrentFilter]
|
||||
),
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
SERVICE_REMOVE_TORRENT_SCHEMA = vol.All(
|
||||
SERVICE_BASE_SCHEMA.extend(
|
||||
{
|
||||
@@ -111,6 +139,24 @@ async def _async_add_torrent(service: ServiceCall) -> None:
|
||||
await coordinator.async_request_refresh()
|
||||
|
||||
|
||||
async def _async_get_torrents(service: ServiceCall) -> dict[str, Any] | None:
|
||||
"""Get torrents."""
|
||||
coordinator = _get_coordinator_from_service_data(service)
|
||||
torrent_filter: str = service.data[ATTR_TORRENT_FILTER]
|
||||
|
||||
def get_filtered_torrents() -> list[Torrent]:
|
||||
"""Filter torrents based on the filter provided."""
|
||||
all_torrents = coordinator.api.get_torrents()
|
||||
return filter_torrents(all_torrents, FILTER_MODES[torrent_filter])
|
||||
|
||||
torrents = await service.hass.async_add_executor_job(get_filtered_torrents)
|
||||
|
||||
info = format_torrents(torrents)
|
||||
return {
|
||||
ATTR_TORRENTS: info,
|
||||
}
|
||||
|
||||
|
||||
async def _async_start_torrent(service: ServiceCall) -> None:
|
||||
"""Start torrent."""
|
||||
coordinator = _get_coordinator_from_service_data(service)
|
||||
@@ -149,6 +195,14 @@ def async_setup_services(hass: HomeAssistant) -> None:
|
||||
schema=SERVICE_ADD_TORRENT_SCHEMA,
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_GET_TORRENTS,
|
||||
_async_get_torrents,
|
||||
schema=SERVICE_GET_TORRENTS_SCHEMA,
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_REMOVE_TORRENT,
|
||||
|
||||
@@ -16,6 +16,27 @@ add_torrent:
|
||||
selector:
|
||||
text:
|
||||
|
||||
get_torrents:
|
||||
fields:
|
||||
entry_id:
|
||||
required: true
|
||||
selector:
|
||||
config_entry:
|
||||
integration: transmission
|
||||
torrent_filter:
|
||||
required: true
|
||||
example: "all"
|
||||
default: "all"
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- "all"
|
||||
- "active"
|
||||
- "started"
|
||||
- "paused"
|
||||
- "completed"
|
||||
translation_key: torrent_filter
|
||||
|
||||
remove_torrent:
|
||||
fields:
|
||||
entry_id:
|
||||
|
||||
@@ -120,6 +120,15 @@
|
||||
"oldest_first": "Oldest first",
|
||||
"worst_ratio_first": "Worst ratio first"
|
||||
}
|
||||
},
|
||||
"torrent_filter": {
|
||||
"options": {
|
||||
"active": "Active",
|
||||
"all": "All",
|
||||
"completed": "Completed",
|
||||
"paused": "Paused",
|
||||
"started": "Started"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
@@ -141,6 +150,20 @@
|
||||
},
|
||||
"name": "Add torrent"
|
||||
},
|
||||
"get_torrents": {
|
||||
"description": "Get a list of current torrents",
|
||||
"fields": {
|
||||
"entry_id": {
|
||||
"description": "[%key:component::transmission::services::add_torrent::fields::entry_id::description%]",
|
||||
"name": "[%key:component::transmission::services::add_torrent::fields::entry_id::name%]"
|
||||
},
|
||||
"torrent_filter": {
|
||||
"description": "What kind of torrents you want to return, such as All or Active.",
|
||||
"name": "Torrent filter"
|
||||
}
|
||||
},
|
||||
"name": "Get torrents"
|
||||
},
|
||||
"remove_torrent": {
|
||||
"description": "Removes a torrent.",
|
||||
"fields": {
|
||||
|
||||
@@ -87,16 +87,15 @@ def mock_torrent():
|
||||
torrent_data = {
|
||||
"id": torrent_id,
|
||||
"name": name,
|
||||
"percentDone": percent_done,
|
||||
"status": status,
|
||||
"rateDownload": 0,
|
||||
"rateUpload": 0,
|
||||
"downloadDir": download_dir,
|
||||
"percentDone": percent_done,
|
||||
"uploadRatio": ratio,
|
||||
"ratio": ratio,
|
||||
"eta": eta,
|
||||
"addedDate": int(added_date.timestamp()),
|
||||
"uploadRatio": ratio,
|
||||
"error": 0,
|
||||
"errorString": "",
|
||||
"doneDate": int(added_date.timestamp()) if percent_done >= 1.0 else 0,
|
||||
"downloadDir": download_dir,
|
||||
"labels": [],
|
||||
}
|
||||
return Torrent(fields=torrent_data)
|
||||
|
||||
|
||||
@@ -8,9 +8,12 @@ from homeassistant.components.transmission.const import (
|
||||
ATTR_DELETE_DATA,
|
||||
ATTR_DOWNLOAD_PATH,
|
||||
ATTR_TORRENT,
|
||||
ATTR_TORRENT_FILTER,
|
||||
ATTR_TORRENTS,
|
||||
CONF_ENTRY_ID,
|
||||
DOMAIN,
|
||||
SERVICE_ADD_TORRENT,
|
||||
SERVICE_GET_TORRENTS,
|
||||
SERVICE_REMOVE_TORRENT,
|
||||
SERVICE_START_TORRENT,
|
||||
SERVICE_STOP_TORRENT,
|
||||
@@ -252,3 +255,69 @@ async def test_remove_torrent_service_with_delete_data(
|
||||
)
|
||||
|
||||
client.remove_torrent.assert_called_once_with(789, delete_data=True)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("filter_mode", "expected_statuses", "expected_torrents"),
|
||||
[
|
||||
("all", ["seeding", "downloading", "stopped"], [1, 2, 3]),
|
||||
("started", ["downloading"], [1]),
|
||||
("completed", ["seeding"], [2]),
|
||||
("paused", ["stopped"], [3]),
|
||||
("active", ["seeding", "downloading"], [1, 2]),
|
||||
],
|
||||
)
|
||||
async def test_get_torrents_service(
|
||||
hass: HomeAssistant,
|
||||
mock_transmission_client: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_torrent,
|
||||
filter_mode: str,
|
||||
expected_statuses: list[str],
|
||||
expected_torrents: list[int],
|
||||
) -> None:
|
||||
"""Test get torrents service with various filter modes."""
|
||||
client = mock_transmission_client.return_value
|
||||
|
||||
downloading_torrent = mock_torrent(torrent_id=1, name="Downloading", status=4)
|
||||
seeding_torrent = mock_torrent(torrent_id=2, name="Seeding", status=6)
|
||||
stopped_torrent = mock_torrent(torrent_id=3, name="Stopped", status=0)
|
||||
|
||||
client.get_torrents.return_value = [
|
||||
downloading_torrent,
|
||||
seeding_torrent,
|
||||
stopped_torrent,
|
||||
]
|
||||
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
response = await hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_GET_TORRENTS,
|
||||
{
|
||||
CONF_ENTRY_ID: mock_config_entry.entry_id,
|
||||
ATTR_TORRENT_FILTER: filter_mode,
|
||||
},
|
||||
blocking=True,
|
||||
return_response=True,
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert ATTR_TORRENTS in response
|
||||
torrents = response[ATTR_TORRENTS]
|
||||
assert isinstance(torrents, dict)
|
||||
|
||||
assert len(torrents) == len(expected_statuses)
|
||||
|
||||
for torrent_name, torrent_data in torrents.items():
|
||||
assert isinstance(torrent_data, dict)
|
||||
assert "id" in torrent_data
|
||||
assert "name" in torrent_data
|
||||
assert "status" in torrent_data
|
||||
assert torrent_data["name"] == torrent_name
|
||||
assert torrent_data["id"] in expected_torrents
|
||||
expected_torrents.remove(int(torrent_data["id"]))
|
||||
|
||||
assert len(expected_torrents) == 0
|
||||
|
||||
Reference in New Issue
Block a user