1
0
mirror of https://github.com/home-assistant/core.git synced 2026-05-08 17:49:37 +01:00

Add get_queue and get_movies service calls to Radarr (#160753)

Co-authored-by: Joostlek <joostlek@outlook.com>
This commit is contained in:
Liquidmasl
2026-02-03 11:30:30 +01:00
committed by GitHub
parent 10d4af5674
commit 145d38403e
14 changed files with 707 additions and 14 deletions
@@ -9,8 +9,11 @@ from aiopyarr.radarr_client import RadarrClient
from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN
from .coordinator import (
CalendarUpdateCoordinator,
DiskSpaceDataUpdateCoordinator,
@@ -22,9 +25,18 @@ from .coordinator import (
RadarrDataUpdateCoordinator,
StatusDataUpdateCoordinator,
)
from .services import async_setup_services
PLATFORMS = [Platform.BINARY_SENSOR, Platform.CALENDAR, Platform.SENSOR]
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Radarr integration."""
async_setup_services(hass)
return True
async def async_setup_entry(hass: HomeAssistant, entry: RadarrConfigEntry) -> bool:
"""Set up Radarr from a config entry."""
+8 -1
View File
@@ -6,7 +6,6 @@ from typing import Final
DOMAIN: Final = "radarr"
# Defaults
DEFAULT_MAX_RECORDS = 20
DEFAULT_NAME = "Radarr"
DEFAULT_URL = "http://127.0.0.1:7878"
@@ -18,3 +17,11 @@ HEALTH_ISSUES = (
)
LOGGER = logging.getLogger(__package__)
# Service names
SERVICE_GET_MOVIES: Final = "get_movies"
SERVICE_GET_QUEUE: Final = "get_queue"
# Service attributes
ATTR_MOVIES: Final = "movies"
ATTR_ENTRY_ID: Final = "entry_id"
@@ -25,7 +25,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DEFAULT_MAX_RECORDS, DOMAIN, LOGGER
from .const import DOMAIN, LOGGER
@dataclass(kw_only=True, slots=True)
@@ -130,21 +130,20 @@ class HealthDataUpdateCoordinator(RadarrDataUpdateCoordinator[list[Health]]):
class MoviesDataUpdateCoordinator(RadarrDataUpdateCoordinator[int]):
"""Movies update coordinator."""
"""Movies count update coordinator."""
async def _fetch_data(self) -> int:
"""Fetch the movies data."""
"""Fetch the total count of movies in Radarr."""
return len(cast(list[RadarrMovie], await self.api_client.async_get_movies()))
class QueueDataUpdateCoordinator(RadarrDataUpdateCoordinator):
"""Queue update coordinator."""
class QueueDataUpdateCoordinator(RadarrDataUpdateCoordinator[int]):
"""Queue count update coordinator."""
async def _fetch_data(self) -> int:
"""Fetch the movies in queue."""
return (
await self.api_client.async_get_queue(page_size=DEFAULT_MAX_RECORDS)
).totalRecords
"""Fetch the number of movies in the download queue."""
# page_size=1 is sufficient since we only need the totalRecords count
return (await self.api_client.async_get_queue(page_size=1)).totalRecords
class CalendarUpdateCoordinator(RadarrDataUpdateCoordinator[None]):
+122
View File
@@ -0,0 +1,122 @@
"""Helper functions for Radarr."""
from typing import Any
from aiopyarr import RadarrMovie, RadarrQueue
def format_queue_item(item: Any, base_url: str | None = None) -> dict[str, Any]:
"""Format a single queue item."""
remaining = 1 if item.size == 0 else item.sizeleft / item.size
remaining_pct = 100 * (1 - remaining)
movie = item.movie
result: dict[str, Any] = {
"id": item.id,
"movie_id": item.movieId,
"title": movie["title"],
"download_title": item.title,
"progress": f"{remaining_pct:.2f}%",
"size": item.size,
"size_left": item.sizeleft,
"status": item.status,
"tracked_download_status": getattr(item, "trackedDownloadStatus", None),
"tracked_download_state": getattr(item, "trackedDownloadState", None),
"download_client": getattr(item, "downloadClient", None),
"download_id": getattr(item, "downloadId", None),
"indexer": getattr(item, "indexer", None),
"protocol": str(getattr(item, "protocol", None)),
"estimated_completion_time": str(
getattr(item, "estimatedCompletionTime", None)
),
"time_left": str(getattr(item, "timeleft", None)),
}
if quality := getattr(item, "quality", None):
result["quality"] = quality.quality.name
if languages := getattr(item, "languages", None):
result["languages"] = [lang.name for lang in languages]
if custom_format_score := getattr(item, "customFormatScore", None):
result["custom_format_score"] = custom_format_score
# Add movie images if available
# Note: item.movie is a dict (not object), so images are also dicts
if images := movie.get("images"):
result["images"] = {}
for image in images:
cover_type = image.get("coverType")
# Prefer remoteUrl (public TMDB URL) over local path
if remote_url := image.get("remoteUrl"):
result["images"][cover_type] = remote_url
elif base_url and (url := image.get("url")):
result["images"][cover_type] = f"{base_url.rstrip('/')}{url}"
return result
def format_queue(
queue: RadarrQueue, base_url: str | None = None
) -> dict[str, dict[str, Any]]:
"""Format queue for service response."""
movies = {}
for item in queue.records:
movies[item.title] = format_queue_item(item, base_url)
return movies
def format_movie_item(
movie: RadarrMovie, base_url: str | None = None
) -> dict[str, Any]:
"""Format a single movie item."""
result: dict[str, Any] = {
"id": movie.id,
"title": movie.title,
"year": movie.year,
"tmdb_id": movie.tmdbId,
"imdb_id": getattr(movie, "imdbId", None),
"status": movie.status,
"monitored": movie.monitored,
"has_file": movie.hasFile,
"size_on_disk": getattr(movie, "sizeOnDisk", None),
}
# Add path if available
if path := getattr(movie, "path", None):
result["path"] = path
# Add movie statistics if available
if statistics := getattr(movie, "statistics", None):
result["movie_file_count"] = getattr(statistics, "movieFileCount", None)
result["size_on_disk"] = getattr(statistics, "sizeOnDisk", None)
# Add movie images if available
if images := getattr(movie, "images", None):
images_dict: dict[str, str] = {}
for image in images:
cover_type = image.coverType
# Prefer remoteUrl (public TMDB URL) over local path
if remote_url := getattr(image, "remoteUrl", None):
images_dict[cover_type] = remote_url
elif base_url and (url := getattr(image, "url", None)):
images_dict[cover_type] = f"{base_url.rstrip('/')}{url}"
result["images"] = images_dict
return result
def format_movies(
movies: list[RadarrMovie], base_url: str | None = None
) -> dict[str, dict[str, Any]]:
"""Format movies list for service response."""
formatted_movies = {}
for movie in movies:
formatted_movies[movie.title] = format_movie_item(movie, base_url)
return formatted_movies
@@ -8,5 +8,13 @@
"default": "mdi:download"
}
}
},
"services": {
"get_movies": {
"service": "mdi:filmstrip"
},
"get_queue": {
"service": "mdi:download"
}
}
}
+143
View File
@@ -0,0 +1,143 @@
"""Define services for the Radarr integration."""
from collections.abc import Awaitable, Callable
from typing import Any, cast
from aiopyarr import exceptions
import voluptuous as vol
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_URL
from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse, callback
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import selector
from .const import (
ATTR_ENTRY_ID,
ATTR_MOVIES,
DOMAIN,
SERVICE_GET_MOVIES,
SERVICE_GET_QUEUE,
)
from .coordinator import RadarrConfigEntry
from .helpers import format_movies, format_queue
# Service parameter constants
CONF_MAX_ITEMS = "max_items"
# Default values - 0 means no limit
DEFAULT_MAX_ITEMS = 0
SERVICE_BASE_SCHEMA = vol.Schema(
{
vol.Required(ATTR_ENTRY_ID): selector.ConfigEntrySelector(
{"integration": DOMAIN}
),
}
)
SERVICE_GET_MOVIES_SCHEMA = SERVICE_BASE_SCHEMA
SERVICE_GET_QUEUE_SCHEMA = SERVICE_BASE_SCHEMA.extend(
{
vol.Optional(CONF_MAX_ITEMS, default=DEFAULT_MAX_ITEMS): vol.All(
vol.Coerce(int), vol.Range(min=0, max=500)
),
}
)
def _get_config_entry_from_service_data(call: ServiceCall) -> RadarrConfigEntry:
"""Return config entry for entry id."""
config_entry_id: str = call.data[ATTR_ENTRY_ID]
if not (entry := call.hass.config_entries.async_get_entry(config_entry_id)):
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="integration_not_found",
translation_placeholders={"target": DOMAIN},
)
if entry.state is not ConfigEntryState.LOADED:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="not_loaded",
translation_placeholders={"target": entry.title},
)
return cast(RadarrConfigEntry, entry)
async def _handle_api_errors[_T](func: Callable[[], Awaitable[_T]]) -> _T:
"""Handle API errors and raise HomeAssistantError with user-friendly messages."""
try:
return await func()
except exceptions.ArrAuthenticationException as ex:
raise HomeAssistantError("Authentication failed for Radarr") from ex
except exceptions.ArrConnectionException as ex:
raise HomeAssistantError("Failed to connect to Radarr") from ex
except exceptions.ArrException as ex:
raise HomeAssistantError(f"Radarr API error: {ex}") from ex
async def _async_get_movies(service: ServiceCall) -> dict[str, Any]:
"""Get all Radarr movies."""
entry = _get_config_entry_from_service_data(service)
api_client = entry.runtime_data.status.api_client
movies_list = await _handle_api_errors(api_client.async_get_movies)
# Get base URL from config entry for image URLs
base_url = entry.data[CONF_URL]
movies = format_movies(cast(list, movies_list), base_url)
return {
ATTR_MOVIES: movies,
}
async def _async_get_queue(service: ServiceCall) -> dict[str, Any]:
"""Get Radarr queue."""
entry = _get_config_entry_from_service_data(service)
max_items: int = service.data[CONF_MAX_ITEMS]
api_client = entry.runtime_data.status.api_client
if max_items > 0:
page_size = max_items
else:
# Get total count first, then fetch all items
queue_preview = await _handle_api_errors(
lambda: api_client.async_get_queue(page_size=1)
)
total = queue_preview.totalRecords
page_size = total if total > 0 else 1
queue = await _handle_api_errors(
lambda: api_client.async_get_queue(page_size=page_size, include_movie=True)
)
# Get base URL from config entry for image URLs
base_url = entry.data[CONF_URL]
movies = format_queue(queue, base_url)
return {ATTR_MOVIES: movies}
@callback
def async_setup_services(hass: HomeAssistant) -> None:
"""Register services for the Radarr integration."""
hass.services.async_register(
DOMAIN,
SERVICE_GET_MOVIES,
_async_get_movies,
schema=SERVICE_GET_MOVIES_SCHEMA,
supports_response=SupportsResponse.ONLY,
)
hass.services.async_register(
DOMAIN,
SERVICE_GET_QUEUE,
_async_get_queue,
schema=SERVICE_GET_QUEUE_SCHEMA,
supports_response=SupportsResponse.ONLY,
)
@@ -0,0 +1,23 @@
get_movies:
fields:
entry_id:
required: true
selector:
config_entry:
integration: radarr
get_queue:
fields:
entry_id:
required: true
selector:
config_entry:
integration: radarr
max_items:
required: false
default: 0
selector:
number:
min: 0
max: 500
mode: box
@@ -46,6 +46,14 @@
}
}
},
"exceptions": {
"integration_not_found": {
"message": "Config entry for integration \"{target}\" not found."
},
"not_loaded": {
"message": "Config entry \"{target}\" is not loaded."
}
},
"options": {
"step": {
"init": {
@@ -54,5 +62,31 @@
}
}
}
},
"services": {
"get_movies": {
"description": "Get all movies in Radarr with their details and status.",
"fields": {
"entry_id": {
"description": "ID of the config entry to use.",
"name": "Radarr entry"
}
},
"name": "Get movies"
},
"get_queue": {
"description": "Get all movies currently in the download queue with their progress and details.",
"fields": {
"entry_id": {
"description": "[%key:component::radarr::services::get_movies::fields::entry_id::description%]",
"name": "[%key:component::radarr::services::get_movies::fields::entry_id::name%]"
},
"max_items": {
"description": "Maximum number of queue items to return (0 = no limit).",
"name": "Max items"
}
},
"name": "Get queue"
}
}
}
+2 -3
View File
@@ -13,7 +13,6 @@ from homeassistant.const import (
CONTENT_TYPE_JSON,
)
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
from tests.common import MockConfigEntry, load_fixture
from tests.test_util.aiohttp import AiohttpClientMocker
@@ -98,7 +97,7 @@ def mock_connection(
aioclient_mock.get(
f"{url}/api/v3/movie",
text=load_fixture("radarr/movie.json"),
text=load_fixture("radarr/movies.json"),
headers={"Content-Type": CONTENT_TYPE_JSON},
)
@@ -192,9 +191,9 @@ async def setup_integration(
mock_calendar(aioclient_mock, url)
if not skip_entry_setup:
# async_setup_entry will automatically trigger async_setup which registers services
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert await async_setup_component(hass, DOMAIN, {})
return entry
@@ -0,0 +1,53 @@
[
{
"id": 1,
"title": "The Matrix",
"year": 1999,
"hasFile": true,
"monitored": true,
"status": "released",
"sizeOnDisk": 8589934592,
"tmdbId": 603,
"imdbId": "tt0133093",
"path": "/movies/The Matrix (1999)",
"images": [
{
"coverType": "poster",
"url": "/MediaCover/1/poster.jpg",
"remoteUrl": "https://image.tmdb.org/t/p/original/testposter1.jpg"
},
{
"coverType": "fanart",
"url": "/MediaCover/1/fanart.jpg",
"remoteUrl": "https://image.tmdb.org/t/p/original/testfanart1.jpg"
}
],
"statistics": {
"movieFileCount": 1,
"sizeOnDisk": 8589934592
}
},
{
"id": 2,
"title": "Inception",
"year": 2010,
"hasFile": false,
"monitored": true,
"status": "released",
"sizeOnDisk": 0,
"tmdbId": 27205,
"imdbId": "tt1375666",
"path": "/movies/Inception (2010)",
"images": [
{
"coverType": "poster",
"url": "/MediaCover/2/poster.jpg",
"remoteUrl": "https://image.tmdb.org/t/p/original/testposter2.jpg"
}
],
"statistics": {
"movieFileCount": 0,
"sizeOnDisk": 0
}
}
]
@@ -7,6 +7,16 @@
"records": [
{
"movieId": 0,
"movie": {
"title": "Test Movie 1",
"images": [
{
"coverType": "poster",
"url": "/MediaCover/1/poster.jpg",
"remoteUrl": "https://image.tmdb.org/t/p/original/testposter1.jpg"
}
]
},
"languages": [
{
"id": 0,
@@ -79,6 +89,16 @@
},
{
"movieId": 0,
"movie": {
"title": "Test Movie 2",
"images": [
{
"coverType": "poster",
"url": "/MediaCover/2/poster.jpg",
"remoteUrl": "https://image.tmdb.org/t/p/original/testposter2.jpg"
}
]
},
"languages": [
{
"id": 0,
@@ -0,0 +1,96 @@
# serializer version: 1
# name: test_get_movies_service
dict({
'movies': dict({
'Inception': dict({
'has_file': False,
'id': 2,
'images': dict({
'poster': 'https://image.tmdb.org/t/p/original/testposter2.jpg',
}),
'imdb_id': 'tt1375666',
'monitored': True,
'movie_file_count': None,
'path': '/movies/Inception (2010)',
'size_on_disk': None,
'status': 'released',
'title': 'Inception',
'tmdb_id': 27205,
'year': 2010,
}),
'The Matrix': dict({
'has_file': True,
'id': 1,
'images': dict({
'fanart': 'https://image.tmdb.org/t/p/original/testfanart1.jpg',
'poster': 'https://image.tmdb.org/t/p/original/testposter1.jpg',
}),
'imdb_id': 'tt0133093',
'monitored': True,
'movie_file_count': None,
'path': '/movies/The Matrix (1999)',
'size_on_disk': None,
'status': 'released',
'title': 'The Matrix',
'tmdb_id': 603,
'year': 1999,
}),
}),
})
# ---
# name: test_get_queue_service
dict({
'movies': dict({
'test': dict({
'download_client': 'string',
'download_id': 'string',
'download_title': 'test',
'estimated_completion_time': '2020-01-21 00:01:59',
'id': 0,
'images': dict({
'poster': 'https://image.tmdb.org/t/p/original/testposter1.jpg',
}),
'indexer': 'string',
'languages': list([
'string',
]),
'movie_id': 0,
'progress': '0.00%',
'protocol': 'ProtocolType.UNKNOWN',
'quality': 'string',
'size': 0,
'size_left': 0,
'status': 'string',
'time_left': 'string',
'title': 'Test Movie 1',
'tracked_download_state': 'downloading',
'tracked_download_status': 'string',
}),
'test2': dict({
'download_client': 'string',
'download_id': 'string',
'download_title': 'test2',
'estimated_completion_time': '2020-01-21 00:01:59',
'id': 0,
'images': dict({
'poster': 'https://image.tmdb.org/t/p/original/testposter2.jpg',
}),
'indexer': 'string',
'languages': list([
'string',
]),
'movie_id': 0,
'progress': '0.00%',
'protocol': 'ProtocolType.UNKNOWN',
'quality': 'string',
'size': 0,
'size_left': 1000000,
'status': 'string',
'time_left': '00:00:00',
'title': 'Test Movie 2',
'tracked_download_state': 'downloading',
'tracked_download_status': 'string',
}),
}),
})
# ---
+1 -1
View File
@@ -67,7 +67,7 @@ async def test_sensors(
assert state.state == "263.10"
assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "GB"
state = hass.states.get("sensor.mock_title_movies")
assert state.state == "1"
assert state.state == "2"
assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "movies"
state = hass.states.get("sensor.mock_title_start_time")
assert state.state == "2020-09-01T23:50:20+00:00"
+177
View File
@@ -0,0 +1,177 @@
"""Test Radarr services."""
from unittest.mock import patch
from aiopyarr import ArrAuthenticationException, ArrConnectionException
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.radarr.const import (
ATTR_ENTRY_ID,
DOMAIN,
SERVICE_GET_MOVIES,
SERVICE_GET_QUEUE,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from . import create_entry, setup_integration
from tests.test_util.aiohttp import AiohttpClientMocker
async def test_get_queue_service(
hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker,
snapshot: SnapshotAssertion,
) -> None:
"""Test the get_queue service."""
entry = await setup_integration(hass, aioclient_mock)
response = await hass.services.async_call(
DOMAIN,
SERVICE_GET_QUEUE,
{ATTR_ENTRY_ID: entry.entry_id},
blocking=True,
return_response=True,
)
# Explicit assertion for specific behavior
assert len(response["movies"]) == 2
# Snapshot for full structure validation
assert response == snapshot
async def test_get_movies_service(
hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker,
snapshot: SnapshotAssertion,
) -> None:
"""Test the get_movies service."""
entry = await setup_integration(hass, aioclient_mock)
response = await hass.services.async_call(
DOMAIN,
SERVICE_GET_MOVIES,
{ATTR_ENTRY_ID: entry.entry_id},
blocking=True,
return_response=True,
)
# Explicit assertion for specific behavior
assert len(response["movies"]) == 2
# Snapshot for full structure validation
assert response == snapshot
@pytest.mark.parametrize(
("service", "method"),
[(SERVICE_GET_QUEUE, "async_get_queue"), (SERVICE_GET_MOVIES, "async_get_movies")],
)
async def test_services_api_connection_error(
hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker,
service: str,
method: str,
) -> None:
"""Test services with API connection error."""
entry = await setup_integration(hass, aioclient_mock)
with (
patch(
f"homeassistant.components.radarr.coordinator.RadarrClient.{method}",
side_effect=ArrConnectionException(None, "Connection failed"),
),
pytest.raises(HomeAssistantError, match="Failed to connect to Radarr"),
):
await hass.services.async_call(
DOMAIN,
service,
{ATTR_ENTRY_ID: entry.entry_id},
blocking=True,
return_response=True,
)
@pytest.mark.parametrize(
("service", "method"),
[(SERVICE_GET_QUEUE, "async_get_queue"), (SERVICE_GET_MOVIES, "async_get_movies")],
)
async def test_get_movies_service_api_auth_error(
hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker,
service: str,
method: str,
) -> None:
"""Test services with API authentication error."""
entry = await setup_integration(hass, aioclient_mock)
with (
patch(
f"homeassistant.components.radarr.coordinator.RadarrClient.{method}",
side_effect=ArrAuthenticationException(None, "Authentication failed"),
),
pytest.raises(HomeAssistantError, match="Authentication failed for Radarr"),
):
await hass.services.async_call(
DOMAIN,
service,
{ATTR_ENTRY_ID: entry.entry_id},
blocking=True,
return_response=True,
)
@pytest.mark.parametrize(
"service",
[SERVICE_GET_QUEUE, SERVICE_GET_MOVIES],
)
async def test_services_invalid_entry(
hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker,
service: str,
) -> None:
"""Test get_queue with invalid entry id."""
# Set up at least one entry so the service gets registered
await setup_integration(hass, aioclient_mock)
with pytest.raises(
ServiceValidationError, match='Config entry for integration "radarr" not found'
):
await hass.services.async_call(
DOMAIN,
service,
{ATTR_ENTRY_ID: "invalid_id"},
blocking=True,
return_response=True,
)
@pytest.mark.parametrize(
"service",
[SERVICE_GET_QUEUE, SERVICE_GET_MOVIES],
)
async def test_services_entry_not_loaded(
hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker,
service: str,
) -> None:
"""Test get_queue with entry that's not loaded."""
# First set up one entry to register the service
await setup_integration(hass, aioclient_mock)
# Now create a second entry that isn't loaded
unloaded_entry = create_entry(hass)
with pytest.raises(
ServiceValidationError, match='Config entry "Mock Title" is not loaded'
):
await hass.services.async_call(
DOMAIN,
service,
{ATTR_ENTRY_ID: unloaded_entry.entry_id},
blocking=True,
return_response=True,
)