Radarr Config Flow (#78965)

This commit is contained in:
Robert Hillis 2022-09-22 22:16:24 -04:00 committed by GitHub
parent 6b0c9b6a6a
commit 0ccb495209
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 1291 additions and 634 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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."
}
}
}

View file

@ -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."
}
}
}

View file

@ -305,6 +305,7 @@ FLOWS = {
"qingping",
"qnap_qsw",
"rachio",
"radarr",
"radio_browser",
"radiotherm",
"rainforest_eagle",

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,8 @@
[
{
"path": "/downloads",
"freeSpace": 282500064232,
"unmappedFolders": [],
"id": 1
}
]

View file

@ -0,0 +1,8 @@
[
{
"path": "D:\\Downloads\\TV",
"freeSpace": 282500064232,
"unmappedFolders": [],
"id": 1
}
]

View file

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

View file

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

View file

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

View file

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