Add Transmission get_torrents service and codeowner (#159211)

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Andrew Jackson
2025-12-23 20:11:47 +00:00
committed by GitHub
parent 9715a7cc32
commit af1218876c
11 changed files with 250 additions and 43 deletions

4
CODEOWNERS generated
View File

@@ -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

View File

@@ -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,
}

View 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

View File

@@ -42,6 +42,9 @@
"add_torrent": {
"service": "mdi:download"
},
"get_torrents": {
"service": "mdi:file-arrow-up-down-outline"
},
"remove_torrent": {
"service": "mdi:download-off"
},

View File

@@ -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",

View File

@@ -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] = {

View File

@@ -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,

View File

@@ -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:

View File

@@ -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": {

View File

@@ -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)

View File

@@ -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