diff --git a/homeassistant/components/radarr/__init__.py b/homeassistant/components/radarr/__init__.py index 11a9b6b4dc0..585b5011176 100644 --- a/homeassistant/components/radarr/__init__.py +++ b/homeassistant/components/radarr/__init__.py @@ -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.""" diff --git a/homeassistant/components/radarr/const.py b/homeassistant/components/radarr/const.py index ef3b29af4e5..6582a024427 100644 --- a/homeassistant/components/radarr/const.py +++ b/homeassistant/components/radarr/const.py @@ -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" diff --git a/homeassistant/components/radarr/coordinator.py b/homeassistant/components/radarr/coordinator.py index 72789658649..1fe92e79061 100644 --- a/homeassistant/components/radarr/coordinator.py +++ b/homeassistant/components/radarr/coordinator.py @@ -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]): diff --git a/homeassistant/components/radarr/helpers.py b/homeassistant/components/radarr/helpers.py new file mode 100644 index 00000000000..a2d71708de9 --- /dev/null +++ b/homeassistant/components/radarr/helpers.py @@ -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 diff --git a/homeassistant/components/radarr/icons.json b/homeassistant/components/radarr/icons.json index ff31d936ae5..36efdd4f777 100644 --- a/homeassistant/components/radarr/icons.json +++ b/homeassistant/components/radarr/icons.json @@ -8,5 +8,13 @@ "default": "mdi:download" } } + }, + "services": { + "get_movies": { + "service": "mdi:filmstrip" + }, + "get_queue": { + "service": "mdi:download" + } } } diff --git a/homeassistant/components/radarr/services.py b/homeassistant/components/radarr/services.py new file mode 100644 index 00000000000..b286b52aa16 --- /dev/null +++ b/homeassistant/components/radarr/services.py @@ -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, + ) diff --git a/homeassistant/components/radarr/services.yaml b/homeassistant/components/radarr/services.yaml new file mode 100644 index 00000000000..957106772ad --- /dev/null +++ b/homeassistant/components/radarr/services.yaml @@ -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 diff --git a/homeassistant/components/radarr/strings.json b/homeassistant/components/radarr/strings.json index b60e38bd255..5b4aa0ed1a7 100644 --- a/homeassistant/components/radarr/strings.json +++ b/homeassistant/components/radarr/strings.json @@ -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" + } } } diff --git a/tests/components/radarr/__init__.py b/tests/components/radarr/__init__.py index a29b928b405..85acd236380 100644 --- a/tests/components/radarr/__init__.py +++ b/tests/components/radarr/__init__.py @@ -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 diff --git a/tests/components/radarr/fixtures/movies.json b/tests/components/radarr/fixtures/movies.json new file mode 100644 index 00000000000..807500d544f --- /dev/null +++ b/tests/components/radarr/fixtures/movies.json @@ -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 + } + } +] diff --git a/tests/components/radarr/fixtures/queue.json b/tests/components/radarr/fixtures/queue.json index 804f1fd3a21..3d3ef6bfa6a 100644 --- a/tests/components/radarr/fixtures/queue.json +++ b/tests/components/radarr/fixtures/queue.json @@ -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, diff --git a/tests/components/radarr/snapshots/test_services.ambr b/tests/components/radarr/snapshots/test_services.ambr new file mode 100644 index 00000000000..b8906c4caf4 --- /dev/null +++ b/tests/components/radarr/snapshots/test_services.ambr @@ -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', + }), + }), + }) +# --- diff --git a/tests/components/radarr/test_sensor.py b/tests/components/radarr/test_sensor.py index f6b14bffa80..87d28cc4068 100644 --- a/tests/components/radarr/test_sensor.py +++ b/tests/components/radarr/test_sensor.py @@ -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" diff --git a/tests/components/radarr/test_services.py b/tests/components/radarr/test_services.py new file mode 100644 index 00000000000..79f86e0ae34 --- /dev/null +++ b/tests/components/radarr/test_services.py @@ -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, + )