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:
parent
3bcc6194ef
commit
651df6b698
10 changed files with 348 additions and 5 deletions
|
@ -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),
|
||||
|
|
63
homeassistant/components/radarr/calendar.py
Normal file
63
homeassistant/components/radarr/calendar.py
Normal 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()
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -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()
|
||||
|
|
111
tests/components/radarr/fixtures/calendar.json
Normal file
111
tests/components/radarr/fixtures/calendar.json
Normal 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
|
||||
}
|
||||
]
|
|
@ -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:
|
||||
|
|
41
tests/components/radarr/test_calendar.py
Normal file
41
tests/components/radarr/test_calendar.py
Normal 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
|
|
@ -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:
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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:
|
||||
|
|
Loading…
Add table
Reference in a new issue