Add Queue sensor to Radarr (#79723)

This commit is contained in:
Robert Hillis 2023-10-08 13:39:56 -04:00 committed by GitHub
parent 6420cdb42b
commit ba3fd4dee1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 223 additions and 148 deletions

View file

@ -25,6 +25,7 @@ from .coordinator import (
DiskSpaceDataUpdateCoordinator, DiskSpaceDataUpdateCoordinator,
HealthDataUpdateCoordinator, HealthDataUpdateCoordinator,
MoviesDataUpdateCoordinator, MoviesDataUpdateCoordinator,
QueueDataUpdateCoordinator,
RadarrDataUpdateCoordinator, RadarrDataUpdateCoordinator,
StatusDataUpdateCoordinator, StatusDataUpdateCoordinator,
T, 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]), session=async_get_clientsession(hass, entry.data[CONF_VERIFY_SSL]),
) )
coordinators: dict[str, RadarrDataUpdateCoordinator[Any]] = { coordinators: dict[str, RadarrDataUpdateCoordinator[Any]] = {
"status": StatusDataUpdateCoordinator(hass, host_configuration, radarr),
"disk_space": DiskSpaceDataUpdateCoordinator(hass, host_configuration, radarr), "disk_space": DiskSpaceDataUpdateCoordinator(hass, host_configuration, radarr),
"health": HealthDataUpdateCoordinator(hass, host_configuration, radarr), "health": HealthDataUpdateCoordinator(hass, host_configuration, radarr),
"movie": MoviesDataUpdateCoordinator(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(): for coordinator in coordinators.values():
await coordinator.async_config_entry_first_refresh() await coordinator.async_config_entry_first_refresh()

View file

@ -5,6 +5,7 @@ from typing import Final
DOMAIN: Final = "radarr" DOMAIN: Final = "radarr"
# Defaults # Defaults
DEFAULT_MAX_RECORDS = 20
DEFAULT_NAME = "Radarr" DEFAULT_NAME = "Radarr"
DEFAULT_URL = "http://127.0.0.1:7878" DEFAULT_URL = "http://127.0.0.1:7878"

View file

@ -3,7 +3,7 @@ from __future__ import annotations
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from datetime import timedelta 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 import Health, RadarrMovie, RootFolder, SystemStatus, exceptions
from aiopyarr.models.host_configuration import PyArrHostConfiguration from aiopyarr.models.host_configuration import PyArrHostConfiguration
@ -14,7 +14,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed 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) T = TypeVar("T", bound=SystemStatus | list[RootFolder] | list[Health] | int)
@ -90,7 +90,14 @@ class MoviesDataUpdateCoordinator(RadarrDataUpdateCoordinator[int]):
async def _fetch_data(self) -> int: async def _fetch_data(self) -> int:
"""Fetch the movies data.""" """Fetch the movies data."""
movies = await self.api_client.async_get_movies() return len(cast(list[RadarrMovie], await self.api_client.async_get_movies()))
if isinstance(movies, RadarrMovie):
return 1
return len(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

View file

@ -13,6 +13,7 @@ from homeassistant.components.sensor import (
SensorDeviceClass, SensorDeviceClass,
SensorEntity, SensorEntity,
SensorEntityDescription, SensorEntityDescription,
SensorStateClass,
) )
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory, UnitOfInformation from homeassistant.const import EntityCategory, UnitOfInformation
@ -82,6 +83,15 @@ SENSOR_TYPES: dict[str, RadarrSensorEntityDescription[Any]] = {
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
value_fn=lambda data, _: data, 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]( "status": RadarrSensorEntityDescription[SystemStatus](
key="start_time", key="start_time",
translation_key="start_time", translation_key="start_time",

View file

@ -45,6 +45,9 @@
"movies": { "movies": {
"name": "Movies" "name": "Movies"
}, },
"queue": {
"name": "Queue"
},
"start_time": { "start_time": {
"name": "Start time" "name": "Start time"
} }

View file

@ -76,6 +76,11 @@ def mock_connection(
headers={"Content-Type": CONTENT_TYPE_JSON}, 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" root_folder_fixture = "rootfolder-linux"
if windows: if windows:
@ -90,13 +95,9 @@ def mock_connection(
headers={"Content-Type": CONTENT_TYPE_JSON}, headers={"Content-Type": CONTENT_TYPE_JSON},
) )
movie_fixture = "movie"
if single_return:
movie_fixture = f"single-{movie_fixture}"
aioclient_mock.get( aioclient_mock.get(
f"{url}/api/v3/movie", 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}, headers={"Content-Type": CONTENT_TYPE_JSON},
) )
@ -114,10 +115,11 @@ def mock_connection_invalid_auth(
url: str = URL, url: str = URL,
) -> None: ) -> None:
"""Mock radarr invalid auth errors.""" """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/command", status=HTTPStatus.UNAUTHORIZED)
aioclient_mock.get(f"{url}/api/v3/movie", 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/rootfolder", status=HTTPStatus.UNAUTHORIZED)
aioclient_mock.get(f"{url}/api/v3/system/status", status=HTTPStatus.UNAUTHORIZED)
def mock_connection_server_error( def mock_connection_server_error(
@ -125,14 +127,15 @@ def mock_connection_server_error(
url: str = URL, url: str = URL,
) -> None: ) -> None:
"""Mock radarr server errors.""" """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/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/movie", status=HTTPStatus.INTERNAL_SERVER_ERROR)
aioclient_mock.get(f"{url}/api/v3/queue", status=HTTPStatus.INTERNAL_SERVER_ERROR)
aioclient_mock.get( aioclient_mock.get(
f"{url}/api/v3/rootfolder", status=HTTPStatus.INTERNAL_SERVER_ERROR 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( 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: def create_entry(hass: HomeAssistant) -> MockConfigEntry:
"""Create Radarr entry in Home Assistant.""" """Create Radarr entry in Home Assistant."""
entry = MockConfigEntry( entry = MockConfigEntry(

View file

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

View file

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

View file

@ -1,12 +1,10 @@
"""Test Radarr integration.""" """Test Radarr integration."""
from aiopyarr import exceptions
from homeassistant.components.radarr.const import DEFAULT_NAME, DOMAIN from homeassistant.components.radarr.const import DEFAULT_NAME, DOMAIN
from homeassistant.config_entries import ConfigEntryState from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr 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 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) 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.""" """Test that it throws ConfigEntryAuthFailed when authentication fails."""
entry = create_entry(hass) entry = create_entry(hass)
with patch_radarr() as radarrmock: mock_connection_invalid_auth(aioclient_mock)
radarrmock.side_effect = exceptions.ArrAuthenticationException await hass.config_entries.async_setup(entry.entry_id)
await hass.config_entries.async_setup(entry.entry_id) assert len(hass.config_entries.async_entries(DOMAIN)) == 1
assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert entry.state == ConfigEntryState.SETUP_ERROR
assert entry.state == ConfigEntryState.SETUP_ERROR assert not hass.data.get(DOMAIN)
assert not hass.data.get(DOMAIN)
async def test_device_info( async def test_device_info(

View file

@ -1,7 +1,11 @@
"""The tests for Radarr sensor platform.""" """The tests for Radarr sensor platform."""
import pytest 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.const import ATTR_DEVICE_CLASS, ATTR_UNIT_OF_MEASUREMENT
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
@ -55,3 +59,17 @@ async def test_sensors(
state = hass.states.get("sensor.mock_title_start_time") state = hass.states.get("sensor.mock_title_start_time")
assert state.state == "2020-09-01T23:50:20+00:00" assert state.state == "2020-09-01T23:50:20+00:00"
assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TIMESTAMP 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"