diff --git a/homeassistant/components/radarr/__init__.py b/homeassistant/components/radarr/__init__.py index 39258e2f787..b6b05b5b568 100644 --- a/homeassistant/components/radarr/__init__.py +++ b/homeassistant/components/radarr/__init__.py @@ -22,6 +22,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DEFAULT_NAME, DOMAIN from .coordinator import ( + CalendarUpdateCoordinator, DiskSpaceDataUpdateCoordinator, HealthDataUpdateCoordinator, MoviesDataUpdateCoordinator, @@ -31,7 +32,7 @@ from .coordinator import ( T, ) -PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.CALENDAR, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -46,6 +47,7 @@ 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]] = { + "calendar": CalendarUpdateCoordinator(hass, host_configuration, radarr), "disk_space": DiskSpaceDataUpdateCoordinator(hass, host_configuration, radarr), "health": HealthDataUpdateCoordinator(hass, host_configuration, radarr), "movie": MoviesDataUpdateCoordinator(hass, host_configuration, radarr), diff --git a/homeassistant/components/radarr/calendar.py b/homeassistant/components/radarr/calendar.py new file mode 100644 index 00000000000..3a5308fffd5 --- /dev/null +++ b/homeassistant/components/radarr/calendar.py @@ -0,0 +1,63 @@ +"""Support for Radarr calendar items.""" +from __future__ import annotations + +from datetime import datetime + +from homeassistant.components.calendar import CalendarEntity, CalendarEvent +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import RadarrEntity +from .const import DOMAIN +from .coordinator import CalendarUpdateCoordinator, RadarrEvent + +CALENDAR_TYPE = EntityDescription( + key="calendar", + name=None, +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Radarr calendar entity.""" + coordinator = hass.data[DOMAIN][entry.entry_id]["calendar"] + async_add_entities([RadarrCalendarEntity(coordinator, CALENDAR_TYPE)]) + + +class RadarrCalendarEntity(RadarrEntity, CalendarEntity): + """A Radarr calendar entity.""" + + coordinator: CalendarUpdateCoordinator + + @property + def event(self) -> CalendarEvent | None: + """Return the next upcoming event.""" + if not self.coordinator.event: + return None + return CalendarEvent( + summary=self.coordinator.event.summary, + start=self.coordinator.event.start, + end=self.coordinator.event.end, + description=self.coordinator.event.description, + ) + + # pylint: disable-next=hass-return-type + async def async_get_events( # type: ignore[override] + self, hass: HomeAssistant, start_date: datetime, end_date: datetime + ) -> list[RadarrEvent]: + """Get all events in a specific time frame.""" + return await self.coordinator.async_get_events(start_date, end_date) + + @callback + def async_write_ha_state(self) -> None: + """Write the state to the state machine.""" + if self.coordinator.event: + self._attr_extra_state_attributes = { + "release_type": self.coordinator.event.release_type + } + else: + self._attr_extra_state_attributes = {} + super().async_write_ha_state() diff --git a/homeassistant/components/radarr/coordinator.py b/homeassistant/components/radarr/coordinator.py index bd41810bfb8..c14603fe9ca 100644 --- a/homeassistant/components/radarr/coordinator.py +++ b/homeassistant/components/radarr/coordinator.py @@ -2,13 +2,23 @@ from __future__ import annotations from abc import ABC, abstractmethod -from datetime import timedelta +import asyncio +from dataclasses import dataclass +from datetime import date, datetime, timedelta from typing import Generic, TypeVar, cast -from aiopyarr import Health, RadarrMovie, RootFolder, SystemStatus, exceptions +from aiopyarr import ( + Health, + RadarrCalendarItem, + RadarrMovie, + RootFolder, + SystemStatus, + exceptions, +) from aiopyarr.models.host_configuration import PyArrHostConfiguration from aiopyarr.radarr_client import RadarrClient +from homeassistant.components.calendar import CalendarEvent from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed @@ -16,13 +26,26 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda 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 | None) + + +@dataclass +class RadarrEventMixIn: + """Mixin for Radarr calendar event.""" + + release_type: str + + +@dataclass +class RadarrEvent(CalendarEvent, RadarrEventMixIn): + """A class to describe a Radarr calendar event.""" class RadarrDataUpdateCoordinator(DataUpdateCoordinator[T], Generic[T], ABC): """Data update coordinator for the Radarr integration.""" config_entry: ConfigEntry + update_interval = timedelta(seconds=30) def __init__( self, @@ -35,7 +58,7 @@ class RadarrDataUpdateCoordinator(DataUpdateCoordinator[T], Generic[T], ABC): hass=hass, logger=LOGGER, name=DOMAIN, - update_interval=timedelta(seconds=30), + update_interval=self.update_interval, ) self.api_client = api_client self.host_configuration = host_configuration @@ -101,3 +124,77 @@ class QueueDataUpdateCoordinator(RadarrDataUpdateCoordinator): return ( await self.api_client.async_get_queue(page_size=DEFAULT_MAX_RECORDS) ).totalRecords + + +class CalendarUpdateCoordinator(RadarrDataUpdateCoordinator[None]): + """Calendar update coordinator.""" + + update_interval = timedelta(hours=1) + + def __init__( + self, + hass: HomeAssistant, + host_configuration: PyArrHostConfiguration, + api_client: RadarrClient, + ) -> None: + """Initialize.""" + super().__init__(hass, host_configuration, api_client) + self.event: RadarrEvent | None = None + self._events: list[RadarrEvent] = [] + + async def _fetch_data(self) -> None: + """Fetch the calendar.""" + self.event = None + _date = datetime.today() + while self.event is None: + await self.async_get_events(_date, _date + timedelta(days=1)) + for event in self._events: + if event.start >= _date.date(): + self.event = event + break + # Prevent infinite loop in case there is nothing recent in the calendar + if (_date - datetime.today()).days > 45: + break + _date = _date + timedelta(days=1) + + async def async_get_events( + self, start_date: datetime, end_date: datetime + ) -> list[RadarrEvent]: + """Get cached events and request missing dates.""" + # remove older events to prevent memory leak + self._events = [ + e + for e in self._events + if e.start >= datetime.now().date() - timedelta(days=30) + ] + _days = (end_date - start_date).days + await asyncio.gather( + *( + self._async_get_events(d) + for d in ((start_date + timedelta(days=x)).date() for x in range(_days)) + if d not in (event.start for event in self._events) + ) + ) + return self._events + + async def _async_get_events(self, _date: date) -> None: + """Return events from specified date.""" + self._events.extend( + _get_calendar_event(evt) + for evt in await self.api_client.async_get_calendar( + start_date=_date, end_date=_date + timedelta(days=1) + ) + if evt.title not in (e.summary for e in self._events) + ) + + +def _get_calendar_event(event: RadarrCalendarItem) -> RadarrEvent: + """Return a RadarrEvent from an API event.""" + _date, _type = event.releaseDateType() + return RadarrEvent( + summary=event.title, + start=_date - timedelta(days=1), + end=_date, + description=event.overview.replace(":", ";"), + release_type=_type, + ) diff --git a/tests/components/radarr/__init__.py b/tests/components/radarr/__init__.py index f7bdf232c9e..47204ebf537 100644 --- a/tests/components/radarr/__init__.py +++ b/tests/components/radarr/__init__.py @@ -102,6 +102,18 @@ def mock_connection( ) +def mock_calendar( + aioclient_mock: AiohttpClientMocker, + url: str = URL, +) -> None: + """Mock radarr connection.""" + aioclient_mock.get( + f"{url}/api/v3/calendar", + text=load_fixture("radarr/calendar.json"), + headers={"Content-Type": CONTENT_TYPE_JSON}, + ) + + def mock_connection_error( aioclient_mock: AiohttpClientMocker, url: str = URL, @@ -120,6 +132,7 @@ def mock_connection_invalid_auth( 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) + aioclient_mock.get(f"{url}/api/v3/calendar", status=HTTPStatus.UNAUTHORIZED) def mock_connection_server_error( @@ -136,6 +149,9 @@ def mock_connection_server_error( aioclient_mock.get( f"{url}/api/v3/system/status", status=HTTPStatus.INTERNAL_SERVER_ERROR ) + aioclient_mock.get( + f"{url}/api/v3/calendar", status=HTTPStatus.INTERNAL_SERVER_ERROR + ) async def setup_integration( @@ -172,6 +188,8 @@ async def setup_integration( single_return=single_return, ) + mock_calendar(aioclient_mock, url) + if not skip_entry_setup: await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/radarr/fixtures/calendar.json b/tests/components/radarr/fixtures/calendar.json new file mode 100644 index 00000000000..2bf0338d639 --- /dev/null +++ b/tests/components/radarr/fixtures/calendar.json @@ -0,0 +1,111 @@ +[ + { + "title": "test", + "originalTitle": "string", + "alternateTitles": [], + "secondaryYearSourceId": 0, + "sortTitle": "string", + "sizeOnDisk": 0, + "status": "string", + "overview": "test2", + "physicalRelease": "2021-12-03T00:00:00Z", + "digitalRelease": "2020-08-11T00:00:00Z", + "images": [ + { + "coverType": "poster", + "url": "string" + } + ], + "website": "string", + "year": 0, + "hasFile": true, + "youTubeTrailerId": "string", + "studio": "string", + "path": "string", + "qualityProfileId": 0, + "monitored": true, + "minimumAvailability": "string", + "isAvailable": true, + "folderName": "string", + "runtime": 0, + "cleanTitle": "string", + "imdbId": "string", + "tmdbId": 0, + "titleSlug": "0", + "genres": ["string"], + "tags": [], + "added": "2020-07-16T13:25:37Z", + "ratings": { + "imdb": { + "votes": 0, + "value": 0.0, + "type": "string" + }, + "tmdb": { + "votes": 0, + "value": 0.0, + "type": "string" + }, + "metacritic": { + "votes": 0, + "value": 0, + "type": "string" + }, + "rottenTomatoes": { + "votes": 0, + "value": 0, + "type": "string" + } + }, + "movieFile": { + "movieId": 0, + "relativePath": "string", + "path": "string", + "size": 0, + "dateAdded": "2021-06-01T04:08:20Z", + "sceneName": "string", + "indexerFlags": 0, + "quality": { + "quality": { + "id": 0, + "name": "string", + "source": "string", + "resolution": 0, + "modifier": "string" + }, + "revision": { + "version": 0, + "real": 0, + "isRepack": false + } + }, + "mediaInfo": { + "audioBitrate": 0, + "audioChannels": 0.0, + "audioCodec": "string", + "audioLanguages": "string", + "audioStreamCount": 0, + "videoBitDepth": 0, + "videoBitrate": 0, + "videoCodec": "string", + "videoFps": 0.0, + "resolution": "string", + "runTime": "00:00:00", + "scanType": "string", + "subtitles": "string" + }, + "originalFilePath": "string", + "qualityCutoffNotMet": false, + "languages": [ + { + "id": 0, + "name": "string" + } + ], + "releaseGroup": "string", + "edition": "string", + "id": 0 + }, + "id": 0 + } +] diff --git a/tests/components/radarr/test_binary_sensor.py b/tests/components/radarr/test_binary_sensor.py index b6303de4a48..cd1df721d5f 100644 --- a/tests/components/radarr/test_binary_sensor.py +++ b/tests/components/radarr/test_binary_sensor.py @@ -1,4 +1,6 @@ """The tests for Radarr binary sensor platform.""" +import pytest + from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.const import ATTR_DEVICE_CLASS, STATE_ON from homeassistant.core import HomeAssistant @@ -8,6 +10,7 @@ from . import setup_integration from tests.test_util.aiohttp import AiohttpClientMocker +@pytest.mark.freeze_time("2021-12-03 00:00:00+00:00") async def test_binary_sensors( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: diff --git a/tests/components/radarr/test_calendar.py b/tests/components/radarr/test_calendar.py new file mode 100644 index 00000000000..61e9bc27c9b --- /dev/null +++ b/tests/components/radarr/test_calendar.py @@ -0,0 +1,41 @@ +"""The tests for Radarr calendar platform.""" +from datetime import timedelta + +from freezegun.api import FrozenDateTimeFactory + +from homeassistant.components.radarr.const import DOMAIN +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from . import setup_integration + +from tests.test_util.aiohttp import AiohttpClientMocker + + +async def test_calendar( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + freezer: FrozenDateTimeFactory, +) -> None: + """Test for successfully setting up the Radarr platform.""" + freezer.move_to("2021-12-02 00:00:00-08:00") + entry = await setup_integration(hass, aioclient_mock) + coordinator: DataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]["calendar"] + + state = hass.states.get("calendar.mock_title") + assert state.state == STATE_ON + assert state.attributes.get("all_day") is True + assert state.attributes.get("description") == "test2" + assert state.attributes.get("end_time") == "2021-12-03 00:00:00" + assert state.attributes.get("message") == "test" + assert state.attributes.get("release_type") == "physicalRelease" + assert state.attributes.get("start_time") == "2021-12-02 00:00:00" + + freezer.tick(timedelta(hours=16)) + await coordinator.async_refresh() + + state = hass.states.get("calendar.mock_title") + assert state.state == STATE_OFF + assert len(state.attributes) == 1 + assert state.attributes.get("release_type") is None diff --git a/tests/components/radarr/test_config_flow.py b/tests/components/radarr/test_config_flow.py index 5527e311114..5eab7c02bb9 100644 --- a/tests/components/radarr/test_config_flow.py +++ b/tests/components/radarr/test_config_flow.py @@ -2,6 +2,7 @@ from unittest.mock import patch from aiopyarr import exceptions +import pytest from homeassistant.components.radarr.const import DEFAULT_NAME, DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER @@ -135,6 +136,7 @@ async def test_zero_conf(hass: HomeAssistant) -> None: assert result["data"] == CONF_DATA +@pytest.mark.freeze_time("2021-12-03 00:00:00+00:00") async def test_full_reauth_flow_implementation( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: diff --git a/tests/components/radarr/test_init.py b/tests/components/radarr/test_init.py index f16e5895633..62660c12874 100644 --- a/tests/components/radarr/test_init.py +++ b/tests/components/radarr/test_init.py @@ -1,4 +1,6 @@ """Test Radarr integration.""" +import pytest + from homeassistant.components.radarr.const import DEFAULT_NAME, DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -9,6 +11,7 @@ from . import create_entry, mock_connection_invalid_auth, setup_integration from tests.test_util.aiohttp import AiohttpClientMocker +@pytest.mark.freeze_time("2021-12-03 00:00:00+00:00") async def test_setup(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> None: """Test unload.""" entry = await setup_integration(hass, aioclient_mock) @@ -43,6 +46,7 @@ async def test_async_setup_entry_auth_failed( assert not hass.data.get(DOMAIN) +@pytest.mark.freeze_time("2021-12-03 00:00:00+00:00") async def test_device_info( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: diff --git a/tests/components/radarr/test_sensor.py b/tests/components/radarr/test_sensor.py index 90ab683037b..11f55b712cd 100644 --- a/tests/components/radarr/test_sensor.py +++ b/tests/components/radarr/test_sensor.py @@ -14,6 +14,7 @@ from . import setup_integration from tests.test_util.aiohttp import AiohttpClientMocker +@pytest.mark.freeze_time("2021-12-03 00:00:00+00:00") @pytest.mark.parametrize( ("windows", "single", "root_folder"), [ @@ -65,6 +66,7 @@ async def test_sensors( assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.TOTAL +@pytest.mark.freeze_time("2021-12-03 00:00:00+00:00") async def test_windows( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: