From ba3fd4dee12536368352c3c402ae384941745f59 Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Sun, 8 Oct 2023 13:39:56 -0400 Subject: [PATCH] Add Queue sensor to Radarr (#79723) --- homeassistant/components/radarr/__init__.py | 4 +- homeassistant/components/radarr/const.py | 1 + .../components/radarr/coordinator.py | 19 ++- homeassistant/components/radarr/sensor.py | 10 ++ homeassistant/components/radarr/strings.json | 3 + tests/components/radarr/__init__.py | 26 ++- tests/components/radarr/fixtures/queue.json | 153 ++++++++++++++++++ .../radarr/fixtures/single-movie.json | 116 ------------- tests/components/radarr/test_init.py | 19 ++- tests/components/radarr/test_sensor.py | 20 ++- 10 files changed, 223 insertions(+), 148 deletions(-) create mode 100644 tests/components/radarr/fixtures/queue.json delete mode 100644 tests/components/radarr/fixtures/single-movie.json diff --git a/homeassistant/components/radarr/__init__.py b/homeassistant/components/radarr/__init__.py index c7f31a999e7..39258e2f787 100644 --- a/homeassistant/components/radarr/__init__.py +++ b/homeassistant/components/radarr/__init__.py @@ -25,6 +25,7 @@ from .coordinator import ( DiskSpaceDataUpdateCoordinator, HealthDataUpdateCoordinator, MoviesDataUpdateCoordinator, + QueueDataUpdateCoordinator, RadarrDataUpdateCoordinator, StatusDataUpdateCoordinator, T, @@ -45,10 +46,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: session=async_get_clientsession(hass, entry.data[CONF_VERIFY_SSL]), ) coordinators: dict[str, RadarrDataUpdateCoordinator[Any]] = { - "status": StatusDataUpdateCoordinator(hass, host_configuration, radarr), "disk_space": DiskSpaceDataUpdateCoordinator(hass, host_configuration, radarr), "health": HealthDataUpdateCoordinator(hass, host_configuration, radarr), "movie": MoviesDataUpdateCoordinator(hass, host_configuration, radarr), + "queue": QueueDataUpdateCoordinator(hass, host_configuration, radarr), + "status": StatusDataUpdateCoordinator(hass, host_configuration, radarr), } for coordinator in coordinators.values(): await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/radarr/const.py b/homeassistant/components/radarr/const.py index b77e134ca34..37388dd51ef 100644 --- a/homeassistant/components/radarr/const.py +++ b/homeassistant/components/radarr/const.py @@ -5,6 +5,7 @@ from typing import Final DOMAIN: Final = "radarr" # Defaults +DEFAULT_MAX_RECORDS = 20 DEFAULT_NAME = "Radarr" DEFAULT_URL = "http://127.0.0.1:7878" diff --git a/homeassistant/components/radarr/coordinator.py b/homeassistant/components/radarr/coordinator.py index c318d662028..bd41810bfb8 100644 --- a/homeassistant/components/radarr/coordinator.py +++ b/homeassistant/components/radarr/coordinator.py @@ -3,7 +3,7 @@ from __future__ import annotations from abc import ABC, abstractmethod from datetime import timedelta -from typing import Generic, TypeVar +from typing import Generic, TypeVar, cast from aiopyarr import Health, RadarrMovie, RootFolder, SystemStatus, exceptions from aiopyarr.models.host_configuration import PyArrHostConfiguration @@ -14,7 +14,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import DOMAIN, LOGGER +from .const import DEFAULT_MAX_RECORDS, DOMAIN, LOGGER T = TypeVar("T", bound=SystemStatus | list[RootFolder] | list[Health] | int) @@ -90,7 +90,14 @@ class MoviesDataUpdateCoordinator(RadarrDataUpdateCoordinator[int]): async def _fetch_data(self) -> int: """Fetch the movies data.""" - movies = await self.api_client.async_get_movies() - if isinstance(movies, RadarrMovie): - return 1 - return len(movies) + return len(cast(list[RadarrMovie], await self.api_client.async_get_movies())) + + +class QueueDataUpdateCoordinator(RadarrDataUpdateCoordinator): + """Queue 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 diff --git a/homeassistant/components/radarr/sensor.py b/homeassistant/components/radarr/sensor.py index 803b6de44a4..ab4315b269a 100644 --- a/homeassistant/components/radarr/sensor.py +++ b/homeassistant/components/radarr/sensor.py @@ -13,6 +13,7 @@ from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorStateClass, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, UnitOfInformation @@ -82,6 +83,15 @@ SENSOR_TYPES: dict[str, RadarrSensorEntityDescription[Any]] = { entity_registry_enabled_default=False, value_fn=lambda data, _: data, ), + "queue": RadarrSensorEntityDescription[int]( + key="queue", + translation_key="queue", + native_unit_of_measurement="Movies", + icon="mdi:download", + entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL, + value_fn=lambda data, _: data, + ), "status": RadarrSensorEntityDescription[SystemStatus]( key="start_time", translation_key="start_time", diff --git a/homeassistant/components/radarr/strings.json b/homeassistant/components/radarr/strings.json index 5cd7bcfc449..ec1baf6ffd8 100644 --- a/homeassistant/components/radarr/strings.json +++ b/homeassistant/components/radarr/strings.json @@ -45,6 +45,9 @@ "movies": { "name": "Movies" }, + "queue": { + "name": "Queue" + }, "start_time": { "name": "Start time" } diff --git a/tests/components/radarr/__init__.py b/tests/components/radarr/__init__.py index 069eeabe8d8..f7bdf232c9e 100644 --- a/tests/components/radarr/__init__.py +++ b/tests/components/radarr/__init__.py @@ -76,6 +76,11 @@ def mock_connection( headers={"Content-Type": CONTENT_TYPE_JSON}, ) + aioclient_mock.get( + f"{url}/api/v3/queue", + text=load_fixture("radarr/queue.json"), + headers={"Content-Type": CONTENT_TYPE_JSON}, + ) root_folder_fixture = "rootfolder-linux" if windows: @@ -90,13 +95,9 @@ def mock_connection( headers={"Content-Type": CONTENT_TYPE_JSON}, ) - movie_fixture = "movie" - if single_return: - movie_fixture = f"single-{movie_fixture}" - aioclient_mock.get( f"{url}/api/v3/movie", - text=load_fixture(f"radarr/{movie_fixture}.json"), + text=load_fixture("radarr/movie.json"), headers={"Content-Type": CONTENT_TYPE_JSON}, ) @@ -114,10 +115,11 @@ def mock_connection_invalid_auth( url: str = URL, ) -> None: """Mock radarr invalid auth errors.""" - aioclient_mock.get(f"{url}/api/v3/system/status", status=HTTPStatus.UNAUTHORIZED) aioclient_mock.get(f"{url}/api/v3/command", status=HTTPStatus.UNAUTHORIZED) aioclient_mock.get(f"{url}/api/v3/movie", status=HTTPStatus.UNAUTHORIZED) + aioclient_mock.get(f"{url}/api/v3/queue", status=HTTPStatus.UNAUTHORIZED) aioclient_mock.get(f"{url}/api/v3/rootfolder", status=HTTPStatus.UNAUTHORIZED) + aioclient_mock.get(f"{url}/api/v3/system/status", status=HTTPStatus.UNAUTHORIZED) def mock_connection_server_error( @@ -125,14 +127,15 @@ def mock_connection_server_error( url: str = URL, ) -> None: """Mock radarr server errors.""" - aioclient_mock.get( - f"{url}/api/v3/system/status", status=HTTPStatus.INTERNAL_SERVER_ERROR - ) aioclient_mock.get(f"{url}/api/v3/command", status=HTTPStatus.INTERNAL_SERVER_ERROR) aioclient_mock.get(f"{url}/api/v3/movie", status=HTTPStatus.INTERNAL_SERVER_ERROR) + aioclient_mock.get(f"{url}/api/v3/queue", status=HTTPStatus.INTERNAL_SERVER_ERROR) aioclient_mock.get( f"{url}/api/v3/rootfolder", status=HTTPStatus.INTERNAL_SERVER_ERROR ) + aioclient_mock.get( + f"{url}/api/v3/system/status", status=HTTPStatus.INTERNAL_SERVER_ERROR + ) async def setup_integration( @@ -185,11 +188,6 @@ def patch_async_setup_entry(return_value=True): ) -def patch_radarr(): - """Patch radarr api.""" - return patch("homeassistant.components.radarr.RadarrClient.async_get_system_status") - - def create_entry(hass: HomeAssistant) -> MockConfigEntry: """Create Radarr entry in Home Assistant.""" entry = MockConfigEntry( diff --git a/tests/components/radarr/fixtures/queue.json b/tests/components/radarr/fixtures/queue.json new file mode 100644 index 00000000000..804f1fd3a21 --- /dev/null +++ b/tests/components/radarr/fixtures/queue.json @@ -0,0 +1,153 @@ +{ + "page": 1, + "pageSize": 10, + "sortKey": "timeleft", + "sortDirection": "ascending", + "totalRecords": 2, + "records": [ + { + "movieId": 0, + "languages": [ + { + "id": 0, + "name": "string" + } + ], + "quality": { + "quality": { + "id": 0, + "name": "string", + "source": "string", + "resolution": 0, + "modifier": "string" + }, + "revision": { + "version": 0, + "real": 0, + "isRepack": true + } + }, + "customFormats": [ + { + "id": 0, + "name": "string", + "includeCustomFormatWhenRenaming": true, + "specifications": [ + { + "name": "string", + "implementation": "string", + "implementationName": "string", + "infoLink": "string", + "negate": true, + "required": true, + "fields": [ + { + "order": 0, + "name": "string", + "label": "string", + "helpText": "string", + "value": "string", + "type": "string", + "advanced": true + } + ] + } + ] + } + ], + "size": 0, + "title": "test", + "sizeleft": 0, + "timeleft": "string", + "estimatedCompletionTime": "2020-01-21T00:01:59Z", + "status": "string", + "trackedDownloadStatus": "string", + "trackedDownloadState": "downloading", + "statusMessages": [ + { + "title": "string", + "messages": ["string"] + } + ], + "errorMessage": "string", + "downloadId": "string", + "protocol": "unknown", + "downloadClient": "string", + "indexer": "string", + "outputPath": "string", + "id": 0 + }, + { + "movieId": 0, + "languages": [ + { + "id": 0, + "name": "string" + } + ], + "quality": { + "quality": { + "id": 0, + "name": "string", + "source": "string", + "resolution": 0, + "modifier": "string" + }, + "revision": { + "version": 0, + "real": 0, + "isRepack": true + } + }, + "customFormats": [ + { + "id": 0, + "name": "string", + "includeCustomFormatWhenRenaming": true, + "specifications": [ + { + "name": "string", + "implementation": "string", + "implementationName": "string", + "infoLink": "string", + "negate": true, + "required": true, + "fields": [ + { + "order": 0, + "name": "string", + "label": "string", + "helpText": "string", + "value": "string", + "type": "string", + "advanced": true + } + ] + } + ] + } + ], + "size": 0, + "title": "test2", + "sizeleft": 1000000, + "timeleft": "00:00:00", + "estimatedCompletionTime": "2020-01-21T00:01:59Z", + "status": "string", + "trackedDownloadStatus": "string", + "trackedDownloadState": "downloading", + "statusMessages": [ + { + "title": "string", + "messages": ["string"] + } + ], + "errorMessage": "string", + "downloadId": "string", + "protocol": "unknown", + "downloadClient": "string", + "indexer": "string", + "outputPath": "string", + "id": 0 + } + ] +} diff --git a/tests/components/radarr/fixtures/single-movie.json b/tests/components/radarr/fixtures/single-movie.json deleted file mode 100644 index db9e720d285..00000000000 --- a/tests/components/radarr/fixtures/single-movie.json +++ /dev/null @@ -1,116 +0,0 @@ -{ - "id": 0, - "title": "string", - "originalTitle": "string", - "alternateTitles": [ - { - "sourceType": "tmdb", - "movieId": 1, - "title": "string", - "sourceId": 0, - "votes": 0, - "voteCount": 0, - "language": { - "id": 1, - "name": "English" - }, - "id": 1 - } - ], - "sortTitle": "string", - "sizeOnDisk": 0, - "overview": "string", - "inCinemas": "2020-11-06T00:00:00Z", - "physicalRelease": "2019-03-19T00:00:00Z", - "images": [ - { - "coverType": "poster", - "url": "string", - "remoteUrl": "string" - } - ], - "website": "string", - "year": 0, - "hasFile": true, - "youTubeTrailerId": "string", - "studio": "string", - "path": "string", - "rootFolderPath": "string", - "qualityProfileId": 0, - "monitored": true, - "minimumAvailability": "announced", - "isAvailable": true, - "folderName": "string", - "runtime": 0, - "cleanTitle": "string", - "imdbId": "string", - "tmdbId": 0, - "titleSlug": "string", - "certification": "string", - "genres": ["string"], - "tags": [0], - "added": "2018-12-28T05:56:49Z", - "ratings": { - "votes": 0, - "value": 0 - }, - "movieFile": { - "movieId": 0, - "relativePath": "string", - "path": "string", - "size": 916662234, - "dateAdded": "2020-11-26T02:00:35Z", - "indexerFlags": 1, - "quality": { - "quality": { - "id": 14, - "name": "WEBRip-720p", - "source": "webrip", - "resolution": 720, - "modifier": "none" - }, - "revision": { - "version": 1, - "real": 0, - "isRepack": false - } - }, - "mediaInfo": { - "audioBitrate": 0, - "audioChannels": 2, - "audioCodec": "AAC", - "audioLanguages": "", - "audioStreamCount": 1, - "videoBitDepth": 8, - "videoBitrate": 1000000, - "videoCodec": "x264", - "videoFps": 25.0, - "resolution": "1280x534", - "runTime": "1:49:06", - "scanType": "Progressive", - "subtitles": "" - }, - "originalFilePath": "string", - "qualityCutoffNotMet": true, - "languages": [ - { - "id": 26, - "name": "Hindi" - } - ], - "edition": "", - "id": 35361 - }, - "collection": { - "name": "string", - "tmdbId": 0, - "images": [ - { - "coverType": "poster", - "url": "string", - "remoteUrl": "string" - } - ] - }, - "status": "deleted" -} diff --git a/tests/components/radarr/test_init.py b/tests/components/radarr/test_init.py index 6b602c8c4d1..f16e5895633 100644 --- a/tests/components/radarr/test_init.py +++ b/tests/components/radarr/test_init.py @@ -1,12 +1,10 @@ """Test Radarr integration.""" -from aiopyarr import exceptions - from homeassistant.components.radarr.const import DEFAULT_NAME, DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -from . import create_entry, patch_radarr, setup_integration +from . import create_entry, mock_connection_invalid_auth, setup_integration from tests.test_util.aiohttp import AiohttpClientMocker @@ -33,15 +31,16 @@ async def test_async_setup_entry_not_ready( assert not hass.data.get(DOMAIN) -async def test_async_setup_entry_auth_failed(hass: HomeAssistant) -> None: +async def test_async_setup_entry_auth_failed( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: """Test that it throws ConfigEntryAuthFailed when authentication fails.""" entry = create_entry(hass) - with patch_radarr() as radarrmock: - radarrmock.side_effect = exceptions.ArrAuthenticationException - await hass.config_entries.async_setup(entry.entry_id) - assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - assert entry.state == ConfigEntryState.SETUP_ERROR - assert not hass.data.get(DOMAIN) + mock_connection_invalid_auth(aioclient_mock) + await hass.config_entries.async_setup(entry.entry_id) + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert entry.state == ConfigEntryState.SETUP_ERROR + assert not hass.data.get(DOMAIN) async def test_device_info( diff --git a/tests/components/radarr/test_sensor.py b/tests/components/radarr/test_sensor.py index f4f863d9bb6..90ab683037b 100644 --- a/tests/components/radarr/test_sensor.py +++ b/tests/components/radarr/test_sensor.py @@ -1,7 +1,11 @@ """The tests for Radarr sensor platform.""" import pytest -from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.components.sensor import ( + ATTR_STATE_CLASS, + SensorDeviceClass, + SensorStateClass, +) from homeassistant.const import ATTR_DEVICE_CLASS, ATTR_UNIT_OF_MEASUREMENT from homeassistant.core import HomeAssistant @@ -55,3 +59,17 @@ async def test_sensors( state = hass.states.get("sensor.mock_title_start_time") assert state.state == "2020-09-01T23:50:20+00:00" assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TIMESTAMP + state = hass.states.get("sensor.mock_title_queue") + assert state.state == "2" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "Movies" + assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.TOTAL + + +async def test_windows( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test for successfully setting up the Radarr platform on Windows.""" + await setup_integration(hass, aioclient_mock, windows=True) + + state = hass.states.get("sensor.mock_title_disk_space_tv") + assert state.state == "263.10"