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:
@@ -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."""
|
||||
|
||||
@@ -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]):
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
}),
|
||||
}),
|
||||
})
|
||||
# ---
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user