Add calendar entity to Radarr (#79077)

* Add calendar entity to Radarr

* address feedback/add tests

* black

* uno mas

* rework to coordinator

* uno mas

* move release atttribute writing

* fix calendar items and attributes
This commit is contained in:
Robert Hillis 2023-12-05 10:51:51 -05:00 committed by GitHub
parent 3bcc6194ef
commit 651df6b698
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 348 additions and 5 deletions

View File

@ -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),

View File

@ -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()

View File

@ -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,
)

View File

@ -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()

View File

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

View File

@ -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:

View File

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

View File

@ -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:

View File

@ -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:

View File

@ -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: