From 0ccb495209bd334d0df1f82513abf70747512c29 Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Thu, 22 Sep 2022 22:16:24 -0400 Subject: [PATCH] Radarr Config Flow (#78965) --- CODEOWNERS | 2 + homeassistant/components/radarr/__init__.py | 116 ++++- .../components/radarr/config_flow.py | 147 ++++++ homeassistant/components/radarr/const.py | 11 + .../components/radarr/coordinator.py | 83 ++++ homeassistant/components/radarr/manifest.json | 7 +- homeassistant/components/radarr/sensor.py | 311 +++++------- homeassistant/components/radarr/strings.json | 48 ++ .../components/radarr/translations/en.json | 48 ++ homeassistant/generated/config_flows.py | 1 + requirements_all.txt | 1 + requirements_test_all.txt | 1 + tests/components/radarr/__init__.py | 205 +++++++- tests/components/radarr/fixtures/command.json | 32 ++ tests/components/radarr/fixtures/movie.json | 118 +++++ .../radarr/fixtures/rootfolder-linux.json | 8 + .../radarr/fixtures/rootfolder-windows.json | 8 + .../radarr/fixtures/system-status.json | 28 ++ tests/components/radarr/test_config_flow.py | 227 +++++++++ tests/components/radarr/test_init.py | 58 +++ tests/components/radarr/test_sensor.py | 465 ++---------------- 21 files changed, 1291 insertions(+), 634 deletions(-) create mode 100644 homeassistant/components/radarr/config_flow.py create mode 100644 homeassistant/components/radarr/const.py create mode 100644 homeassistant/components/radarr/coordinator.py create mode 100644 homeassistant/components/radarr/strings.json create mode 100644 homeassistant/components/radarr/translations/en.json create mode 100644 tests/components/radarr/fixtures/command.json create mode 100644 tests/components/radarr/fixtures/movie.json create mode 100644 tests/components/radarr/fixtures/rootfolder-linux.json create mode 100644 tests/components/radarr/fixtures/rootfolder-windows.json create mode 100644 tests/components/radarr/fixtures/system-status.json create mode 100644 tests/components/radarr/test_config_flow.py create mode 100644 tests/components/radarr/test_init.py diff --git a/CODEOWNERS b/CODEOWNERS index 9c25b5a3eed..0b9c577b003 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -888,6 +888,8 @@ build.json @home-assistant/supervisor /tests/components/qwikswitch/ @kellerza /homeassistant/components/rachio/ @bdraco /tests/components/rachio/ @bdraco +/homeassistant/components/radarr/ @tkdrob +/tests/components/radarr/ @tkdrob /homeassistant/components/radio_browser/ @frenck /tests/components/radio_browser/ @frenck /homeassistant/components/radiotherm/ @bdraco @vinnyfuria diff --git a/homeassistant/components/radarr/__init__.py b/homeassistant/components/radarr/__init__.py index 24377725bfc..467f95780e8 100644 --- a/homeassistant/components/radarr/__init__.py +++ b/homeassistant/components/radarr/__init__.py @@ -1 +1,115 @@ -"""The radarr component.""" +"""The Radarr component.""" +from __future__ import annotations + +from aiopyarr.models.host_configuration import PyArrHostConfiguration +from aiopyarr.radarr_client import RadarrClient + +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_API_KEY, + CONF_PLATFORM, + CONF_URL, + CONF_VERIFY_SSL, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.device_registry import DeviceEntryType +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue +from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DEFAULT_NAME, DOMAIN +from .coordinator import ( + DiskSpaceDataUpdateCoordinator, + MoviesDataUpdateCoordinator, + RadarrDataUpdateCoordinator, + StatusDataUpdateCoordinator, +) + +PLATFORMS = [Platform.SENSOR] + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Steam integration.""" + if SENSOR_DOMAIN not in config: + return True + + for entry in config[SENSOR_DOMAIN]: + if entry[CONF_PLATFORM] == DOMAIN: + async_create_issue( + hass, + DOMAIN, + "deprecated_yaml", + breaks_in_ha_version="2022.10.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + ) + async_create_issue( + hass, + DOMAIN, + "removed_attributes", + breaks_in_ha_version="2022.10.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="removed_attributes", + ) + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Radarr from a config entry.""" + host_configuration = PyArrHostConfiguration( + api_token=entry.data[CONF_API_KEY], + verify_ssl=entry.data[CONF_VERIFY_SSL], + url=entry.data[CONF_URL], + ) + radarr = RadarrClient( + host_configuration=host_configuration, + session=async_get_clientsession(hass, entry.data[CONF_VERIFY_SSL]), + ) + coordinators: dict[str, RadarrDataUpdateCoordinator] = { + "status": StatusDataUpdateCoordinator(hass, host_configuration, radarr), + "disk_space": DiskSpaceDataUpdateCoordinator(hass, host_configuration, radarr), + "movie": MoviesDataUpdateCoordinator(hass, host_configuration, radarr), + } + # Temporary, until we add diagnostic entities + _version = None + for coordinator in coordinators.values(): + await coordinator.async_config_entry_first_refresh() + if isinstance(coordinator, StatusDataUpdateCoordinator): + _version = coordinator.data + coordinator.system_version = _version + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinators + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + return unload_ok + + +class RadarrEntity(CoordinatorEntity[RadarrDataUpdateCoordinator]): + """Defines a base Radarr entity.""" + + coordinator: RadarrDataUpdateCoordinator + + @property + def device_info(self) -> DeviceInfo: + """Return device information about the Radarr instance.""" + return DeviceInfo( + configuration_url=self.coordinator.host_configuration.url, + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, self.coordinator.config_entry.entry_id)}, + manufacturer=DEFAULT_NAME, + name=self.coordinator.config_entry.title, + sw_version=self.coordinator.system_version, + ) diff --git a/homeassistant/components/radarr/config_flow.py b/homeassistant/components/radarr/config_flow.py new file mode 100644 index 00000000000..af74922402a --- /dev/null +++ b/homeassistant/components/radarr/config_flow.py @@ -0,0 +1,147 @@ +"""Config flow for Radarr.""" +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from aiohttp import ClientConnectorError +from aiopyarr import exceptions +from aiopyarr.models.host_configuration import PyArrHostConfiguration +from aiopyarr.radarr_client import RadarrClient +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry, ConfigFlow +from homeassistant.const import ( + CONF_API_KEY, + CONF_HOST, + CONF_PORT, + CONF_SSL, + CONF_URL, + CONF_VERIFY_SSL, +) +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DEFAULT_NAME, DEFAULT_URL, DOMAIN, LOGGER + + +class RadarrConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Radarr.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize the flow.""" + self.entry: ConfigEntry | None = None + + async def async_step_reauth(self, _: Mapping[str, Any]) -> FlowResult: + """Handle configuration by re-auth.""" + self.entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) + + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: + """Confirm reauth dialog.""" + if user_input is not None: + return await self.async_step_user() + + self._set_confirm_only() + return self.async_show_form(step_id="reauth_confirm") + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initiated by the user.""" + errors = {} + + if user_input is None: + user_input = dict(self.entry.data) if self.entry else None + + else: + try: + result = await validate_input(self.hass, user_input) + if isinstance(result, tuple): + user_input[CONF_API_KEY] = result[1] + elif isinstance(result, str): + errors = {"base": result} + except exceptions.ArrAuthenticationException: + errors = {"base": "invalid_auth"} + except (ClientConnectorError, exceptions.ArrConnectionException): + errors = {"base": "cannot_connect"} + except exceptions.ArrException: + errors = {"base": "unknown"} + if not errors: + if self.entry: + self.hass.config_entries.async_update_entry( + self.entry, data=user_input + ) + await self.hass.config_entries.async_reload(self.entry.entry_id) + + return self.async_abort(reason="reauth_successful") + + return self.async_create_entry( + title=DEFAULT_NAME, + data=user_input, + ) + + user_input = user_input or {} + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required( + CONF_URL, default=user_input.get(CONF_URL, DEFAULT_URL) + ): str, + vol.Optional(CONF_API_KEY): str, + vol.Optional( + CONF_VERIFY_SSL, + default=user_input.get(CONF_VERIFY_SSL, False), + ): bool, + } + ), + errors=errors, + ) + + async def async_step_import(self, config: dict[str, Any]) -> FlowResult: + """Import a config entry from configuration.yaml.""" + for entry in self._async_current_entries(): + if entry.data[CONF_API_KEY] == config[CONF_API_KEY]: + _part = config[CONF_API_KEY][0:4] + _msg = f"Radarr yaml config with partial key {_part} has been imported. Please remove it" + LOGGER.warning(_msg) + return self.async_abort(reason="already_configured") + proto = "https" if config[CONF_SSL] else "http" + host_port = f"{config[CONF_HOST]}:{config[CONF_PORT]}" + path = "" + if config["urlbase"].rstrip("/") not in ("", "/", "/api"): + path = config["urlbase"].rstrip("/") + return self.async_create_entry( + title=DEFAULT_NAME, + data={ + CONF_URL: f"{proto}://{host_port}{path}", + CONF_API_KEY: config[CONF_API_KEY], + CONF_VERIFY_SSL: False, + }, + ) + + +async def validate_input( + hass: HomeAssistant, data: dict[str, Any] +) -> tuple[str, str, str] | str | None: + """Validate the user input allows us to connect.""" + host_configuration = PyArrHostConfiguration( + api_token=data.get(CONF_API_KEY, ""), + verify_ssl=data[CONF_VERIFY_SSL], + url=data[CONF_URL], + ) + radarr = RadarrClient( + host_configuration=host_configuration, + session=async_get_clientsession(hass), + ) + if CONF_API_KEY not in data: + return await radarr.async_try_zeroconf() + await radarr.async_get_system_status() + return None diff --git a/homeassistant/components/radarr/const.py b/homeassistant/components/radarr/const.py new file mode 100644 index 00000000000..b3320cf63a4 --- /dev/null +++ b/homeassistant/components/radarr/const.py @@ -0,0 +1,11 @@ +"""Constants for Radarr.""" +import logging +from typing import Final + +DOMAIN: Final = "radarr" + +# Defaults +DEFAULT_NAME = "Radarr" +DEFAULT_URL = "http://127.0.0.1:7878" + +LOGGER = logging.getLogger(__package__) diff --git a/homeassistant/components/radarr/coordinator.py b/homeassistant/components/radarr/coordinator.py new file mode 100644 index 00000000000..a66455c8cc3 --- /dev/null +++ b/homeassistant/components/radarr/coordinator.py @@ -0,0 +1,83 @@ +"""Data update coordinator for the Radarr integration.""" +from __future__ import annotations + +from abc import abstractmethod +from datetime import timedelta +from typing import Generic, TypeVar, cast + +from aiopyarr import RootFolder, exceptions +from aiopyarr.models.host_configuration import PyArrHostConfiguration +from aiopyarr.radarr_client import RadarrClient + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, LOGGER + +T = TypeVar("T", str, list[RootFolder], int) + + +class RadarrDataUpdateCoordinator(DataUpdateCoordinator, Generic[T]): + """Data update coordinator for the Radarr integration.""" + + config_entry: ConfigEntry + + def __init__( + self, + hass: HomeAssistant, + host_configuration: PyArrHostConfiguration, + api_client: RadarrClient, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass=hass, + logger=LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=30), + ) + self.api_client = api_client + self.host_configuration = host_configuration + self.system_version: str | None = None + + async def _async_update_data(self) -> T: + """Get the latest data from Radarr.""" + try: + return await self._fetch_data() + + except exceptions.ArrConnectionException as ex: + raise UpdateFailed(ex) from ex + except exceptions.ArrAuthenticationException as ex: + raise ConfigEntryAuthFailed( + "API Key is no longer valid. Please reauthenticate" + ) from ex + + @abstractmethod + async def _fetch_data(self) -> T: + """Fetch the actual data.""" + raise NotImplementedError + + +class StatusDataUpdateCoordinator(RadarrDataUpdateCoordinator): + """Status update coordinator for Radarr.""" + + async def _fetch_data(self) -> str: + """Fetch the data.""" + return (await self.api_client.async_get_system_status()).version + + +class DiskSpaceDataUpdateCoordinator(RadarrDataUpdateCoordinator): + """Disk space update coordinator for Radarr.""" + + async def _fetch_data(self) -> list[RootFolder]: + """Fetch the data.""" + return cast(list, await self.api_client.async_get_root_folders()) + + +class MoviesDataUpdateCoordinator(RadarrDataUpdateCoordinator): + """Movies update coordinator.""" + + async def _fetch_data(self) -> int: + """Fetch the movies data.""" + return len(cast(list, await self.api_client.async_get_movies())) diff --git a/homeassistant/components/radarr/manifest.json b/homeassistant/components/radarr/manifest.json index 611b4a33f3b..3ecb4247d87 100644 --- a/homeassistant/components/radarr/manifest.json +++ b/homeassistant/components/radarr/manifest.json @@ -2,6 +2,9 @@ "domain": "radarr", "name": "Radarr", "documentation": "https://www.home-assistant.io/integrations/radarr", - "codeowners": [], - "iot_class": "local_polling" + "requirements": ["aiopyarr==22.7.0"], + "codeowners": ["@tkdrob"], + "config_flow": true, + "iot_class": "local_polling", + "loggers": ["aiopyarr"] } diff --git a/homeassistant/components/radarr/sensor.py b/homeassistant/components/radarr/sensor.py index c0c10c5b1b3..ac678198cd7 100644 --- a/homeassistant/components/radarr/sensor.py +++ b/homeassistant/components/radarr/sensor.py @@ -1,13 +1,12 @@ """Support for Radarr.""" from __future__ import annotations -from datetime import datetime, timedelta -from http import HTTPStatus -import logging -import time -from typing import Any +from collections.abc import Callable +from copy import deepcopy +from dataclasses import dataclass +from typing import Generic -import requests +from aiopyarr import RootFolder import voluptuous as vol from homeassistant.components.sensor import ( @@ -15,6 +14,7 @@ from homeassistant.components.sensor import ( SensorEntity, SensorEntityDescription, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( CONF_API_KEY, CONF_HOST, @@ -22,245 +22,164 @@ from homeassistant.const import ( CONF_PORT, CONF_SSL, DATA_BYTES, - DATA_EXABYTES, DATA_GIGABYTES, DATA_KILOBYTES, DATA_MEGABYTES, - DATA_PETABYTES, - DATA_TERABYTES, - DATA_YOTTABYTES, - DATA_ZETTABYTES, ) from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import dt as dt_util +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType -_LOGGER = logging.getLogger(__name__) +from . import RadarrEntity +from .const import DEFAULT_NAME, DOMAIN +from .coordinator import RadarrDataUpdateCoordinator, T -CONF_DAYS = "days" -CONF_INCLUDED = "include_paths" -CONF_UNIT = "unit" -CONF_URLBASE = "urlbase" -DEFAULT_HOST = "localhost" -DEFAULT_PORT = 7878 -DEFAULT_URLBASE = "" -DEFAULT_DAYS = "1" -DEFAULT_UNIT = DATA_GIGABYTES +def get_space(coordinator: RadarrDataUpdateCoordinator, name: str) -> str: + """Get space.""" + space = [ + mount.freeSpace / 1024 ** BYTE_SIZES.index(DATA_GIGABYTES) + for mount in coordinator.data + if name in mount.path + ] + return f"{space[0]:.2f}" -SCAN_INTERVAL = timedelta(minutes=10) -SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( - SensorEntityDescription( - key="diskspace", - name="Disk Space", +def get_modified_description( + description: RadarrSensorEntityDescription, mount: RootFolder +) -> tuple[RadarrSensorEntityDescription, str]: + """Return modified description and folder name.""" + desc = deepcopy(description) + name = mount.path.rsplit("/")[-1].rsplit("\\")[-1] + desc.key = f"{description.key}_{name}" + desc.name = f"{description.name} {name}".capitalize() + return desc, name + + +@dataclass +class RadarrSensorEntityDescriptionMixIn(Generic[T]): + """Mixin for required keys.""" + + value: Callable[[RadarrDataUpdateCoordinator[T], str], str] + + +@dataclass +class RadarrSensorEntityDescription( + SensorEntityDescription, RadarrSensorEntityDescriptionMixIn[T], Generic[T] +): + """Class to describe a Radarr sensor.""" + + description_fn: Callable[ + [RadarrSensorEntityDescription, RootFolder], + tuple[RadarrSensorEntityDescription, str] | None, + ] = lambda _, __: None + + +SENSOR_TYPES: dict[str, RadarrSensorEntityDescription] = { + "disk_space": RadarrSensorEntityDescription( + key="disk_space", + name="Disk space", native_unit_of_measurement=DATA_GIGABYTES, icon="mdi:harddisk", + value=get_space, + description_fn=get_modified_description, ), - SensorEntityDescription( - key="upcoming", - name="Upcoming", - native_unit_of_measurement="Movies", - icon="mdi:television", - ), - SensorEntityDescription( + "movie": RadarrSensorEntityDescription( key="movies", name="Movies", native_unit_of_measurement="Movies", icon="mdi:television", + entity_registry_enabled_default=False, + value=lambda coordinator, _: coordinator.data, ), - SensorEntityDescription( - key="commands", - name="Commands", - native_unit_of_measurement="Commands", - icon="mdi:code-braces", - ), - SensorEntityDescription( - key="status", - name="Status", - native_unit_of_measurement="Status", - icon="mdi:information", - ), -) - -SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] - -ENDPOINTS = { - "diskspace": "{0}://{1}:{2}/{3}api/diskspace", - "upcoming": "{0}://{1}:{2}/{3}api/calendar?start={4}&end={5}", - "movies": "{0}://{1}:{2}/{3}api/movie", - "commands": "{0}://{1}:{2}/{3}api/command", - "status": "{0}://{1}:{2}/{3}api/system/status", } -# Support to Yottabytes for the future, why not +SENSOR_KEYS: list[str] = [description.key for description in SENSOR_TYPES.values()] + BYTE_SIZES = [ DATA_BYTES, DATA_KILOBYTES, DATA_MEGABYTES, DATA_GIGABYTES, - DATA_TERABYTES, - DATA_PETABYTES, - DATA_EXABYTES, - DATA_ZETTABYTES, - DATA_YOTTABYTES, ] +# Deprecated in Home Assistant 2022.10 PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_API_KEY): cv.string, - vol.Optional(CONF_DAYS, default=DEFAULT_DAYS): cv.string, - vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, - vol.Optional(CONF_INCLUDED, default=[]): cv.ensure_list, + vol.Optional("days", default=1): cv.string, + vol.Optional(CONF_HOST, default="localhost"): cv.string, + vol.Optional("include_paths", default=[]): cv.ensure_list, vol.Optional(CONF_MONITORED_CONDITIONS, default=["movies"]): vol.All( cv.ensure_list, [vol.In(SENSOR_KEYS)] ), - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_PORT, default=7878): cv.port, vol.Optional(CONF_SSL, default=False): cv.boolean, - vol.Optional(CONF_UNIT, default=DEFAULT_UNIT): vol.In(BYTE_SIZES), - vol.Optional(CONF_URLBASE, default=DEFAULT_URLBASE): cv.string, + vol.Optional("unit", default=DATA_GIGABYTES): cv.string, + vol.Optional("urlbase", default=""): cv.string, } ) +PARALLEL_UPDATES = 1 -def setup_platform( + +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - add_entities: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Radarr platform.""" - conditions = config[CONF_MONITORED_CONDITIONS] - # deprecated in 2022.3 - entities = [ - RadarrSensor(hass, config, description) - for description in SENSOR_TYPES - if description.key in conditions + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config + ) + ) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Radarr sensors based on a config entry.""" + coordinators: dict[str, RadarrDataUpdateCoordinator] = hass.data[DOMAIN][ + entry.entry_id ] - add_entities(entities, True) + entities = [] + for coordinator_type, description in SENSOR_TYPES.items(): + coordinator = coordinators[coordinator_type] + if coordinator_type != "disk_space": + entities.append(RadarrSensor(coordinator, description)) + else: + entities.extend( + RadarrSensor(coordinator, *get_modified_description(description, mount)) + for mount in coordinator.data + if description.description_fn + ) + async_add_entities(entities) -class RadarrSensor(SensorEntity): +class RadarrSensor(RadarrEntity, SensorEntity): """Implementation of the Radarr sensor.""" - def __init__(self, hass, conf, description: SensorEntityDescription): - """Create Radarr entity.""" - self.entity_description = description + coordinator: RadarrDataUpdateCoordinator + entity_description: RadarrSensorEntityDescription - self.conf = conf - self.host = conf.get(CONF_HOST) - self.port = conf.get(CONF_PORT) - self.urlbase = conf.get(CONF_URLBASE) - if self.urlbase: - self.urlbase = f"{self.urlbase.strip('/')}/" - self.apikey = conf.get(CONF_API_KEY) - self.included = conf.get(CONF_INCLUDED) - self.days = int(conf.get(CONF_DAYS)) - self.ssl = "https" if conf.get(CONF_SSL) else "http" - self.data: list[Any] = [] - self._attr_name = f"Radarr {description.name}" - if description.key == "diskspace": - self._attr_native_unit_of_measurement = conf.get(CONF_UNIT) - self._attr_available = False + def __init__( + self, + coordinator: RadarrDataUpdateCoordinator, + description: RadarrSensorEntityDescription, + folder_name: str = "", + ) -> None: + """Create Radarr entity.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_name = f"{DEFAULT_NAME} {description.name}" + self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{description.key}" + self.folder_name = folder_name @property - def extra_state_attributes(self): - """Return the state attributes of the sensor.""" - attributes = {} - sensor_type = self.entity_description.key - if sensor_type == "upcoming": - for movie in self.data: - attributes[to_key(movie)] = get_release_date(movie) - elif sensor_type == "commands": - for command in self.data: - attributes[command["name"]] = command["state"] - elif sensor_type == "diskspace": - for data in self.data: - free_space = to_unit(data["freeSpace"], self.native_unit_of_measurement) - total_space = to_unit( - data["totalSpace"], self.native_unit_of_measurement - ) - percentage_used = ( - 0 if total_space == 0 else free_space / total_space * 100 - ) - attributes[data["path"]] = "{:.2f}/{:.2f}{} ({:.2f}%)".format( - free_space, - total_space, - self.native_unit_of_measurement, - percentage_used, - ) - elif sensor_type == "movies": - for movie in self.data: - attributes[to_key(movie)] = movie["downloaded"] - elif sensor_type == "status": - attributes = self.data - - return attributes - - def update(self): - """Update the data for the sensor.""" - sensor_type = self.entity_description.key - time_zone = dt_util.get_time_zone(self.hass.config.time_zone) - start = get_date(time_zone) - end = get_date(time_zone, self.days) - try: - res = requests.get( - ENDPOINTS[sensor_type].format( - self.ssl, self.host, self.port, self.urlbase, start, end - ), - headers={"X-Api-Key": self.apikey}, - timeout=10, - ) - except OSError: - _LOGGER.warning("Host %s is not available", self.host) - self._attr_available = False - self._attr_native_value = None - return - - if res.status_code == HTTPStatus.OK: - if sensor_type in ("upcoming", "movies", "commands"): - self.data = res.json() - self._attr_native_value = len(self.data) - elif sensor_type == "diskspace": - # If included paths are not provided, use all data - if self.included == []: - self.data = res.json() - else: - # Filter to only show lists that are included - self.data = list( - filter(lambda x: x["path"] in self.included, res.json()) - ) - self._attr_native_value = "{:.2f}".format( - to_unit( - sum(data["freeSpace"] for data in self.data), - self.native_unit_of_measurement, - ) - ) - elif sensor_type == "status": - self.data = res.json() - self._attr_native_value = self.data["version"] - self._attr_available = True - - -def get_date(zone, offset=0): - """Get date based on timezone and offset of days.""" - day = 60 * 60 * 24 - return datetime.date(datetime.fromtimestamp(time.time() + day * offset, tz=zone)) - - -def get_release_date(data): - """Get release date.""" - if not (date := data.get("physicalRelease")): - date = data.get("inCinemas") - return date - - -def to_key(data): - """Get key.""" - return "{} ({})".format(data["title"], data["year"]) - - -def to_unit(value, unit): - """Convert bytes to give unit.""" - return value / 1024 ** BYTE_SIZES.index(unit) + def native_value(self) -> StateType: + """Return the state of the sensor.""" + return self.entity_description.value(self.coordinator, self.folder_name) diff --git a/homeassistant/components/radarr/strings.json b/homeassistant/components/radarr/strings.json new file mode 100644 index 00000000000..47e7aebce02 --- /dev/null +++ b/homeassistant/components/radarr/strings.json @@ -0,0 +1,48 @@ +{ + "config": { + "step": { + "user": { + "description": "API key can be retrieved automatically if login credentials were not set in application.\nYour API key can be found in Settings > General in the Radarr Web UI.", + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]", + "url": "[%key:common::config_flow::data::url%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + } + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The Radarr integration needs to be manually re-authenticated with the Radarr API" + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "zeroconf_failed": "API key not found. Please enter it manually", + "wrong_app": "Incorrect application reached. Please try again", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + } + }, + "options": { + "step": { + "init": { + "data": { + "upcoming_days": "Number of upcoming days to display" + } + } + } + }, + "issues": { + "deprecated_yaml": { + "title": "The Radarr YAML configuration is being removed", + "description": "Configuring Radarr using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the Radarr YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." + }, + "removed_attributes": { + "title": "Changes to the Radarr integration", + "description": "Some breaking changes has been made in disabling the Movies count sensor out of caution.\n\nThis sensor can cause problems with massive databases. If you still wish to use it, you may do so.\n\nMovie names are no longer included as attributes in the movies sensor.\n\nUpcoming has been removed. It is being modernized as calendar items should be. Disk space is now split into different sensors, one for each folder.\n\nStatus and commands have been removed as they don't appear to have real value for automations." + } + } +} diff --git a/homeassistant/components/radarr/translations/en.json b/homeassistant/components/radarr/translations/en.json new file mode 100644 index 00000000000..7172eed0021 --- /dev/null +++ b/homeassistant/components/radarr/translations/en.json @@ -0,0 +1,48 @@ +{ + "config": { + "abort": { + "already_configured": "Service is already configured", + "reauth_successful": "Re-authentication was successful" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "zeroconf_failed": "API key not found. Please enter it manually", + "wrong_app": "Incorrect application reached. Please try again", + "unknown": "Unexpected error" + }, + "step": { + "reauth_confirm": { + "title": "Reauthenticate Integration", + "description": "The Radarr integration needs to be manually re-authenticated with the Radarr API" + }, + "user": { + "description": "API key can be retrieved automatically if login credentials were not set in application.\nYour API key can be found in Settings > General in the Radarr Web UI.", + "data": { + "api_key": "API Key", + "url": "URL", + "verify_ssl": "Verify SSL certificate" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "upcoming_days": "Number of upcoming days to display" + } + } + } + }, + "issues": { + "deprecated_yaml": { + "title": "The Radarr YAML configuration is being removed", + "description": "Configuring Radarr using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the Radarr YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." + }, + "removed_attributes": { + "title": "Changes to the Radarr integration", + "description": "Some breaking changes has been made in disabling the Movies count sensor out of caution.\n\nThis sensor can cause problems with massive databases. If you still wish to use it, you may do so.\n\nMovie names are no longer included as attributes in the movies sensor.\n\nUpcoming has been removed. It is being modernized as calendar items should be. Disk space is now split into different sensors, one for each folder.\n\nStatus and commands have been removed as they don't appear to have real value for automations." + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index b375cb4a340..21154b35a7a 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -305,6 +305,7 @@ FLOWS = { "qingping", "qnap_qsw", "rachio", + "radarr", "radio_browser", "radiotherm", "rainforest_eagle", diff --git a/requirements_all.txt b/requirements_all.txt index 4a528e1ce9f..1309e0bddff 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -235,6 +235,7 @@ aiopvapi==2.0.1 aiopvpc==3.0.0 # homeassistant.components.lidarr +# homeassistant.components.radarr # homeassistant.components.sonarr aiopyarr==22.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a4141abffe0..1c1c093f107 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -210,6 +210,7 @@ aiopvapi==2.0.1 aiopvpc==3.0.0 # homeassistant.components.lidarr +# homeassistant.components.radarr # homeassistant.components.sonarr aiopyarr==22.7.0 diff --git a/tests/components/radarr/__init__.py b/tests/components/radarr/__init__.py index 13cc76db384..c631291b37b 100644 --- a/tests/components/radarr/__init__.py +++ b/tests/components/radarr/__init__.py @@ -1 +1,204 @@ -"""Tests for the radarr component.""" +"""Tests for the Radarr component.""" +from http import HTTPStatus +from unittest.mock import patch + +from aiohttp.client_exceptions import ClientError + +from homeassistant.components.radarr.const import DOMAIN +from homeassistant.const import ( + CONF_API_KEY, + CONF_HOST, + CONF_MONITORED_CONDITIONS, + CONF_PORT, + CONF_SSL, + CONF_URL, + CONF_VERIFY_SSL, + CONTENT_TYPE_JSON, +) +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry, load_fixture +from tests.test_util.aiohttp import AiohttpClientMocker + +URL = "http://192.168.1.189:7887/test" +API_KEY = "MOCK_API_KEY" + +MOCK_REAUTH_INPUT = {CONF_API_KEY: "test-api-key-reauth"} + +MOCK_USER_INPUT = { + CONF_URL: URL, + CONF_API_KEY: API_KEY, + CONF_VERIFY_SSL: False, +} + +CONF_IMPORT_DATA = { + CONF_API_KEY: API_KEY, + CONF_HOST: "192.168.1.189", + CONF_MONITORED_CONDITIONS: ["Stream count"], + CONF_PORT: "7887", + "urlbase": "/test", + CONF_SSL: False, +} + +CONF_DATA = { + CONF_URL: URL, + CONF_API_KEY: API_KEY, + CONF_VERIFY_SSL: False, +} + + +def mock_connection( + aioclient_mock: AiohttpClientMocker, + url: str = URL, + error: bool = False, + invalid_auth: bool = False, + windows: bool = False, +) -> None: + """Mock radarr connection.""" + if error: + mock_connection_error( + aioclient_mock, + url=url, + ) + return + + if invalid_auth: + mock_connection_invalid_auth( + aioclient_mock, + url=url, + ) + return + + aioclient_mock.get( + f"{url}/api/v3/system/status", + text=load_fixture("radarr/system-status.json"), + headers={"Content-Type": CONTENT_TYPE_JSON}, + ) + + aioclient_mock.get( + f"{url}/api/v3/command", + text=load_fixture("radarr/command.json"), + headers={"Content-Type": CONTENT_TYPE_JSON}, + ) + + if windows: + aioclient_mock.get( + f"{url}/api/v3/rootfolder", + text=load_fixture("radarr/rootfolder-windows.json"), + headers={"Content-Type": CONTENT_TYPE_JSON}, + ) + else: + aioclient_mock.get( + f"{url}/api/v3/rootfolder", + text=load_fixture("radarr/rootfolder-linux.json"), + headers={"Content-Type": CONTENT_TYPE_JSON}, + ) + + aioclient_mock.get( + f"{url}/api/v3/movie", + text=load_fixture("radarr/movie.json"), + headers={"Content-Type": CONTENT_TYPE_JSON}, + ) + + +def mock_connection_error( + aioclient_mock: AiohttpClientMocker, + url: str = URL, +) -> None: + """Mock radarr connection errors.""" + aioclient_mock.get(f"{url}/api/v3/system/status", exc=ClientError) + + +def mock_connection_invalid_auth( + aioclient_mock: AiohttpClientMocker, + url: str = URL, +) -> None: + """Mock radarr invalid auth errors.""" + aioclient_mock.get(f"{url}/api/v3/system/status", status=HTTPStatus.UNAUTHORIZED) + aioclient_mock.get(f"{url}/api/v3/command", status=HTTPStatus.UNAUTHORIZED) + aioclient_mock.get(f"{url}/api/v3/movie", status=HTTPStatus.UNAUTHORIZED) + aioclient_mock.get(f"{url}/api/v3/rootfolder", status=HTTPStatus.UNAUTHORIZED) + + +def mock_connection_server_error( + aioclient_mock: AiohttpClientMocker, + url: str = URL, +) -> None: + """Mock radarr server errors.""" + aioclient_mock.get( + f"{url}/api/v3/system/status", status=HTTPStatus.INTERNAL_SERVER_ERROR + ) + aioclient_mock.get(f"{url}/api/v3/command", status=HTTPStatus.INTERNAL_SERVER_ERROR) + aioclient_mock.get(f"{url}/api/v3/movie", status=HTTPStatus.INTERNAL_SERVER_ERROR) + aioclient_mock.get( + f"{url}/api/v3/rootfolder", status=HTTPStatus.INTERNAL_SERVER_ERROR + ) + + +async def setup_integration( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + url: str = URL, + api_key: str = API_KEY, + unique_id: str = None, + skip_entry_setup: bool = False, + connection_error: bool = False, + invalid_auth: bool = False, + windows: bool = False, +) -> MockConfigEntry: + """Set up the radarr integration in Home Assistant.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=unique_id, + data={ + CONF_URL: url, + CONF_API_KEY: api_key, + CONF_VERIFY_SSL: False, + }, + ) + + entry.add_to_hass(hass) + + mock_connection( + aioclient_mock, + url=url, + error=connection_error, + invalid_auth=invalid_auth, + windows=windows, + ) + + if not skip_entry_setup: + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert await async_setup_component(hass, DOMAIN, {}) + + return entry + + +def patch_async_setup_entry(return_value=True): + """Patch the async entry setup of radarr.""" + return patch( + "homeassistant.components.radarr.async_setup_entry", + return_value=return_value, + ) + + +def patch_radarr(): + """Patch radarr api.""" + return patch("homeassistant.components.radarr.RadarrClient.async_get_system_status") + + +def create_entry(hass: HomeAssistant) -> MockConfigEntry: + """Create Efergy entry in Home Assistant.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_URL: URL, + CONF_API_KEY: API_KEY, + CONF_VERIFY_SSL: False, + }, + ) + + entry.add_to_hass(hass) + return entry diff --git a/tests/components/radarr/fixtures/command.json b/tests/components/radarr/fixtures/command.json new file mode 100644 index 00000000000..51b4d669cf5 --- /dev/null +++ b/tests/components/radarr/fixtures/command.json @@ -0,0 +1,32 @@ +[ + { + "name": "MessagingCleanup", + "commandName": "Messaging Cleanup", + "message": "Completed", + "body": { + "sendUpdatesToClient": false, + "updateScheduledTask": true, + "completionMessage": "Completed", + "requiresDiskAccess": false, + "isExclusive": false, + "isTypeExclusive": false, + "name": "MessagingCleanup", + "lastExecutionTime": "2021-11-29T19:57:46Z", + "lastStartTime": "2021-11-29T19:57:46Z", + "trigger": "scheduled", + "suppressMessages": false + }, + "priority": "low", + "status": "completed", + "queued": "2021-11-29T20:03:16Z", + "started": "2021-11-29T20:03:16Z", + "ended": "2021-11-29T20:03:16Z", + "duration": "00:00:00.0102456", + "trigger": "scheduled", + "stateChangeTime": "2021-11-29T20:03:16Z", + "sendUpdatesToClient": false, + "updateScheduledTask": true, + "lastExecutionTime": "2021-11-29T19:57:46Z", + "id": 1987776 + } +] diff --git a/tests/components/radarr/fixtures/movie.json b/tests/components/radarr/fixtures/movie.json new file mode 100644 index 00000000000..0f974859631 --- /dev/null +++ b/tests/components/radarr/fixtures/movie.json @@ -0,0 +1,118 @@ +[ + { + "id": 0, + "title": "string", + "originalTitle": "string", + "alternateTitles": [ + { + "sourceType": "tmdb", + "movieId": 1, + "title": "string", + "sourceId": 0, + "votes": 0, + "voteCount": 0, + "language": { + "id": 1, + "name": "English" + }, + "id": 1 + } + ], + "sortTitle": "string", + "sizeOnDisk": 0, + "overview": "string", + "inCinemas": "string", + "physicalRelease": "string", + "images": [ + { + "coverType": "poster", + "url": "string", + "remoteUrl": "string" + } + ], + "website": "string", + "year": 0, + "hasFile": true, + "youTubeTrailerId": "string", + "studio": "string", + "path": "string", + "rootFolderPath": "string", + "qualityProfileId": 0, + "monitored": true, + "minimumAvailability": "announced", + "isAvailable": true, + "folderName": "string", + "runtime": 0, + "cleanTitle": "string", + "imdbId": "string", + "tmdbId": 0, + "titleSlug": "string", + "certification": "string", + "genres": ["string"], + "tags": [0], + "added": "string", + "ratings": { + "votes": 0, + "value": 0 + }, + "movieFile": { + "movieId": 0, + "relativePath": "string", + "path": "string", + "size": 916662234, + "dateAdded": "2020-11-26T02:00:35Z", + "indexerFlags": 1, + "quality": { + "quality": { + "id": 14, + "name": "WEBRip-720p", + "source": "webrip", + "resolution": 720, + "modifier": "none" + }, + "revision": { + "version": 1, + "real": 0, + "isRepack": false + } + }, + "mediaInfo": { + "audioBitrate": 0, + "audioChannels": 2, + "audioCodec": "AAC", + "audioLanguages": "", + "audioStreamCount": 1, + "videoBitDepth": 8, + "videoBitrate": 1000000, + "videoCodec": "x264", + "videoFps": 25.0, + "resolution": "1280x534", + "runTime": "1:49:06", + "scanType": "Progressive", + "subtitles": "" + }, + "originalFilePath": "string", + "qualityCutoffNotMet": true, + "languages": [ + { + "id": 26, + "name": "Hindi" + } + ], + "edition": "", + "id": 35361 + }, + "collection": { + "name": "string", + "tmdbId": 0, + "images": [ + { + "coverType": "poster", + "url": "string", + "remoteUrl": "string" + } + ] + }, + "status": "deleted" + } +] diff --git a/tests/components/radarr/fixtures/rootfolder-linux.json b/tests/components/radarr/fixtures/rootfolder-linux.json new file mode 100644 index 00000000000..04610f0cc5c --- /dev/null +++ b/tests/components/radarr/fixtures/rootfolder-linux.json @@ -0,0 +1,8 @@ +[ + { + "path": "/downloads", + "freeSpace": 282500064232, + "unmappedFolders": [], + "id": 1 + } +] diff --git a/tests/components/radarr/fixtures/rootfolder-windows.json b/tests/components/radarr/fixtures/rootfolder-windows.json new file mode 100644 index 00000000000..0e903d13942 --- /dev/null +++ b/tests/components/radarr/fixtures/rootfolder-windows.json @@ -0,0 +1,8 @@ +[ + { + "path": "D:\\Downloads\\TV", + "freeSpace": 282500064232, + "unmappedFolders": [], + "id": 1 + } +] diff --git a/tests/components/radarr/fixtures/system-status.json b/tests/components/radarr/fixtures/system-status.json new file mode 100644 index 00000000000..9c6de2045e4 --- /dev/null +++ b/tests/components/radarr/fixtures/system-status.json @@ -0,0 +1,28 @@ +{ + "version": "10.0.0.34882", + "buildTime": "2020-09-01T23:23:23.9621974Z", + "isDebug": true, + "isProduction": false, + "isAdmin": false, + "isUserInteractive": true, + "startupPath": "C:\\ProgramData\\Radarr", + "appData": "C:\\ProgramData\\Radarr", + "osName": "Windows", + "osVersion": "10.0.18363.0", + "isNetCore": true, + "isMono": false, + "isLinux": false, + "isOsx": false, + "isWindows": true, + "isDocker": false, + "mode": "console", + "branch": "nightly", + "authentication": "none", + "sqliteVersion": "3.32.1", + "migrationVersion": 180, + "urlBase": "", + "runtimeVersion": "3.1.10", + "runtimeName": "netCore", + "startTime": "2020-09-01T23:50:20.2415965Z", + "packageUpdateMechanism": "builtIn" +} diff --git a/tests/components/radarr/test_config_flow.py b/tests/components/radarr/test_config_flow.py new file mode 100644 index 00000000000..ffd6b5f5759 --- /dev/null +++ b/tests/components/radarr/test_config_flow.py @@ -0,0 +1,227 @@ +"""Test Radarr config flow.""" +from unittest.mock import AsyncMock, patch + +from aiopyarr import ArrException + +from homeassistant import data_entry_flow +from homeassistant.components.radarr.const import DEFAULT_NAME, DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_REAUTH, SOURCE_USER +from homeassistant.const import CONF_API_KEY, CONF_SOURCE, CONF_URL, CONF_VERIFY_SSL +from homeassistant.core import HomeAssistant + +from . import ( + API_KEY, + CONF_DATA, + CONF_IMPORT_DATA, + MOCK_REAUTH_INPUT, + MOCK_USER_INPUT, + URL, + mock_connection, + mock_connection_error, + mock_connection_invalid_auth, + patch_async_setup_entry, + setup_integration, +) + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker + + +def _patch_setup(): + return patch("homeassistant.components.radarr.async_setup_entry") + + +async def test_flow_import(hass: HomeAssistant): + """Test import step.""" + with patch( + "homeassistant.components.radarr.config_flow.RadarrClient.async_get_system_status", + return_value=AsyncMock(), + ), _patch_setup(): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=CONF_IMPORT_DATA, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == DEFAULT_NAME + assert result["data"] == CONF_DATA | { + CONF_URL: "http://192.168.1.189:7887/test" + } + assert result["data"][CONF_URL] == "http://192.168.1.189:7887/test" + + +async def test_flow_import_already_configured(hass: HomeAssistant): + """Test import step already configured.""" + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_INPUT) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.radarr.config_flow.RadarrClient.async_get_system_status", + return_value=AsyncMock(), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=CONF_IMPORT_DATA, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_show_user_form(hass: HomeAssistant) -> None: + """Test that the user set up form is served.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + ) + + assert result["step_id"] == "user" + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + + +async def test_cannot_connect( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test we show user form on connection error.""" + mock_connection_error(aioclient_mock) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + data=MOCK_USER_INPUT, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_invalid_auth( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test we show user form on invalid auth.""" + mock_connection_invalid_auth(aioclient_mock) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_USER}, data=MOCK_USER_INPUT + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "invalid_auth"} + + +async def test_wrong_app(hass: HomeAssistant) -> None: + """Test we show user form on wrong app.""" + with patch( + "homeassistant.components.radarr.config_flow.RadarrClient.async_try_zeroconf", + return_value="wrong_app", + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + data={CONF_URL: URL, CONF_VERIFY_SSL: False}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "wrong_app"} + + +async def test_unknown_error(hass: HomeAssistant) -> None: + """Test we show user form on unknown error.""" + with patch( + "homeassistant.components.radarr.config_flow.RadarrClient.async_get_system_status", + side_effect=ArrException, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + data=MOCK_USER_INPUT, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "unknown"} + + +async def test_zero_conf(hass: HomeAssistant) -> None: + """Test the manual flow for zero config.""" + with patch( + "homeassistant.components.radarr.config_flow.RadarrClient.async_try_zeroconf", + return_value=("v3", API_KEY, "/test"), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + data={CONF_URL: URL, CONF_VERIFY_SSL: False}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == DEFAULT_NAME + assert result["data"] == CONF_DATA + + +async def test_full_reauth_flow_implementation( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test the manual reauth flow from start to finish.""" + entry = await setup_integration(hass, aioclient_mock) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + CONF_SOURCE: SOURCE_REAUTH, + "entry_id": entry.entry_id, + "unique_id": entry.unique_id, + }, + data=entry.data, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + with patch_async_setup_entry() as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_REAUTH_INPUT + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "reauth_successful" + + assert entry.data == CONF_DATA | {CONF_API_KEY: "test-api-key-reauth"} + + mock_setup_entry.assert_called_once() + + +async def test_full_user_flow_implementation( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test the full manual user flow from start to finish.""" + mock_connection(aioclient_mock) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + with patch_async_setup_entry(): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_USER_INPUT, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == DEFAULT_NAME + assert result["data"] == CONF_DATA + assert result["data"][CONF_URL] == "http://192.168.1.189:7887/test" diff --git a/tests/components/radarr/test_init.py b/tests/components/radarr/test_init.py new file mode 100644 index 00000000000..a6d77d884d4 --- /dev/null +++ b/tests/components/radarr/test_init.py @@ -0,0 +1,58 @@ +"""Test Radarr integration.""" +from aiopyarr import exceptions + +from homeassistant.components.radarr.const import DEFAULT_NAME, DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from . import create_entry, patch_radarr, setup_integration + +from tests.test_util.aiohttp import AiohttpClientMocker + + +async def test_setup(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker): + """Test unload.""" + entry = await setup_integration(hass, aioclient_mock) + assert entry.state == ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.NOT_LOADED + assert not hass.data.get(DOMAIN) + + +async def test_async_setup_entry_not_ready( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +): + """Test that it throws ConfigEntryNotReady when exception occurs during setup.""" + entry = await setup_integration(hass, aioclient_mock, connection_error=True) + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert entry.state == ConfigEntryState.SETUP_RETRY + assert not hass.data.get(DOMAIN) + + +async def test_async_setup_entry_auth_failed(hass: HomeAssistant): + """Test that it throws ConfigEntryAuthFailed when authentication fails.""" + entry = create_entry(hass) + with patch_radarr() as radarrmock: + radarrmock.side_effect = exceptions.ArrAuthenticationException + await hass.config_entries.async_setup(entry.entry_id) + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert entry.state == ConfigEntryState.SETUP_ERROR + assert not hass.data.get(DOMAIN) + + +async def test_device_info(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker): + """Test device info.""" + entry = await setup_integration(hass, aioclient_mock) + device_registry = dr.async_get(hass) + await hass.async_block_till_done() + device = device_registry.async_get_device({(DOMAIN, entry.entry_id)}) + + assert device.configuration_url == "http://192.168.1.189:7887/test" + assert device.identifiers == {(DOMAIN, entry.entry_id)} + assert device.manufacturer == DEFAULT_NAME + assert device.name == "Mock Title" + assert device.sw_version == "10.0.0.34882" diff --git a/tests/components/radarr/test_sensor.py b/tests/components/radarr/test_sensor.py index aa8ccd74667..a95885f1b1b 100644 --- a/tests/components/radarr/test_sensor.py +++ b/tests/components/radarr/test_sensor.py @@ -1,441 +1,38 @@ -"""The tests for the Radarr platform.""" -from unittest.mock import patch +"""The tests for Radarr sensor platform.""" +from datetime import timedelta -import pytest +from homeassistant.components.radarr.sensor import SENSOR_TYPES +from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT +from homeassistant.core import HomeAssistant +import homeassistant.util.dt as dt_util -from homeassistant.const import DATA_GIGABYTES -from homeassistant.setup import async_setup_component +from . import setup_integration + +from tests.common import async_fire_time_changed +from tests.test_util.aiohttp import AiohttpClientMocker -def mocked_exception(*args, **kwargs): - """Mock exception thrown by requests.get.""" - raise OSError +async def test_sensors(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker): + """Test for successfully setting up the Radarr platform.""" + for description in SENSOR_TYPES.values(): + description.entity_registry_enabled_default = True + await setup_integration(hass, aioclient_mock) + + next_update = dt_util.utcnow() + timedelta(seconds=30) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() + + state = hass.states.get("sensor.radarr_disk_space_downloads") + assert state.state == "263.10" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "GB" + state = hass.states.get("sensor.radarr_movies") + assert state.state == "1" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "Movies" -def mocked_requests_get(*args, **kwargs): - """Mock requests.get invocations.""" +async def test_windows(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker): + """Test for successfully setting up the Radarr platform on Windows.""" + await setup_integration(hass, aioclient_mock, windows=True) - class MockResponse: - """Class to represent a mocked response.""" - - def __init__(self, json_data, status_code): - """Initialize the mock response class.""" - self.json_data = json_data - self.status_code = status_code - - def json(self): - """Return the json of the response.""" - return self.json_data - - url = str(args[0]) - if "api/calendar" in url: - return MockResponse( - [ - { - "title": "Resident Evil", - "sortTitle": "resident evil final chapter", - "sizeOnDisk": 0, - "status": "announced", - "overview": "Alice, Jill, Claire, Chris, Leon, Ada, and...", - "inCinemas": "2017-01-25T00:00:00Z", - "physicalRelease": "2017-01-27T00:00:00Z", - "images": [ - { - "coverType": "poster", - "url": ( - "/radarr/MediaCover/12/poster.jpg" - "?lastWrite=636208663600000000" - ), - }, - { - "coverType": "banner", - "url": ( - "/radarr/MediaCover/12/banner.jpg" - "?lastWrite=636208663600000000" - ), - }, - ], - "website": "", - "downloaded": "false", - "year": 2017, - "hasFile": "false", - "youTubeTrailerId": "B5yxr7lmxhg", - "studio": "Impact Pictures", - "path": "/path/to/Resident Evil The Final Chapter (2017)", - "profileId": 3, - "monitored": "false", - "runtime": 106, - "lastInfoSync": "2017-01-24T14:52:40.315434Z", - "cleanTitle": "residentevilfinalchapter", - "imdbId": "tt2592614", - "tmdbId": 173897, - "titleSlug": "resident-evil-the-final-chapter-2017", - "genres": ["Action", "Horror", "Science Fiction"], - "tags": [], - "added": "2017-01-24T14:52:39.989964Z", - "ratings": {"votes": 363, "value": 4.3}, - "alternativeTitles": ["Resident Evil: Rising"], - "qualityProfileId": 3, - "id": 12, - } - ], - 200, - ) - if "api/command" in url: - return MockResponse( - [ - { - "name": "RescanMovie", - "startedOn": "0001-01-01T00:00:00Z", - "stateChangeTime": "2014-02-05T05:09:09.2366139Z", - "sendUpdatesToClient": "true", - "state": "pending", - "id": 24, - } - ], - 200, - ) - if "api/movie" in url: - return MockResponse( - [ - { - "title": "Assassin's Creed", - "sortTitle": "assassins creed", - "sizeOnDisk": 0, - "status": "released", - "overview": "Lynch discovers he is a descendant of...", - "inCinemas": "2016-12-21T00:00:00Z", - "images": [ - { - "coverType": "poster", - "url": ( - "/radarr/MediaCover/1/poster.jpg" - "?lastWrite=636200219330000000" - ), - }, - { - "coverType": "banner", - "url": ( - "/radarr/MediaCover/1/banner.jpg" - "?lastWrite=636200219340000000" - ), - }, - ], - "website": "https://www.ubisoft.com/en-US/", - "downloaded": "false", - "year": 2016, - "hasFile": "false", - "youTubeTrailerId": "pgALJgMjXN4", - "studio": "20th Century Fox", - "path": "/path/to/Assassin's Creed (2016)", - "profileId": 6, - "monitored": "true", - "runtime": 115, - "lastInfoSync": "2017-01-23T22:05:32.365337Z", - "cleanTitle": "assassinscreed", - "imdbId": "tt2094766", - "tmdbId": 121856, - "titleSlug": "assassins-creed-121856", - "genres": ["Action", "Adventure", "Fantasy", "Science Fiction"], - "tags": [], - "added": "2017-01-14T20:18:52.938244Z", - "ratings": {"votes": 711, "value": 5.2}, - "alternativeTitles": ["Assassin's Creed: The IMAX Experience"], - "qualityProfileId": 6, - "id": 1, - } - ], - 200, - ) - if "api/diskspace" in url: - return MockResponse( - [ - { - "path": "/data", - "label": "", - "freeSpace": 282500067328, - "totalSpace": 499738734592, - } - ], - 200, - ) - if "api/system/status" in url: - return MockResponse( - { - "version": "0.2.0.210", - "buildTime": "2017-01-22T23:12:49Z", - "isDebug": "false", - "isProduction": "true", - "isAdmin": "false", - "isUserInteractive": "false", - "startupPath": "/path/to/radarr", - "appData": "/path/to/radarr/data", - "osVersion": "4.8.13.1", - "isMonoRuntime": "true", - "isMono": "true", - "isLinux": "true", - "isOsx": "false", - "isWindows": "false", - "branch": "develop", - "authentication": "forms", - "sqliteVersion": "3.16.2", - "urlBase": "", - "runtimeVersion": ( - "4.6.1 (Stable 4.6.1.3/abb06f1 Mon Oct 3 07:57:59 UTC 2016)" - ), - }, - 200, - ) - return MockResponse({"error": "Unauthorized"}, 401) - - -async def test_diskspace_no_paths(hass): - """Test getting all disk space.""" - config = { - "sensor": { - "platform": "radarr", - "api_key": "foo", - "days": "2", - "unit": DATA_GIGABYTES, - "include_paths": [], - "monitored_conditions": ["diskspace"], - } - } - - with patch( - "requests.get", - side_effect=mocked_requests_get, - ): - assert await async_setup_component(hass, "sensor", config) - await hass.async_block_till_done() - - entity = hass.states.get("sensor.radarr_disk_space") - assert entity is not None - assert entity.state == "263.10" - assert entity.attributes["icon"] == "mdi:harddisk" - assert entity.attributes["unit_of_measurement"] == DATA_GIGABYTES - assert entity.attributes["friendly_name"] == "Radarr Disk Space" - assert entity.attributes["/data"] == "263.10/465.42GB (56.53%)" - - -async def test_diskspace_paths(hass): - """Test getting diskspace for included paths.""" - config = { - "sensor": { - "platform": "radarr", - "api_key": "foo", - "days": "2", - "unit": DATA_GIGABYTES, - "include_paths": ["/data"], - "monitored_conditions": ["diskspace"], - } - } - - with patch( - "requests.get", - side_effect=mocked_requests_get, - ): - assert await async_setup_component(hass, "sensor", config) - await hass.async_block_till_done() - - entity = hass.states.get("sensor.radarr_disk_space") - assert entity is not None - assert entity.state == "263.10" - assert entity.attributes["icon"] == "mdi:harddisk" - assert entity.attributes["unit_of_measurement"] == DATA_GIGABYTES - assert entity.attributes["friendly_name"] == "Radarr Disk Space" - assert entity.attributes["/data"] == "263.10/465.42GB (56.53%)" - - -async def test_commands(hass): - """Test getting running commands.""" - config = { - "sensor": { - "platform": "radarr", - "api_key": "foo", - "days": "2", - "unit": DATA_GIGABYTES, - "include_paths": ["/data"], - "monitored_conditions": ["commands"], - } - } - - with patch( - "requests.get", - side_effect=mocked_requests_get, - ): - assert await async_setup_component(hass, "sensor", config) - await hass.async_block_till_done() - - entity = hass.states.get("sensor.radarr_commands") - assert entity is not None - assert int(entity.state) == 1 - assert entity.attributes["icon"] == "mdi:code-braces" - assert entity.attributes["unit_of_measurement"] == "Commands" - assert entity.attributes["friendly_name"] == "Radarr Commands" - assert entity.attributes["RescanMovie"] == "pending" - - -async def test_movies(hass): - """Test getting the number of movies.""" - config = { - "sensor": { - "platform": "radarr", - "api_key": "foo", - "days": "2", - "unit": DATA_GIGABYTES, - "include_paths": ["/data"], - "monitored_conditions": ["movies"], - } - } - - with patch( - "requests.get", - side_effect=mocked_requests_get, - ): - assert await async_setup_component(hass, "sensor", config) - await hass.async_block_till_done() - - entity = hass.states.get("sensor.radarr_movies") - assert entity is not None - assert int(entity.state) == 1 - assert entity.attributes["icon"] == "mdi:television" - assert entity.attributes["unit_of_measurement"] == "Movies" - assert entity.attributes["friendly_name"] == "Radarr Movies" - assert entity.attributes["Assassin's Creed (2016)"] == "false" - - -async def test_upcoming_multiple_days(hass): - """Test the upcoming movies for multiple days.""" - config = { - "sensor": { - "platform": "radarr", - "api_key": "foo", - "days": "2", - "unit": DATA_GIGABYTES, - "include_paths": ["/data"], - "monitored_conditions": ["upcoming"], - } - } - - with patch( - "requests.get", - side_effect=mocked_requests_get, - ): - assert await async_setup_component(hass, "sensor", config) - await hass.async_block_till_done() - - entity = hass.states.get("sensor.radarr_upcoming") - assert entity is not None - assert int(entity.state) == 1 - assert entity.attributes["icon"] == "mdi:television" - assert entity.attributes["unit_of_measurement"] == "Movies" - assert entity.attributes["friendly_name"] == "Radarr Upcoming" - assert entity.attributes["Resident Evil (2017)"] == "2017-01-27T00:00:00Z" - - -@pytest.mark.skip -async def test_upcoming_today(hass): - """Test filtering for a single day. - - Radarr needs to respond with at least 2 days. - """ - config = { - "sensor": { - "platform": "radarr", - "api_key": "foo", - "days": "1", - "unit": DATA_GIGABYTES, - "include_paths": ["/data"], - "monitored_conditions": ["upcoming"], - } - } - with patch( - "requests.get", - side_effect=mocked_requests_get, - ): - assert await async_setup_component(hass, "sensor", config) - await hass.async_block_till_done() - entity = hass.states.get("sensor.radarr_upcoming") - assert int(entity.state) == 1 - assert entity.attributes["icon"] == "mdi:television" - assert entity.attributes["unit_of_measurement"] == "Movies" - assert entity.attributes["friendly_name"] == "Radarr Upcoming" - assert entity.attributes["Resident Evil (2017)"] == "2017-01-27T00:00:00Z" - - -async def test_system_status(hass): - """Test the getting of the system status.""" - config = { - "sensor": { - "platform": "radarr", - "api_key": "foo", - "days": "2", - "unit": DATA_GIGABYTES, - "include_paths": ["/data"], - "monitored_conditions": ["status"], - } - } - with patch( - "requests.get", - side_effect=mocked_requests_get, - ): - assert await async_setup_component(hass, "sensor", config) - await hass.async_block_till_done() - entity = hass.states.get("sensor.radarr_status") - assert entity is not None - assert entity.state == "0.2.0.210" - assert entity.attributes["icon"] == "mdi:information" - assert entity.attributes["friendly_name"] == "Radarr Status" - assert entity.attributes["osVersion"] == "4.8.13.1" - - -async def test_ssl(hass): - """Test SSL being enabled.""" - config = { - "sensor": { - "platform": "radarr", - "api_key": "foo", - "days": "1", - "unit": DATA_GIGABYTES, - "include_paths": ["/data"], - "monitored_conditions": ["upcoming"], - "ssl": "true", - } - } - with patch( - "requests.get", - side_effect=mocked_requests_get, - ): - assert await async_setup_component(hass, "sensor", config) - await hass.async_block_till_done() - entity = hass.states.get("sensor.radarr_upcoming") - assert entity is not None - assert int(entity.state) == 1 - assert entity.attributes["icon"] == "mdi:television" - assert entity.attributes["unit_of_measurement"] == "Movies" - assert entity.attributes["friendly_name"] == "Radarr Upcoming" - assert entity.attributes["Resident Evil (2017)"] == "2017-01-27T00:00:00Z" - - -async def test_exception_handling(hass): - """Test exception being handled.""" - config = { - "sensor": { - "platform": "radarr", - "api_key": "foo", - "days": "1", - "unit": DATA_GIGABYTES, - "include_paths": ["/data"], - "monitored_conditions": ["upcoming"], - } - } - with patch( - "requests.get", - side_effect=mocked_exception, - ): - assert await async_setup_component(hass, "sensor", config) - await hass.async_block_till_done() - entity = hass.states.get("sensor.radarr_upcoming") - assert entity is not None - assert entity.state == "unavailable" + state = hass.states.get("sensor.radarr_disk_space_tv") + assert state.state == "263.10"