From f30681dae7efffd8980b3ee3ae7f355c603b842c Mon Sep 17 00:00:00 2001 From: Chris Talkington Date: Tue, 22 Feb 2022 11:33:10 -0600 Subject: [PATCH] Use aiopyarr for sonarr (#65349) --- homeassistant/components/sonarr/__init__.py | 57 +++- .../components/sonarr/config_flow.py | 55 ++-- homeassistant/components/sonarr/const.py | 7 +- homeassistant/components/sonarr/entity.py | 18 +- homeassistant/components/sonarr/manifest.json | 4 +- homeassistant/components/sonarr/sensor.py | 107 ++++--- homeassistant/components/sonarr/strings.json | 7 +- requirements_all.txt | 6 +- requirements_test_all.txt | 6 +- tests/components/sonarr/__init__.py | 8 +- tests/components/sonarr/conftest.py | 109 ++++---- tests/components/sonarr/fixtures/app.json | 28 -- tests/components/sonarr/fixtures/command.json | 3 +- tests/components/sonarr/fixtures/queue.json | 263 +++++++++--------- tests/components/sonarr/fixtures/series.json | 13 +- .../sonarr/fixtures/system-status.json | 29 ++ .../sonarr/fixtures/wanted-missing.json | 2 +- tests/components/sonarr/test_config_flow.py | 16 +- tests/components/sonarr/test_init.py | 49 +++- tests/components/sonarr/test_sensor.py | 22 +- 20 files changed, 464 insertions(+), 345 deletions(-) delete mode 100644 tests/components/sonarr/fixtures/app.json create mode 100644 tests/components/sonarr/fixtures/system-status.json diff --git a/homeassistant/components/sonarr/__init__.py b/homeassistant/components/sonarr/__init__.py index b574e68ae2f..9934cc8f481 100644 --- a/homeassistant/components/sonarr/__init__.py +++ b/homeassistant/components/sonarr/__init__.py @@ -2,8 +2,11 @@ from __future__ import annotations from datetime import timedelta +import logging -from sonarr import Sonarr, SonarrAccessRestricted, SonarrError +from aiopyarr import ArrAuthenticationException, ArrException +from aiopyarr.models.host_configuration import PyArrHostConfiguration +from aiopyarr.sonarr_client import SonarrClient from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -11,6 +14,7 @@ from homeassistant.const import ( CONF_HOST, CONF_PORT, CONF_SSL, + CONF_URL, CONF_VERIFY_SSL, Platform, ) @@ -22,7 +26,9 @@ from .const import ( CONF_BASE_PATH, CONF_UPCOMING_DAYS, CONF_WANTED_MAX_ITEMS, + DATA_HOST_CONFIG, DATA_SONARR, + DATA_SYSTEM_STATUS, DEFAULT_UPCOMING_DAYS, DEFAULT_WANTED_MAX_ITEMS, DOMAIN, @@ -30,6 +36,7 @@ from .const import ( PLATFORMS = [Platform.SENSOR] SCAN_INTERVAL = timedelta(seconds=30) +_LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -45,30 +52,33 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: } hass.config_entries.async_update_entry(entry, options=options) - sonarr = Sonarr( - host=entry.data[CONF_HOST], - port=entry.data[CONF_PORT], - api_key=entry.data[CONF_API_KEY], - base_path=entry.data[CONF_BASE_PATH], - session=async_get_clientsession(hass), - tls=entry.data[CONF_SSL], + host_configuration = PyArrHostConfiguration( + api_token=entry.data[CONF_API_KEY], + url=entry.data[CONF_URL], verify_ssl=entry.data[CONF_VERIFY_SSL], ) + sonarr = SonarrClient( + host_configuration=host_configuration, + session=async_get_clientsession(hass), + ) + try: - await sonarr.update() - except SonarrAccessRestricted as err: + system_status = await sonarr.async_get_system_status() + except ArrAuthenticationException as err: raise ConfigEntryAuthFailed( "API Key is no longer valid. Please reauthenticate" ) from err - except SonarrError as err: + except ArrException as err: raise ConfigEntryNotReady from err entry.async_on_unload(entry.add_update_listener(_async_update_listener)) hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][entry.entry_id] = { + DATA_HOST_CONFIG: host_configuration, DATA_SONARR: sonarr, + DATA_SYSTEM_STATUS: system_status, } hass.config_entries.async_setup_platforms(entry, PLATFORMS) @@ -76,6 +86,31 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True +async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Migrate old entry.""" + _LOGGER.debug("Migrating from version %s", entry.version) + + if entry.version == 1: + new_proto = "https" if entry.data[CONF_SSL] else "http" + new_host_port = f"{entry.data[CONF_HOST]}:{entry.data[CONF_PORT]}" + + new_path = "" + + if entry.data[CONF_BASE_PATH].rstrip("/") not in ("", "/", "/api"): + new_path = entry.data[CONF_BASE_PATH].rstrip("/") + + data = { + **entry.data, + CONF_URL: f"{new_proto}://{new_host_port}{new_path}", + } + hass.config_entries.async_update_entry(entry, data=data) + entry.version = 2 + + _LOGGER.info("Migration to version %s successful", entry.version) + + return True + + async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/sonarr/config_flow.py b/homeassistant/components/sonarr/config_flow.py index f226d1883a5..c9ef2d3ecc8 100644 --- a/homeassistant/components/sonarr/config_flow.py +++ b/homeassistant/components/sonarr/config_flow.py @@ -4,28 +4,21 @@ from __future__ import annotations import logging from typing import Any -from sonarr import Sonarr, SonarrAccessRestricted, SonarrError +from aiopyarr import ArrAuthenticationException, ArrException +from aiopyarr.models.host_configuration import PyArrHostConfiguration +from aiopyarr.sonarr_client import SonarrClient import voluptuous as vol +import yarl from homeassistant.config_entries import ConfigFlow, OptionsFlow -from homeassistant.const import ( - CONF_API_KEY, - CONF_HOST, - CONF_PORT, - CONF_SSL, - CONF_VERIFY_SSL, -) +from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import ( - CONF_BASE_PATH, CONF_UPCOMING_DAYS, CONF_WANTED_MAX_ITEMS, - DEFAULT_BASE_PATH, - DEFAULT_PORT, - DEFAULT_SSL, DEFAULT_UPCOMING_DAYS, DEFAULT_VERIFY_SSL, DEFAULT_WANTED_MAX_ITEMS, @@ -40,25 +33,24 @@ async def validate_input(hass: HomeAssistant, data: dict) -> None: Data has the keys from DATA_SCHEMA with values provided by the user. """ - session = async_get_clientsession(hass) - - sonarr = Sonarr( - host=data[CONF_HOST], - port=data[CONF_PORT], - api_key=data[CONF_API_KEY], - base_path=data[CONF_BASE_PATH], - tls=data[CONF_SSL], + host_configuration = PyArrHostConfiguration( + api_token=data[CONF_API_KEY], + url=data[CONF_URL], verify_ssl=data[CONF_VERIFY_SSL], - session=session, ) - await sonarr.update() + sonarr = SonarrClient( + host_configuration=host_configuration, + session=async_get_clientsession(hass), + ) + + await sonarr.async_get_system_status() class SonarrConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Sonarr.""" - VERSION = 1 + VERSION = 2 def __init__(self): """Initialize the flow.""" @@ -83,7 +75,7 @@ class SonarrConfigFlow(ConfigFlow, domain=DOMAIN): if user_input is None: return self.async_show_form( step_id="reauth_confirm", - description_placeholders={"host": self.entry.data[CONF_HOST]}, + description_placeholders={"url": self.entry.data[CONF_URL]}, data_schema=vol.Schema({}), errors={}, ) @@ -105,9 +97,9 @@ class SonarrConfigFlow(ConfigFlow, domain=DOMAIN): try: await validate_input(self.hass, user_input) - except SonarrAccessRestricted: + except ArrAuthenticationException: errors = {"base": "invalid_auth"} - except SonarrError: + except ArrException: errors = {"base": "cannot_connect"} except Exception: # pylint: disable=broad-except _LOGGER.exception("Unexpected exception") @@ -116,8 +108,10 @@ class SonarrConfigFlow(ConfigFlow, domain=DOMAIN): if self.entry: return await self._async_reauth_update_entry(user_input) + parsed = yarl.URL(user_input[CONF_URL]) + return self.async_create_entry( - title=user_input[CONF_HOST], data=user_input + title=parsed.host or "Sonarr", data=user_input ) data_schema = self._get_user_data_schema() @@ -139,12 +133,9 @@ class SonarrConfigFlow(ConfigFlow, domain=DOMAIN): if self.entry: return {vol.Required(CONF_API_KEY): str} - data_schema = { - vol.Required(CONF_HOST): str, + data_schema: dict[str, Any] = { + vol.Required(CONF_URL): str, vol.Required(CONF_API_KEY): str, - vol.Optional(CONF_BASE_PATH, default=DEFAULT_BASE_PATH): str, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): int, - vol.Optional(CONF_SSL, default=DEFAULT_SSL): bool, } if self.show_advanced_options: diff --git a/homeassistant/components/sonarr/const.py b/homeassistant/components/sonarr/const.py index be0fa00d597..58f5c465716 100644 --- a/homeassistant/components/sonarr/const.py +++ b/homeassistant/components/sonarr/const.py @@ -7,17 +7,14 @@ CONF_DAYS = "days" CONF_INCLUDED = "include_paths" CONF_UNIT = "unit" CONF_UPCOMING_DAYS = "upcoming_days" -CONF_URLBASE = "urlbase" CONF_WANTED_MAX_ITEMS = "wanted_max_items" # Data +DATA_HOST_CONFIG = "host_config" DATA_SONARR = "sonarr" +DATA_SYSTEM_STATUS = "system_status" # Defaults -DEFAULT_BASE_PATH = "/api" -DEFAULT_HOST = "localhost" -DEFAULT_PORT = 8989 -DEFAULT_SSL = False DEFAULT_UPCOMING_DAYS = 1 DEFAULT_VERIFY_SSL = False DEFAULT_WANTED_MAX_ITEMS = 50 diff --git a/homeassistant/components/sonarr/entity.py b/homeassistant/components/sonarr/entity.py index 1d0cb2ce6f3..41f6786503d 100644 --- a/homeassistant/components/sonarr/entity.py +++ b/homeassistant/components/sonarr/entity.py @@ -1,7 +1,9 @@ """Base Entity for Sonarr.""" from __future__ import annotations -from sonarr import Sonarr +from aiopyarr import SystemStatus +from aiopyarr.models.host_configuration import PyArrHostConfiguration +from aiopyarr.sonarr_client import SonarrClient from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo, Entity @@ -15,7 +17,9 @@ class SonarrEntity(Entity): def __init__( self, *, - sonarr: Sonarr, + sonarr: SonarrClient, + host_config: PyArrHostConfiguration, + system_status: SystemStatus, entry_id: str, device_id: str, ) -> None: @@ -23,6 +27,8 @@ class SonarrEntity(Entity): self._entry_id = entry_id self._device_id = device_id self.sonarr = sonarr + self.host_config = host_config + self.system_status = system_status @property def device_info(self) -> DeviceInfo | None: @@ -30,15 +36,11 @@ class SonarrEntity(Entity): if self._device_id is None: return None - configuration_url = "https://" if self.sonarr.tls else "http://" - configuration_url += f"{self.sonarr.host}:{self.sonarr.port}" - configuration_url += self.sonarr.base_path.replace("/api", "") - return DeviceInfo( identifiers={(DOMAIN, self._device_id)}, name="Activity Sensor", manufacturer="Sonarr", - sw_version=self.sonarr.app.info.version, + sw_version=self.system_status.version, entry_type=DeviceEntryType.SERVICE, - configuration_url=configuration_url, + configuration_url=self.host_config.base_url, ) diff --git a/homeassistant/components/sonarr/manifest.json b/homeassistant/components/sonarr/manifest.json index 4b1555fa3de..9c43bfed282 100644 --- a/homeassistant/components/sonarr/manifest.json +++ b/homeassistant/components/sonarr/manifest.json @@ -3,9 +3,9 @@ "name": "Sonarr", "documentation": "https://www.home-assistant.io/integrations/sonarr", "codeowners": ["@ctalkington"], - "requirements": ["sonarr==0.3.0"], + "requirements": ["aiopyarr==22.2.1"], "config_flow": true, "quality_scale": "silver", "iot_class": "local_polling", - "loggers": ["sonarr"] + "loggers": ["aiopyarr"] } diff --git a/homeassistant/components/sonarr/sensor.py b/homeassistant/components/sonarr/sensor.py index 01046ded7c2..c182bb2bbeb 100644 --- a/homeassistant/components/sonarr/sensor.py +++ b/homeassistant/components/sonarr/sensor.py @@ -7,7 +7,9 @@ from functools import wraps import logging from typing import Any, TypeVar -from sonarr import Sonarr, SonarrConnectionError, SonarrError +from aiopyarr import ArrConnectionException, ArrException, SystemStatus +from aiopyarr.models.host_configuration import PyArrHostConfiguration +from aiopyarr.sonarr_client import SonarrClient from typing_extensions import Concatenate, ParamSpec from homeassistant.components.sensor import SensorEntity, SensorEntityDescription @@ -18,7 +20,14 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType import homeassistant.util.dt as dt_util -from .const import CONF_UPCOMING_DAYS, CONF_WANTED_MAX_ITEMS, DATA_SONARR, DOMAIN +from .const import ( + CONF_UPCOMING_DAYS, + CONF_WANTED_MAX_ITEMS, + DATA_HOST_CONFIG, + DATA_SONARR, + DATA_SYSTEM_STATUS, + DOMAIN, +) from .entity import SonarrEntity _LOGGER = logging.getLogger(__name__) @@ -77,11 +86,22 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Sonarr sensors based on a config entry.""" - sonarr: Sonarr = hass.data[DOMAIN][entry.entry_id][DATA_SONARR] + sonarr: SonarrClient = hass.data[DOMAIN][entry.entry_id][DATA_SONARR] + host_config: PyArrHostConfiguration = hass.data[DOMAIN][entry.entry_id][ + DATA_HOST_CONFIG + ] + system_status: SystemStatus = hass.data[DOMAIN][entry.entry_id][DATA_SYSTEM_STATUS] options: dict[str, Any] = dict(entry.options) entities = [ - SonarrSensor(sonarr, entry.entry_id, description, options) + SonarrSensor( + sonarr, + host_config, + system_status, + entry.entry_id, + description, + options, + ) for description in SENSOR_TYPES ] @@ -102,12 +122,12 @@ def sonarr_exception_handler( try: await func(self, *args, **kwargs) self.last_update_success = True - except SonarrConnectionError as error: - if self.available: + except ArrConnectionException as error: + if self.last_update_success: _LOGGER.error("Error communicating with API: %s", error) self.last_update_success = False - except SonarrError as error: - if self.available: + except ArrException as error: + if self.last_update_success: _LOGGER.error("Invalid response from API: %s", error) self.last_update_success = False @@ -124,7 +144,9 @@ class SonarrSensor(SonarrEntity, SensorEntity): def __init__( self, - sonarr: Sonarr, + sonarr: SonarrClient, + host_config: PyArrHostConfiguration, + system_status: SystemStatus, entry_id: str, description: SensorEntityDescription, options: dict[str, Any], @@ -140,6 +162,8 @@ class SonarrSensor(SonarrEntity, SensorEntity): super().__init__( sonarr=sonarr, + host_config=host_config, + system_status=system_status, entry_id=entry_id, device_id=entry_id, ) @@ -155,23 +179,30 @@ class SonarrSensor(SonarrEntity, SensorEntity): key = self.entity_description.key if key == "diskspace": - await self.sonarr.update() + self.data[key] = await self.sonarr.async_get_diskspace() elif key == "commands": - self.data[key] = await self.sonarr.commands() + self.data[key] = await self.sonarr.async_get_commands() elif key == "queue": - self.data[key] = await self.sonarr.queue() + self.data[key] = await self.sonarr.async_get_queue( + include_series=True, include_episode=True + ) elif key == "series": - self.data[key] = await self.sonarr.series() + self.data[key] = await self.sonarr.async_get_series() elif key == "upcoming": local = dt_util.start_of_local_day().replace(microsecond=0) start = dt_util.as_utc(local) end = start + timedelta(days=self.upcoming_days) - self.data[key] = await self.sonarr.calendar( - start=start.isoformat(), end=end.isoformat() + self.data[key] = await self.sonarr.async_get_calendar( + start_date=start, + end_date=end, + include_series=True, ) elif key == "wanted": - self.data[key] = await self.sonarr.wanted(page_size=self.wanted_max_items) + self.data[key] = await self.sonarr.async_get_wanted( + page_size=self.wanted_max_items, + include_series=True, + ) @property def extra_state_attributes(self) -> dict[str, str] | None: @@ -179,10 +210,10 @@ class SonarrSensor(SonarrEntity, SensorEntity): attrs = {} key = self.entity_description.key - if key == "diskspace": - for disk in self.sonarr.app.disks: - free = disk.free / 1024**3 - total = disk.total / 1024**3 + if key == "diskspace" and self.data.get(key) is not None: + for disk in self.data[key]: + free = disk.freeSpace / 1024**3 + total = disk.totalSpace / 1024**3 usage = free / total * 100 attrs[ @@ -190,23 +221,33 @@ class SonarrSensor(SonarrEntity, SensorEntity): ] = f"{free:.2f}/{total:.2f}{self.unit_of_measurement} ({usage:.2f}%)" elif key == "commands" and self.data.get(key) is not None: for command in self.data[key]: - attrs[command.name] = command.state + attrs[command.name] = command.status elif key == "queue" and self.data.get(key) is not None: - for item in self.data[key]: - remaining = 1 if item.size == 0 else item.size_remaining / item.size + for item in self.data[key].records: + remaining = 1 if item.size == 0 else item.sizeleft / item.size remaining_pct = 100 * (1 - remaining) - name = f"{item.episode.series.title} {item.episode.identifier}" + identifier = f"S{item.episode.seasonNumber:02d}E{item.episode. episodeNumber:02d}" + + name = f"{item.series.title} {identifier}" attrs[name] = f"{remaining_pct:.2f}%" elif key == "series" and self.data.get(key) is not None: for item in self.data[key]: - attrs[item.series.title] = f"{item.downloaded}/{item.episodes} Episodes" + stats = item.statistics + attrs[ + item.title + ] = f"{stats.episodeFileCount}/{stats.episodeCount} Episodes" elif key == "upcoming" and self.data.get(key) is not None: for episode in self.data[key]: - attrs[episode.series.title] = episode.identifier + identifier = f"S{episode.seasonNumber:02d}E{episode.episodeNumber:02d}" + attrs[episode.series.title] = identifier elif key == "wanted" and self.data.get(key) is not None: - for episode in self.data[key].episodes: - name = f"{episode.series.title} {episode.identifier}" - attrs[name] = episode.airdate + for item in self.data[key].records: + identifier = f"S{item.seasonNumber:02d}E{item.episodeNumber:02d}" + + name = f"{item.series.title} {identifier}" + attrs[name] = dt_util.as_local( + item.airDateUtc.replace(tzinfo=dt_util.UTC) + ).isoformat() return attrs @@ -215,8 +256,8 @@ class SonarrSensor(SonarrEntity, SensorEntity): """Return the state of the sensor.""" key = self.entity_description.key - if key == "diskspace": - total_free = sum(disk.free for disk in self.sonarr.app.disks) + if key == "diskspace" and self.data.get(key) is not None: + total_free = sum(disk.freeSpace for disk in self.data[key]) free = total_free / 1024**3 return f"{free:.2f}" @@ -224,7 +265,7 @@ class SonarrSensor(SonarrEntity, SensorEntity): return len(self.data[key]) if key == "queue" and self.data.get(key) is not None: - return len(self.data[key]) + return self.data[key].totalRecords if key == "series" and self.data.get(key) is not None: return len(self.data[key]) @@ -233,6 +274,6 @@ class SonarrSensor(SonarrEntity, SensorEntity): return len(self.data[key]) if key == "wanted" and self.data.get(key) is not None: - return self.data[key].total + return self.data[key].totalRecords return None diff --git a/homeassistant/components/sonarr/strings.json b/homeassistant/components/sonarr/strings.json index 2281b6cec57..b8537e11442 100644 --- a/homeassistant/components/sonarr/strings.json +++ b/homeassistant/components/sonarr/strings.json @@ -4,17 +4,14 @@ "step": { "user": { "data": { - "host": "[%key:common::config_flow::data::host%]", + "url": "[%key:common::config_flow::data::url%]", "api_key": "[%key:common::config_flow::data::api_key%]", - "base_path": "Path to API", - "port": "[%key:common::config_flow::data::port%]", - "ssl": "[%key:common::config_flow::data::ssl%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" } }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", - "description": "The Sonarr integration needs to be manually re-authenticated with the Sonarr API hosted at: {host}" + "description": "The Sonarr integration needs to be manually re-authenticated with the Sonarr API hosted at: {url}" } }, "error": { diff --git a/requirements_all.txt b/requirements_all.txt index 52751230625..5f12ee60725 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -244,6 +244,9 @@ aiopvapi==1.6.19 # homeassistant.components.pvpc_hourly_pricing aiopvpc==3.0.0 +# homeassistant.components.sonarr +aiopyarr==22.2.1 + # homeassistant.components.recollect_waste aiorecollect==1.0.8 @@ -2252,9 +2255,6 @@ somecomfort==0.8.0 # homeassistant.components.somfy_mylink somfy-mylink-synergy==1.0.6 -# homeassistant.components.sonarr -sonarr==0.3.0 - # homeassistant.components.marytts speak2mary==1.4.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b68b3b2b3b9..1832922baef 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -179,6 +179,9 @@ aiopvapi==1.6.19 # homeassistant.components.pvpc_hourly_pricing aiopvpc==3.0.0 +# homeassistant.components.sonarr +aiopyarr==22.2.1 + # homeassistant.components.recollect_waste aiorecollect==1.0.8 @@ -1388,9 +1391,6 @@ somecomfort==0.8.0 # homeassistant.components.somfy_mylink somfy-mylink-synergy==1.0.6 -# homeassistant.components.sonarr -sonarr==0.3.0 - # homeassistant.components.marytts speak2mary==1.4.0 diff --git a/tests/components/sonarr/__init__.py b/tests/components/sonarr/__init__.py index cd3fb8f795a..ca9fc91bd5e 100644 --- a/tests/components/sonarr/__init__.py +++ b/tests/components/sonarr/__init__.py @@ -1,13 +1,9 @@ """Tests for the Sonarr component.""" -from homeassistant.components.sonarr.const import CONF_BASE_PATH -from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PORT, CONF_SSL +from homeassistant.const import CONF_API_KEY, CONF_URL MOCK_REAUTH_INPUT = {CONF_API_KEY: "test-api-key-reauth"} MOCK_USER_INPUT = { - CONF_HOST: "192.168.1.189", - CONF_PORT: 8989, - CONF_BASE_PATH: "/api", - CONF_SSL: False, + CONF_URL: "http://192.168.1.189:8989", CONF_API_KEY: "MOCK_API_KEY", } diff --git a/tests/components/sonarr/conftest.py b/tests/components/sonarr/conftest.py index a03ae7532d4..da8ff75df0f 100644 --- a/tests/components/sonarr/conftest.py +++ b/tests/components/sonarr/conftest.py @@ -3,15 +3,16 @@ from collections.abc import Generator import json from unittest.mock import MagicMock, patch -import pytest -from sonarr.models import ( - Application, - CommandItem, - Episode, - QueueItem, - SeriesItem, - WantedResults, +from aiopyarr import ( + Command, + Diskspace, + SonarrCalendar, + SonarrQueue, + SonarrSeries, + SonarrWantedMissing, + SystemStatus, ) +import pytest from homeassistant.components.sonarr.const import ( CONF_BASE_PATH, @@ -33,34 +34,46 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry, load_fixture -def sonarr_calendar(): +def sonarr_calendar() -> list[SonarrCalendar]: """Generate a response for the calendar method.""" results = json.loads(load_fixture("sonarr/calendar.json")) - return [Episode.from_dict(result) for result in results] + return [SonarrCalendar(result) for result in results] -def sonarr_commands(): +def sonarr_commands() -> list[Command]: """Generate a response for the commands method.""" results = json.loads(load_fixture("sonarr/command.json")) - return [CommandItem.from_dict(result) for result in results] + return [Command(result) for result in results] -def sonarr_queue(): +def sonarr_diskspace() -> list[Diskspace]: + """Generate a response for the diskspace method.""" + results = json.loads(load_fixture("sonarr/diskspace.json")) + return [Diskspace(result) for result in results] + + +def sonarr_queue() -> SonarrQueue: """Generate a response for the queue method.""" results = json.loads(load_fixture("sonarr/queue.json")) - return [QueueItem.from_dict(result) for result in results] + return SonarrQueue(results) -def sonarr_series(): +def sonarr_series() -> list[SonarrSeries]: """Generate a response for the series method.""" results = json.loads(load_fixture("sonarr/series.json")) - return [SeriesItem.from_dict(result) for result in results] + return [SonarrSeries(result) for result in results] -def sonarr_wanted(): +def sonarr_system_status() -> SystemStatus: + """Generate a response for the system status method.""" + result = json.loads(load_fixture("sonarr/system-status.json")) + return SystemStatus(result) + + +def sonarr_wanted() -> SonarrWantedMissing: """Generate a response for the wanted method.""" results = json.loads(load_fixture("sonarr/wanted-missing.json")) - return WantedResults.from_dict(results) + return SonarrWantedMissing(results) @pytest.fixture @@ -95,54 +108,38 @@ def mock_setup_entry() -> Generator[None, None, None]: @pytest.fixture -def mock_sonarr_config_flow( - request: pytest.FixtureRequest, -) -> Generator[None, MagicMock, None]: +def mock_sonarr_config_flow() -> Generator[None, MagicMock, None]: """Return a mocked Sonarr client.""" - fixture: str = "sonarr/app.json" - if hasattr(request, "param") and request.param: - fixture = request.param - - app = Application(json.loads(load_fixture(fixture))) with patch( - "homeassistant.components.sonarr.config_flow.Sonarr", autospec=True + "homeassistant.components.sonarr.config_flow.SonarrClient", autospec=True ) as sonarr_mock: client = sonarr_mock.return_value - client.host = "192.168.1.189" - client.port = 8989 - client.base_path = "/api" - client.tls = False - client.app = app - client.update.return_value = app - client.calendar.return_value = sonarr_calendar() - client.commands.return_value = sonarr_commands() - client.queue.return_value = sonarr_queue() - client.series.return_value = sonarr_series() - client.wanted.return_value = sonarr_wanted() + client.async_get_calendar.return_value = sonarr_calendar() + client.async_get_commands.return_value = sonarr_commands() + client.async_get_diskspace.return_value = sonarr_diskspace() + client.async_get_queue.return_value = sonarr_queue() + client.async_get_series.return_value = sonarr_series() + client.async_get_system_status.return_value = sonarr_system_status() + client.async_get_wanted.return_value = sonarr_wanted() + yield client @pytest.fixture -def mock_sonarr(request: pytest.FixtureRequest) -> Generator[None, MagicMock, None]: +def mock_sonarr() -> Generator[None, MagicMock, None]: """Return a mocked Sonarr client.""" - fixture: str = "sonarr/app.json" - if hasattr(request, "param") and request.param: - fixture = request.param - - app = Application(json.loads(load_fixture(fixture))) - with patch("homeassistant.components.sonarr.Sonarr", autospec=True) as sonarr_mock: + with patch( + "homeassistant.components.sonarr.SonarrClient", autospec=True + ) as sonarr_mock: client = sonarr_mock.return_value - client.host = "192.168.1.189" - client.port = 8989 - client.base_path = "/api" - client.tls = False - client.app = app - client.update.return_value = app - client.calendar.return_value = sonarr_calendar() - client.commands.return_value = sonarr_commands() - client.queue.return_value = sonarr_queue() - client.series.return_value = sonarr_series() - client.wanted.return_value = sonarr_wanted() + client.async_get_calendar.return_value = sonarr_calendar() + client.async_get_commands.return_value = sonarr_commands() + client.async_get_diskspace.return_value = sonarr_diskspace() + client.async_get_queue.return_value = sonarr_queue() + client.async_get_series.return_value = sonarr_series() + client.async_get_system_status.return_value = sonarr_system_status() + client.async_get_wanted.return_value = sonarr_wanted() + yield client diff --git a/tests/components/sonarr/fixtures/app.json b/tests/components/sonarr/fixtures/app.json deleted file mode 100644 index e9ce88b233e..00000000000 --- a/tests/components/sonarr/fixtures/app.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "info": { - "version": "2.0.0.1121", - "buildTime": "2014-02-08T20:49:36.5560392Z", - "isDebug": false, - "isProduction": true, - "isAdmin": true, - "isUserInteractive": false, - "startupPath": "C:\\ProgramData\\NzbDrone\\bin", - "appData": "C:\\ProgramData\\NzbDrone", - "osVersion": "6.2.9200.0", - "isMono": false, - "isLinux": false, - "isWindows": true, - "branch": "develop", - "authentication": false, - "startOfWeek": 0, - "urlBase": "" - }, - "diskspace": [ - { - "path": "C:\\", - "label": "", - "freeSpace": 282500067328, - "totalSpace": 499738734592 - } - ] -} diff --git a/tests/components/sonarr/fixtures/command.json b/tests/components/sonarr/fixtures/command.json index 97acc2f9f82..943bed3308c 100644 --- a/tests/components/sonarr/fixtures/command.json +++ b/tests/components/sonarr/fixtures/command.json @@ -17,7 +17,6 @@ "queued": "2020-04-06T16:54:06.41945Z", "started": "2020-04-06T16:54:06.421322Z", "trigger": "manual", - "state": "started", "manual": true, "startedOn": "2020-04-06T16:54:06.41945Z", "stateChangeTime": "2020-04-06T16:54:06.421322Z", @@ -27,7 +26,7 @@ }, { "name": "RefreshSeries", - "state": "started", + "status": "started", "startedOn": "2020-04-06T16:57:51.406504Z", "stateChangeTime": "2020-04-06T16:57:51.417931Z", "sendUpdatesToClient": true, diff --git a/tests/components/sonarr/fixtures/queue.json b/tests/components/sonarr/fixtures/queue.json index 1a8eb0924c3..493353e2d88 100644 --- a/tests/components/sonarr/fixtures/queue.json +++ b/tests/components/sonarr/fixtures/queue.json @@ -1,129 +1,140 @@ -[ - { - "series": { - "title": "The Andy Griffith Show", - "sortTitle": "andy griffith show", - "seasonCount": 8, - "status": "ended", - "overview": "Down-home humor and an endearing cast of characters helped make The Andy Griffith Show one of the most beloved comedies in the history of TV. The show centered around widower Andy Taylor, who divided his time between raising his young son Opie, and his job as sheriff of the sleepy North Carolina town, Mayberry. Andy and Opie live with Andy's Aunt Bee, who serves as a surrogate mother to both father and son. Andy's nervous cousin, Barney Fife, is his deputy sheriff whose incompetence is tolerated because Mayberry is virtually crime-free.", - "network": "CBS", - "airTime": "21:30", - "images": [ - { - "coverType": "fanart", - "url": "https://artworks.thetvdb.com/banners/fanart/original/77754-5.jpg" +{ + "page":1, + "pageSize":10, + "sortKey":"timeleft", + "sortDirection":"ascending", + "totalRecords":1, + "records":[ + { + "series":{ + "title":"The Andy Griffith Show", + "sortTitle":"andy griffith show", + "seasonCount":8, + "status":"ended", + "overview":"Down-home humor and an endearing cast of characters helped make The Andy Griffith Show one of the most beloved comedies in the history of TV. The show centered around widower Andy Taylor, who divided his time between raising his young son Opie, and his job as sheriff of the sleepy North Carolina town, Mayberry. Andy and Opie live with Andy's Aunt Bee, who serves as a surrogate mother to both father and son. Andy's nervous cousin, Barney Fife, is his deputy sheriff whose incompetence is tolerated because Mayberry is virtually crime-free.", + "network":"CBS", + "airTime":"21:30", + "images":[ + { + "coverType":"fanart", + "url":"https://artworks.thetvdb.com/banners/fanart/original/77754-5.jpg" + }, + { + "coverType":"banner", + "url":"https://artworks.thetvdb.com/banners/graphical/77754-g.jpg" + }, + { + "coverType":"poster", + "url":"https://artworks.thetvdb.com/banners/posters/77754-4.jpg" + } + ], + "seasons":[ + { + "seasonNumber":0, + "monitored":false + }, + { + "seasonNumber":1, + "monitored":false + }, + { + "seasonNumber":2, + "monitored":true + }, + { + "seasonNumber":3, + "monitored":false + }, + { + "seasonNumber":4, + "monitored":false + }, + { + "seasonNumber":5, + "monitored":true + }, + { + "seasonNumber":6, + "monitored":true + }, + { + "seasonNumber":7, + "monitored":true + }, + { + "seasonNumber":8, + "monitored":true + } + ], + "year":1960, + "path":"F:\\The Andy Griffith Show", + "profileId":5, + "seasonFolder":true, + "monitored":true, + "useSceneNumbering":false, + "runtime":25, + "tvdbId":77754, + "tvRageId":5574, + "tvMazeId":3853, + "firstAired":"1960-02-15T06:00:00Z", + "lastInfoSync":"2016-02-05T16:40:11.614176Z", + "seriesType":"standard", + "cleanTitle":"theandygriffithshow", + "imdbId":"", + "titleSlug":"the-andy-griffith-show", + "certification":"TV-G", + "genres":[ + "Comedy" + ], + "tags":[ + + ], + "added":"2008-02-04T13:44:24.204583Z", + "ratings":{ + "votes":547, + "value":8.6 }, - { - "coverType": "banner", - "url": "https://artworks.thetvdb.com/banners/graphical/77754-g.jpg" - }, - { - "coverType": "poster", - "url": "https://artworks.thetvdb.com/banners/posters/77754-4.jpg" - } - ], - "seasons": [ - { - "seasonNumber": 0, - "monitored": false - }, - { - "seasonNumber": 1, - "monitored": false - }, - { - "seasonNumber": 2, - "monitored": true - }, - { - "seasonNumber": 3, - "monitored": false - }, - { - "seasonNumber": 4, - "monitored": false - }, - { - "seasonNumber": 5, - "monitored": true - }, - { - "seasonNumber": 6, - "monitored": true - }, - { - "seasonNumber": 7, - "monitored": true - }, - { - "seasonNumber": 8, - "monitored": true - } - ], - "year": 1960, - "path": "F:\\The Andy Griffith Show", - "profileId": 5, - "seasonFolder": true, - "monitored": true, - "useSceneNumbering": false, - "runtime": 25, - "tvdbId": 77754, - "tvRageId": 5574, - "tvMazeId": 3853, - "firstAired": "1960-02-15T06:00:00Z", - "lastInfoSync": "2016-02-05T16:40:11.614176Z", - "seriesType": "standard", - "cleanTitle": "theandygriffithshow", - "imdbId": "", - "titleSlug": "the-andy-griffith-show", - "certification": "TV-G", - "genres": [ - "Comedy" - ], - "tags": [], - "added": "2008-02-04T13:44:24.204583Z", - "ratings": { - "votes": 547, - "value": 8.6 + "qualityProfileId":5, + "id":17 }, - "qualityProfileId": 5, - "id": 17 - }, - "episode": { - "seriesId": 17, - "episodeFileId": 0, - "seasonNumber": 1, - "episodeNumber": 1, - "title": "The New Housekeeper", - "airDate": "1960-10-03", - "airDateUtc": "1960-10-03T01:00:00Z", - "overview": "Sheriff Andy Taylor and his young son Opie are in need of a new housekeeper. Andy's Aunt Bee looks like the perfect candidate and moves in, but her presence causes friction with Opie.", - "hasFile": false, - "monitored": false, - "absoluteEpisodeNumber": 1, - "unverifiedSceneNumbering": false, - "id": 889 - }, - "quality": { - "quality": { - "id": 7, - "name": "SD" + "episode":{ + "seriesId":17, + "episodeFileId":0, + "seasonNumber":1, + "episodeNumber":1, + "title":"The New Housekeeper", + "airDate":"1960-10-03", + "airDateUtc":"1960-10-03T01:00:00Z", + "overview":"Sheriff Andy Taylor and his young son Opie are in need of a new housekeeper. Andy's Aunt Bee looks like the perfect candidate and moves in, but her presence causes friction with Opie.", + "hasFile":false, + "monitored":false, + "absoluteEpisodeNumber":1, + "unverifiedSceneNumbering":false, + "id":889 }, - "revision": { - "version": 1, - "real": 0 - } - }, - "size": 4472186820, - "title": "The.Andy.Griffith.Show.S01E01.x264-GROUP", - "sizeleft": 0, - "timeleft": "00:00:00", - "estimatedCompletionTime": "2016-02-05T22:46:52.440104Z", - "status": "Downloading", - "trackedDownloadStatus": "Ok", - "statusMessages": [], - "downloadId": "SABnzbd_nzo_Mq2f_b", - "protocol": "usenet", - "id": 1503378561 - } -] + "quality":{ + "quality":{ + "id":7, + "name":"SD" + }, + "revision":{ + "version":1, + "real":0 + } + }, + "size":4472186820, + "title":"The.Andy.Griffith.Show.S01E01.x264-GROUP", + "sizeleft":0, + "timeleft":"00:00:00", + "estimatedCompletionTime":"2016-02-05T22:46:52.440104Z", + "status":"Downloading", + "trackedDownloadStatus":"Ok", + "statusMessages":[ + + ], + "downloadId":"SABnzbd_nzo_Mq2f_b", + "protocol":"usenet", + "id":1503378561 + } + ] +} diff --git a/tests/components/sonarr/fixtures/series.json b/tests/components/sonarr/fixtures/series.json index ea727c14a97..154ab7eb75e 100644 --- a/tests/components/sonarr/fixtures/series.json +++ b/tests/components/sonarr/fixtures/series.json @@ -3,11 +3,6 @@ "title": "The Andy Griffith Show", "alternateTitles": [], "sortTitle": "andy griffith show", - "seasonCount": 8, - "totalEpisodeCount": 253, - "episodeCount": 0, - "episodeFileCount": 0, - "sizeOnDisk": 0, "status": "ended", "overview": "Down-home humor and an endearing cast of characters helped make The Andy Griffith Show one of the most beloved comedies in the history of TV. The show centered around widower Andy Taylor, who divided his time between raising his young son Opie, and his job as sheriff of the sleepy North Carolina town, Mayberry. Andy and Opie live with Andy's Aunt Bee, who serves as a surrogate mother to both father and son. Andy's nervous cousin, Barney Fife, is his deputy sheriff whose incompetence is tolerated because Mayberry is virtually crime-free.", "network": "CBS", @@ -158,6 +153,14 @@ "value": 8.6 }, "qualityProfileId": 2, + "statistics": { + "seasonCount": 8, + "episodeFileCount": 0, + "episodeCount": 0, + "totalEpisodeCount": 253, + "sizeOnDisk": 0, + "percentOfEpisodes": 0.0 + }, "id": 105 } ] diff --git a/tests/components/sonarr/fixtures/system-status.json b/tests/components/sonarr/fixtures/system-status.json new file mode 100644 index 00000000000..fe6198a0444 --- /dev/null +++ b/tests/components/sonarr/fixtures/system-status.json @@ -0,0 +1,29 @@ +{ + "appName": "Sonarr", + "version": "3.0.6.1451", + "buildTime": "2022-01-23T16:51:56Z", + "isDebug": false, + "isProduction": true, + "isAdmin": false, + "isUserInteractive": false, + "startupPath": "/app/sonarr/bin", + "appData": "/config", + "osName": "ubuntu", + "osVersion": "20.04", + "isMonoRuntime": true, + "isMono": true, + "isLinux": true, + "isOsx": false, + "isWindows": false, + "mode": "console", + "branch": "develop", + "authentication": "forms", + "sqliteVersion": "3.31.1", + "urlBase": "", + "runtimeVersion": "6.12.0.122", + "runtimeName": "mono", + "startTime": "2022-02-01T22:10:11.956137Z", + "packageVersion": "3.0.6.1451-ls247", + "packageAuthor": "[linuxserver.io](https://linuxserver.io)", + "packageUpdateMechanism": "docker" +} diff --git a/tests/components/sonarr/fixtures/wanted-missing.json b/tests/components/sonarr/fixtures/wanted-missing.json index 5db7c52f469..df6212487fb 100644 --- a/tests/components/sonarr/fixtures/wanted-missing.json +++ b/tests/components/sonarr/fixtures/wanted-missing.json @@ -1,6 +1,6 @@ { "page": 1, - "pageSize": 10, + "pageSize": 50, "sortKey": "airDateUtc", "sortDirection": "descending", "totalRecords": 2, diff --git a/tests/components/sonarr/test_config_flow.py b/tests/components/sonarr/test_config_flow.py index 52e7b9b61ca..59783995d23 100644 --- a/tests/components/sonarr/test_config_flow.py +++ b/tests/components/sonarr/test_config_flow.py @@ -1,7 +1,7 @@ """Test the Sonarr config flow.""" from unittest.mock import MagicMock, patch -from sonarr import SonarrAccessRestricted, SonarrError +from aiopyarr import ArrAuthenticationException, ArrException from homeassistant.components.sonarr.const import ( CONF_UPCOMING_DAYS, @@ -11,7 +11,7 @@ from homeassistant.components.sonarr.const import ( DOMAIN, ) from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER -from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_SOURCE, CONF_VERIFY_SSL +from homeassistant.const import CONF_API_KEY, CONF_SOURCE, CONF_URL, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import ( RESULT_TYPE_ABORT, @@ -38,7 +38,7 @@ async def test_cannot_connect( hass: HomeAssistant, mock_sonarr_config_flow: MagicMock ) -> None: """Test we show user form on connection error.""" - mock_sonarr_config_flow.update.side_effect = SonarrError + mock_sonarr_config_flow.async_get_system_status.side_effect = ArrException user_input = MOCK_USER_INPUT.copy() result = await hass.config_entries.flow.async_init( @@ -56,7 +56,9 @@ async def test_invalid_auth( hass: HomeAssistant, mock_sonarr_config_flow: MagicMock ) -> None: """Test we show user form on invalid auth.""" - mock_sonarr_config_flow.update.side_effect = SonarrAccessRestricted + mock_sonarr_config_flow.async_get_system_status.side_effect = ( + ArrAuthenticationException + ) user_input = MOCK_USER_INPUT.copy() result = await hass.config_entries.flow.async_init( @@ -74,7 +76,7 @@ async def test_unknown_error( hass: HomeAssistant, mock_sonarr_config_flow: MagicMock ) -> None: """Test we show user form on unknown error.""" - mock_sonarr_config_flow.update.side_effect = Exception + mock_sonarr_config_flow.async_get_system_status.side_effect = Exception user_input = MOCK_USER_INPUT.copy() result = await hass.config_entries.flow.async_init( @@ -153,7 +155,7 @@ async def test_full_user_flow_implementation( assert result["title"] == "192.168.1.189" assert result["data"] - assert result["data"][CONF_HOST] == "192.168.1.189" + assert result["data"][CONF_URL] == "http://192.168.1.189:8989" async def test_full_user_flow_advanced_options( @@ -183,7 +185,7 @@ async def test_full_user_flow_advanced_options( assert result["title"] == "192.168.1.189" assert result["data"] - assert result["data"][CONF_HOST] == "192.168.1.189" + assert result["data"][CONF_URL] == "http://192.168.1.189:8989" assert result["data"][CONF_VERIFY_SSL] diff --git a/tests/components/sonarr/test_init.py b/tests/components/sonarr/test_init.py index 3a59f5d7cca..f4a317e3de0 100644 --- a/tests/components/sonarr/test_init.py +++ b/tests/components/sonarr/test_init.py @@ -1,11 +1,19 @@ """Tests for the Sonsrr integration.""" from unittest.mock import MagicMock, patch -from sonarr import SonarrAccessRestricted, SonarrError +from aiopyarr import ArrAuthenticationException, ArrException -from homeassistant.components.sonarr.const import DOMAIN +from homeassistant.components.sonarr.const import CONF_BASE_PATH, DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState -from homeassistant.const import CONF_SOURCE +from homeassistant.const import ( + CONF_API_KEY, + CONF_HOST, + CONF_PORT, + CONF_SOURCE, + CONF_SSL, + CONF_URL, + CONF_VERIFY_SSL, +) from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -17,7 +25,7 @@ async def test_config_entry_not_ready( mock_sonarr: MagicMock, ) -> None: """Test the configuration entry not ready.""" - mock_sonarr.update.side_effect = SonarrError + mock_sonarr.async_get_system_status.side_effect = ArrException mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) @@ -32,7 +40,7 @@ async def test_config_entry_reauth( mock_sonarr: MagicMock, ) -> None: """Test the configuration entry needing to be re-authenticated.""" - mock_sonarr.update.side_effect = SonarrAccessRestricted + mock_sonarr.async_get_system_status.side_effect = ArrAuthenticationException with patch.object(hass.config_entries.flow, "async_init") as mock_flow_init: mock_config_entry.add_to_hass(hass) @@ -77,3 +85,34 @@ async def test_unload_config_entry( assert mock_config_entry.state is ConfigEntryState.NOT_LOADED assert mock_config_entry.entry_id not in hass.data[DOMAIN] + + +async def test_migrate_config_entry(hass: HomeAssistant): + """Test successful migration of entry data.""" + legacy_config = { + CONF_API_KEY: "MOCK_API_KEY", + CONF_HOST: "1.2.3.4", + CONF_PORT: 8989, + CONF_SSL: False, + CONF_VERIFY_SSL: False, + CONF_BASE_PATH: "/base/", + } + entry = MockConfigEntry(domain=DOMAIN, data=legacy_config) + + assert entry.data == legacy_config + assert entry.version == 1 + assert not entry.unique_id + + await entry.async_migrate(hass) + + assert entry.data == { + CONF_API_KEY: "MOCK_API_KEY", + CONF_HOST: "1.2.3.4", + CONF_PORT: 8989, + CONF_SSL: False, + CONF_VERIFY_SSL: False, + CONF_BASE_PATH: "/base/", + CONF_URL: "http://1.2.3.4:8989/base", + } + assert entry.version == 2 + assert not entry.unique_id diff --git a/tests/components/sonarr/test_sensor.py b/tests/components/sonarr/test_sensor.py index cc15376efb1..c499dc0112f 100644 --- a/tests/components/sonarr/test_sensor.py +++ b/tests/components/sonarr/test_sensor.py @@ -2,8 +2,8 @@ from datetime import timedelta from unittest.mock import MagicMock, patch +from aiopyarr import ArrException import pytest -from sonarr import SonarrError from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.sonarr.const import DOMAIN @@ -96,8 +96,11 @@ async def test_sensors( assert state assert state.attributes.get(ATTR_ICON) == "mdi:television" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "Episodes" - assert state.attributes.get("Bob's Burgers S04E11") == "2014-01-26" - assert state.attributes.get("The Andy Griffith Show S01E01") == "1960-10-03" + assert state.attributes.get("Bob's Burgers S04E11") == "2014-01-27T01:30:00+00:00" + assert ( + state.attributes.get("The Andy Griffith Show S01E01") + == "1960-10-03T01:00:00+00:00" + ) assert state.state == "2" @@ -141,44 +144,49 @@ async def test_availability( await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() + assert hass.states.get(UPCOMING_ENTITY_ID) assert hass.states.get(UPCOMING_ENTITY_ID).state == "1" # state to unavailable - mock_sonarr.calendar.side_effect = SonarrError + mock_sonarr.async_get_calendar.side_effect = ArrException future = now + timedelta(minutes=1) with patch("homeassistant.util.dt.utcnow", return_value=future): async_fire_time_changed(hass, future) await hass.async_block_till_done() + assert hass.states.get(UPCOMING_ENTITY_ID) assert hass.states.get(UPCOMING_ENTITY_ID).state == STATE_UNAVAILABLE # state to available - mock_sonarr.calendar.side_effect = None + mock_sonarr.async_get_calendar.side_effect = None future += timedelta(minutes=1) with patch("homeassistant.util.dt.utcnow", return_value=future): async_fire_time_changed(hass, future) await hass.async_block_till_done() + assert hass.states.get(UPCOMING_ENTITY_ID) assert hass.states.get(UPCOMING_ENTITY_ID).state == "1" # state to unavailable - mock_sonarr.calendar.side_effect = SonarrError + mock_sonarr.async_get_calendar.side_effect = ArrException future += timedelta(minutes=1) with patch("homeassistant.util.dt.utcnow", return_value=future): async_fire_time_changed(hass, future) await hass.async_block_till_done() + assert hass.states.get(UPCOMING_ENTITY_ID) assert hass.states.get(UPCOMING_ENTITY_ID).state == STATE_UNAVAILABLE # state to available - mock_sonarr.calendar.side_effect = None + mock_sonarr.async_get_calendar.side_effect = None future += timedelta(minutes=1) with patch("homeassistant.util.dt.utcnow", return_value=future): async_fire_time_changed(hass, future) await hass.async_block_till_done() + assert hass.states.get(UPCOMING_ENTITY_ID) assert hass.states.get(UPCOMING_ENTITY_ID).state == "1"