Refactor Sonarr Integration (#33859)

Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>
pull/31119/head
Chris Talkington 2020-05-29 19:08:05 -05:00 committed by GitHub
parent f4a518edcc
commit 940249f45e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 2179 additions and 899 deletions

View File

@ -726,7 +726,6 @@ omit =
homeassistant/components/soma/__init__.py
homeassistant/components/somfy/*
homeassistant/components/somfy_mylink/*
homeassistant/components/sonarr/sensor.py
homeassistant/components/sonos/*
homeassistant/components/sony_projector/switch.py
homeassistant/components/spc/*

View File

@ -1 +1,164 @@
"""The sonarr component."""
"""The Sonarr component."""
import asyncio
from datetime import timedelta
from typing import Any, Dict
from sonarr import Sonarr, SonarrError
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_NAME,
CONF_API_KEY,
CONF_HOST,
CONF_PORT,
CONF_SSL,
CONF_VERIFY_SSL,
)
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.typing import HomeAssistantType
from .const import (
ATTR_IDENTIFIERS,
ATTR_MANUFACTURER,
ATTR_SOFTWARE_VERSION,
CONF_BASE_PATH,
CONF_UPCOMING_DAYS,
CONF_WANTED_MAX_ITEMS,
DATA_SONARR,
DATA_UNDO_UPDATE_LISTENER,
DEFAULT_UPCOMING_DAYS,
DEFAULT_WANTED_MAX_ITEMS,
DOMAIN,
)
PLATFORMS = ["sensor"]
SCAN_INTERVAL = timedelta(seconds=30)
async def async_setup(hass: HomeAssistantType, config: Dict) -> bool:
"""Set up the Sonarr component."""
hass.data.setdefault(DOMAIN, {})
return True
async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool:
"""Set up Sonarr from a config entry."""
if not entry.options:
options = {
CONF_UPCOMING_DAYS: entry.data.get(
CONF_UPCOMING_DAYS, DEFAULT_UPCOMING_DAYS
),
CONF_WANTED_MAX_ITEMS: entry.data.get(
CONF_WANTED_MAX_ITEMS, DEFAULT_WANTED_MAX_ITEMS
),
}
hass.config_entries.async_update_entry(entry, options=options)
sonarr = Sonarr(
host=entry.data[CONF_HOST],
port=entry.data[CONF_PORT],
api_key=entry.data[CONF_API_KEY],
base_path=entry.data[CONF_BASE_PATH],
session=async_get_clientsession(hass),
tls=entry.data[CONF_SSL],
verify_ssl=entry.data[CONF_VERIFY_SSL],
)
try:
await sonarr.update()
except SonarrError:
raise ConfigEntryNotReady
undo_listener = entry.add_update_listener(_async_update_listener)
hass.data[DOMAIN][entry.entry_id] = {
DATA_SONARR: sonarr,
DATA_UNDO_UPDATE_LISTENER: undo_listener,
}
for component in PLATFORMS:
hass.async_create_task(
hass.config_entries.async_forward_entry_setup(entry, component)
)
return True
async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
unload_ok = all(
await asyncio.gather(
*[
hass.config_entries.async_forward_entry_unload(entry, component)
for component in PLATFORMS
]
)
)
hass.data[DOMAIN][entry.entry_id][DATA_UNDO_UPDATE_LISTENER]()
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
async def _async_update_listener(hass: HomeAssistantType, entry: ConfigEntry) -> None:
"""Handle options update."""
async_dispatcher_send(
hass, f"sonarr.{entry.entry_id}.entry_options_update", entry.options
)
class SonarrEntity(Entity):
"""Defines a base Sonarr entity."""
def __init__(
self,
*,
sonarr: Sonarr,
entry_id: str,
device_id: str,
name: str,
icon: str,
enabled_default: bool = True,
) -> None:
"""Initialize the Sonar entity."""
self._entry_id = entry_id
self._device_id = device_id
self._enabled_default = enabled_default
self._icon = icon
self._name = name
self.sonarr = sonarr
@property
def name(self) -> str:
"""Return the name of the entity."""
return self._name
@property
def icon(self) -> str:
"""Return the mdi icon of the entity."""
return self._icon
@property
def entity_registry_enabled_default(self) -> bool:
"""Return if the entity should be enabled when first added to the entity registry."""
return self._enabled_default
@property
def device_info(self) -> Dict[str, Any]:
"""Return device information about the application."""
if self._device_id is None:
return None
return {
ATTR_IDENTIFIERS: {(DOMAIN, self._device_id)},
ATTR_NAME: "Activity Sensor",
ATTR_MANUFACTURER: "Sonarr",
ATTR_SOFTWARE_VERSION: self.sonarr.app.info.version,
"entry_type": "service",
}

View File

@ -0,0 +1,145 @@
"""Config flow for Sonarr."""
import logging
from typing import Any, Dict, Optional
from sonarr import Sonarr, SonarrAccessRestricted, SonarrError
import voluptuous as vol
from homeassistant.config_entries import CONN_CLASS_LOCAL_POLL, ConfigFlow, OptionsFlow
from homeassistant.const import (
CONF_API_KEY,
CONF_HOST,
CONF_PORT,
CONF_SSL,
CONF_VERIFY_SSL,
)
from homeassistant.core import callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.typing import ConfigType, HomeAssistantType
from .const import (
CONF_BASE_PATH,
CONF_UPCOMING_DAYS,
CONF_WANTED_MAX_ITEMS,
DEFAULT_BASE_PATH,
DEFAULT_PORT,
DEFAULT_SSL,
DEFAULT_UPCOMING_DAYS,
DEFAULT_VERIFY_SSL,
DEFAULT_WANTED_MAX_ITEMS,
)
from .const import DOMAIN # pylint: disable=unused-import
_LOGGER = logging.getLogger(__name__)
async def validate_input(hass: HomeAssistantType, data: dict) -> Dict[str, Any]:
"""Validate the user input allows us to connect.
Data has the keys from DATA_SCHEMA with values provided by the user.
"""
session = async_get_clientsession(hass)
sonarr = Sonarr(
host=data[CONF_HOST],
port=data[CONF_PORT],
api_key=data[CONF_API_KEY],
base_path=data[CONF_BASE_PATH],
tls=data[CONF_SSL],
verify_ssl=data[CONF_VERIFY_SSL],
session=session,
)
await sonarr.update()
return True
class SonarrConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Sonarr."""
VERSION = 1
CONNECTION_CLASS = CONN_CLASS_LOCAL_POLL
@staticmethod
@callback
def async_get_options_flow(config_entry):
"""Get the options flow for this handler."""
return SonarrOptionsFlowHandler(config_entry)
async def async_step_import(
self, user_input: Optional[ConfigType] = None
) -> Dict[str, Any]:
"""Handle a flow initiated by configuration file."""
return await self.async_step_user(user_input)
async def async_step_user(
self, user_input: Optional[ConfigType] = None
) -> Dict[str, Any]:
"""Handle a flow initiated by the user."""
if user_input is None:
return self._show_setup_form()
if CONF_VERIFY_SSL not in user_input:
user_input[CONF_VERIFY_SSL] = DEFAULT_VERIFY_SSL
try:
await validate_input(self.hass, user_input)
except SonarrAccessRestricted:
return self._show_setup_form({"base": "invalid_auth"})
except SonarrError:
return self._show_setup_form({"base": "cannot_connect"})
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception")
return self.async_abort(reason="unknown")
return self.async_create_entry(title=user_input[CONF_HOST], data=user_input)
def _show_setup_form(self, errors: Optional[Dict] = None) -> Dict[str, Any]:
"""Show the setup form to the user."""
data_schema = {
vol.Required(CONF_HOST): str,
vol.Required(CONF_API_KEY): str,
vol.Optional(CONF_BASE_PATH, default=DEFAULT_BASE_PATH): str,
vol.Optional(CONF_PORT, default=DEFAULT_PORT): int,
vol.Optional(CONF_SSL, default=DEFAULT_SSL): bool,
}
if self.show_advanced_options:
data_schema[
vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL)
] = bool
return self.async_show_form(
step_id="user", data_schema=vol.Schema(data_schema), errors=errors or {},
)
class SonarrOptionsFlowHandler(OptionsFlow):
"""Handle Sonarr client options."""
def __init__(self, config_entry):
"""Initialize options flow."""
self.config_entry = config_entry
async def async_step_init(self, user_input: Optional[ConfigType] = None):
"""Manage Sonarr options."""
if user_input is not None:
return self.async_create_entry(title="", data=user_input)
options = {
vol.Optional(
CONF_UPCOMING_DAYS,
default=self.config_entry.options.get(
CONF_UPCOMING_DAYS, DEFAULT_UPCOMING_DAYS
),
): int,
vol.Optional(
CONF_WANTED_MAX_ITEMS,
default=self.config_entry.options.get(
CONF_WANTED_MAX_ITEMS, DEFAULT_WANTED_MAX_ITEMS
),
): int,
}
return self.async_show_form(step_id="init", data_schema=vol.Schema(options))

View File

@ -0,0 +1,29 @@
"""Constants for Sonarr."""
DOMAIN = "sonarr"
# Attributes
ATTR_IDENTIFIERS = "identifiers"
ATTR_MANUFACTURER = "manufacturer"
ATTR_SOFTWARE_VERSION = "sw_version"
# Config Keys
CONF_BASE_PATH = "base_path"
CONF_DAYS = "days"
CONF_INCLUDED = "include_paths"
CONF_UNIT = "unit"
CONF_UPCOMING_DAYS = "upcoming_days"
CONF_URLBASE = "urlbase"
CONF_WANTED_MAX_ITEMS = "wanted_max_items"
# Data
DATA_SONARR = "sonarr"
DATA_UNDO_UPDATE_LISTENER = "undo_update_listener"
# Defaults
DEFAULT_BASE_PATH = "/api"
DEFAULT_HOST = "localhost"
DEFAULT_PORT = 8989
DEFAULT_SSL = False
DEFAULT_UPCOMING_DAYS = 1
DEFAULT_VERIFY_SSL = False
DEFAULT_WANTED_MAX_ITEMS = 50

View File

@ -2,5 +2,8 @@
"domain": "sonarr",
"name": "Sonarr",
"documentation": "https://www.home-assistant.io/integrations/sonarr",
"codeowners": ["@ctalkington"]
"codeowners": ["@ctalkington"],
"requirements": ["sonarr==0.2.1"],
"config_flow": true,
"quality_scale": "silver"
}

View File

@ -1,17 +1,20 @@
"""Support for Sonarr."""
"""Support for Sonarr sensors."""
from datetime import timedelta
import logging
from typing import Any, Callable, Dict, List, Optional, Union
import requests
from sonarr import Sonarr, SonarrConnectionError, SonarrError
import voluptuous as vol
from homeassistant.components.sensor import PLATFORM_SCHEMA
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import (
CONF_API_KEY,
CONF_HOST,
CONF_MONITORED_CONDITIONS,
CONF_PORT,
CONF_SSL,
CONF_VERIFY_SSL,
DATA_BYTES,
DATA_EXABYTES,
DATA_GIGABYTES,
@ -21,46 +24,32 @@ from homeassistant.const import (
DATA_TERABYTES,
DATA_YOTTABYTES,
DATA_ZETTABYTES,
HTTP_OK,
)
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.typing import ConfigType, HomeAssistantType
import homeassistant.util.dt as dt_util
from . import SonarrEntity
from .const import (
CONF_BASE_PATH,
CONF_DAYS,
CONF_INCLUDED,
CONF_UNIT,
CONF_UPCOMING_DAYS,
CONF_URLBASE,
CONF_WANTED_MAX_ITEMS,
DATA_SONARR,
DEFAULT_BASE_PATH,
DEFAULT_HOST,
DEFAULT_PORT,
DEFAULT_SSL,
DOMAIN,
)
_LOGGER = logging.getLogger(__name__)
CONF_DAYS = "days"
CONF_INCLUDED = "include_paths"
CONF_UNIT = "unit"
CONF_URLBASE = "urlbase"
DEFAULT_HOST = "localhost"
DEFAULT_PORT = 8989
DEFAULT_URLBASE = ""
DEFAULT_DAYS = "1"
DEFAULT_UNIT = DATA_GIGABYTES
SENSOR_TYPES = {
"diskspace": ["Disk Space", DATA_GIGABYTES, "mdi:harddisk"],
"queue": ["Queue", "Episodes", "mdi:download"],
"upcoming": ["Upcoming", "Episodes", "mdi:television"],
"wanted": ["Wanted", "Episodes", "mdi:television"],
"series": ["Series", "Shows", "mdi:television"],
"commands": ["Commands", "Commands", "mdi:code-braces"],
"status": ["Status", "Status", "mdi:information"],
}
ENDPOINTS = {
"diskspace": "{0}://{1}:{2}/{3}api/diskspace",
"queue": "{0}://{1}:{2}/{3}api/queue",
"upcoming": "{0}://{1}:{2}/{3}api/calendar?start={4}&end={5}",
"wanted": "{0}://{1}:{2}/{3}api/wanted/missing",
"series": "{0}://{1}:{2}/{3}api/series",
"commands": "{0}://{1}:{2}/{3}api/command",
"status": "{0}://{1}:{2}/{3}api/system/status",
}
# Support to Yottabytes for the future, why not
BYTE_SIZES = [
DATA_BYTES,
DATA_KILOBYTES,
@ -72,198 +61,430 @@ BYTE_SIZES = [
DATA_ZETTABYTES,
DATA_YOTTABYTES,
]
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(CONF_MONITORED_CONDITIONS, default=["upcoming"]): vol.All(
cv.ensure_list, [vol.In(list(SENSOR_TYPES))]
),
vol.Optional(CONF_PORT, default=DEFAULT_PORT): 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,
}
DEFAULT_URLBASE = ""
DEFAULT_DAYS = "1"
DEFAULT_UNIT = DATA_GIGABYTES
PLATFORM_SCHEMA = vol.All(
cv.deprecated(CONF_INCLUDED, invalidation_version="0.112"),
cv.deprecated(CONF_MONITORED_CONDITIONS, invalidation_version="0.112"),
cv.deprecated(CONF_UNIT, invalidation_version="0.112"),
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(CONF_MONITORED_CONDITIONS, default=[]): cv.ensure_list,
vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean,
vol.Optional(CONF_UNIT, default=DEFAULT_UNIT): vol.In(BYTE_SIZES),
vol.Optional(CONF_URLBASE, default=DEFAULT_URLBASE): cv.string,
}
),
)
def setup_platform(hass, config, add_entities, discovery_info=None):
"""Set up the Sonarr platform."""
conditions = config.get(CONF_MONITORED_CONDITIONS)
add_entities([SonarrSensor(config, sensor) for sensor in conditions], True)
async def async_setup_platform(
hass: HomeAssistantType,
config: ConfigType,
async_add_entities: Callable[[List[Entity], bool], None],
discovery_info: Any = None,
) -> None:
"""Import the platform into a config entry."""
if len(hass.config_entries.async_entries(DOMAIN)) > 0:
return True
config[CONF_BASE_PATH] = f"{config[CONF_URLBASE]}{DEFAULT_BASE_PATH}"
config[CONF_UPCOMING_DAYS] = int(config[CONF_DAYS])
config[CONF_VERIFY_SSL] = False
del config[CONF_DAYS]
del config[CONF_INCLUDED]
del config[CONF_MONITORED_CONDITIONS]
del config[CONF_URLBASE]
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_IMPORT}, data=config
)
)
class SonarrSensor(Entity):
async def async_setup_entry(
hass: HomeAssistantType,
entry: ConfigEntry,
async_add_entities: Callable[[List[Entity], bool], None],
) -> None:
"""Set up Sonarr sensors based on a config entry."""
options = entry.options
sonarr = hass.data[DOMAIN][entry.entry_id][DATA_SONARR]
entities = [
SonarrCommandsSensor(sonarr, entry.entry_id),
SonarrDiskspaceSensor(sonarr, entry.entry_id),
SonarrQueueSensor(sonarr, entry.entry_id),
SonarrSeriesSensor(sonarr, entry.entry_id),
SonarrUpcomingSensor(sonarr, entry.entry_id, days=options[CONF_UPCOMING_DAYS]),
SonarrWantedSensor(
sonarr, entry.entry_id, max_items=options[CONF_WANTED_MAX_ITEMS]
),
]
async_add_entities(entities, True)
def sonarr_exception_handler(func):
"""Decorate Sonarr calls to handle Sonarr exceptions.
A decorator that wraps the passed in function, catches Sonarr errors,
and handles the availability of the entity.
"""
async def handler(self, *args, **kwargs):
try:
await func(self, *args, **kwargs)
self.last_update_success = True
except SonarrConnectionError as error:
if self.available:
_LOGGER.error("Error communicating with API: %s", error)
self.last_update_success = False
except SonarrError as error:
if self.available:
_LOGGER.error("Invalid response from API: %s", error)
self.last_update_success = False
return handler
class SonarrSensor(SonarrEntity):
"""Implementation of the Sonarr sensor."""
def __init__(self, conf, sensor_type):
"""Create Sonarr entity."""
def __init__(
self,
*,
sonarr: Sonarr,
entry_id: str,
enabled_default: bool = True,
icon: str,
key: str,
name: str,
unit_of_measurement: Optional[str] = None,
) -> None:
"""Initialize Sonarr sensor."""
self._unit_of_measurement = unit_of_measurement
self._key = key
self._unique_id = f"{entry_id}_{key}"
self.last_update_success = False
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 = "{}/".format(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._state = None
self.data = []
self.type = sensor_type
self._name = SENSOR_TYPES[self.type][0]
if self.type == "diskspace":
self._unit = conf.get(CONF_UNIT)
else:
self._unit = SENSOR_TYPES[self.type][1]
self._icon = SENSOR_TYPES[self.type][2]
self._available = False
super().__init__(
sonarr=sonarr,
entry_id=entry_id,
device_id=entry_id,
name=name,
icon=icon,
enabled_default=enabled_default,
)
@property
def name(self):
"""Return the name of the sensor."""
return "{} {}".format("Sonarr", self._name)
def unique_id(self) -> str:
"""Return the unique ID for this sensor."""
return self._unique_id
@property
def state(self):
"""Return sensor state."""
return self._state
@property
def available(self):
def available(self) -> bool:
"""Return sensor availability."""
return self._available
return self.last_update_success
@property
def unit_of_measurement(self):
"""Return the unit of the sensor."""
return self._unit
def unit_of_measurement(self) -> str:
"""Return the unit this state is expressed in."""
return self._unit_of_measurement
class SonarrCommandsSensor(SonarrSensor):
"""Defines a Sonarr Commands sensor."""
def __init__(self, sonarr: Sonarr, entry_id: str) -> None:
"""Initialize Sonarr Commands sensor."""
self._commands = []
super().__init__(
sonarr=sonarr,
entry_id=entry_id,
icon="mdi:code-braces",
key="commands",
name=f"{sonarr.app.info.app_name} Commands",
unit_of_measurement="Commands",
enabled_default=False,
)
@sonarr_exception_handler
async def async_update(self) -> None:
"""Update entity."""
self._commands = await self.sonarr.commands()
@property
def device_state_attributes(self):
"""Return the state attributes of the sensor."""
attributes = {}
if self.type == "upcoming":
for show in self.data:
if show["series"]["title"] in attributes:
continue
def device_state_attributes(self) -> Optional[Dict[str, Any]]:
"""Return the state attributes of the entity."""
attrs = {}
attributes[show["series"]["title"]] = "S{:02d}E{:02d}".format(
show["seasonNumber"], show["episodeNumber"]
)
elif self.type == "queue":
for show in self.data:
remaining = 1 if show["size"] == 0 else show["sizeleft"] / show["size"]
attributes[
show["series"]["title"]
+ " S{:02d}E{:02d}".format(
show["episode"]["seasonNumber"],
show["episode"]["episodeNumber"],
)
] = "{:.2f}%".format(100 * (1 - (remaining)))
elif self.type == "wanted":
for show in self.data:
attributes[
show["series"]["title"]
+ " S{:02d}E{:02d}".format(
show["seasonNumber"], show["episodeNumber"]
)
] = show["airDate"]
elif self.type == "commands":
for command in self.data:
attributes[command["name"]] = command["state"]
elif self.type == "diskspace":
for data in self.data:
attributes[data["path"]] = "{:.2f}/{:.2f}{} ({:.2f}%)".format(
to_unit(data["freeSpace"], self._unit),
to_unit(data["totalSpace"], self._unit),
self._unit,
(
to_unit(data["freeSpace"], self._unit)
/ to_unit(data["totalSpace"], self._unit)
* 100
),
)
elif self.type == "series":
for show in self.data:
if "episodeFileCount" not in show or "episodeCount" not in show:
attributes[show["title"]] = "N/A"
else:
attributes[show["title"]] = "{}/{} Episodes".format(
show["episodeFileCount"], show["episodeCount"]
)
elif self.type == "status":
attributes = self.data
return attributes
for command in self._commands:
attrs[command.name] = command.state
return attrs
@property
def icon(self):
"""Return the icon of the sensor."""
return self._icon
def state(self) -> Union[None, str, int, float]:
"""Return the state of the sensor."""
return len(self._commands)
def update(self):
"""Update the data for the sensor."""
class SonarrDiskspaceSensor(SonarrSensor):
"""Defines a Sonarr Disk Space sensor."""
def __init__(self, sonarr: Sonarr, entry_id: str) -> None:
"""Initialize Sonarr Disk Space sensor."""
self._disks = []
self._total_free = 0
super().__init__(
sonarr=sonarr,
entry_id=entry_id,
icon="mdi:harddisk",
key="diskspace",
name=f"{sonarr.app.info.app_name} Disk Space",
unit_of_measurement=DATA_GIGABYTES,
enabled_default=False,
)
def _to_unit(self, value):
"""Return a value converted to unit of measurement."""
return value / 1024 ** BYTE_SIZES.index(self._unit_of_measurement)
@sonarr_exception_handler
async def async_update(self) -> None:
"""Update entity."""
app = await self.sonarr.update()
self._disks = app.disks
self._total_free = sum([disk.free for disk in self._disks])
@property
def device_state_attributes(self) -> Optional[Dict[str, Any]]:
"""Return the state attributes of the entity."""
attrs = {}
for disk in self._disks:
free = self._to_unit(disk.free)
total = self._to_unit(disk.total)
usage = free / total * 100
attrs[
disk.path
] = f"{free:.2f}/{total:.2f}{self._unit_of_measurement} ({usage:.2f}%)"
return attrs
@property
def state(self) -> Union[None, str, int, float]:
"""Return the state of the sensor."""
free = self._to_unit(self._total_free)
return f"{free:.2f}"
class SonarrQueueSensor(SonarrSensor):
"""Defines a Sonarr Queue sensor."""
def __init__(self, sonarr: Sonarr, entry_id: str) -> None:
"""Initialize Sonarr Queue sensor."""
self._queue = []
super().__init__(
sonarr=sonarr,
entry_id=entry_id,
icon="mdi:download",
key="queue",
name=f"{sonarr.app.info.app_name} Queue",
unit_of_measurement="Episodes",
enabled_default=False,
)
@sonarr_exception_handler
async def async_update(self) -> None:
"""Update entity."""
self._queue = await self.sonarr.queue()
@property
def device_state_attributes(self) -> Optional[Dict[str, Any]]:
"""Return the state attributes of the entity."""
attrs = {}
for item in self._queue:
remaining = 1 if item.size == 0 else item.size_remaining / item.size
remaining_pct = 100 * (1 - remaining)
name = f"{item.episode.series.title} {item.episode.identifier}"
attrs[name] = f"{remaining_pct:.2f}%"
return attrs
@property
def state(self) -> Union[None, str, int, float]:
"""Return the state of the sensor."""
return len(self._queue)
class SonarrSeriesSensor(SonarrSensor):
"""Defines a Sonarr Series sensor."""
def __init__(self, sonarr: Sonarr, entry_id: str) -> None:
"""Initialize Sonarr Series sensor."""
self._items = []
super().__init__(
sonarr=sonarr,
entry_id=entry_id,
icon="mdi:television",
key="series",
name=f"{sonarr.app.info.app_name} Shows",
unit_of_measurement="Series",
enabled_default=False,
)
@sonarr_exception_handler
async def async_update(self) -> None:
"""Update entity."""
self._items = await self.sonarr.series()
@property
def device_state_attributes(self) -> Optional[Dict[str, Any]]:
"""Return the state attributes of the entity."""
attrs = {}
for item in self._items:
attrs[item.series.title] = f"{item.downloaded}/{item.episodes} Episodes"
return attrs
@property
def state(self) -> Union[None, str, int, float]:
"""Return the state of the sensor."""
return len(self._items)
class SonarrUpcomingSensor(SonarrSensor):
"""Defines a Sonarr Upcoming sensor."""
def __init__(self, sonarr: Sonarr, entry_id: str, days: int = 1) -> None:
"""Initialize Sonarr Upcoming sensor."""
self._days = days
self._upcoming = []
super().__init__(
sonarr=sonarr,
entry_id=entry_id,
icon="mdi:television",
key="upcoming",
name=f"{sonarr.app.info.app_name} Upcoming",
unit_of_measurement="Episodes",
)
async def async_added_to_hass(self):
"""Listen for signals."""
await super().async_added_to_hass()
self.async_on_remove(
async_dispatcher_connect(
self.hass,
f"sonarr.{self._entry_id}.entry_options_update",
self.async_update_entry_options,
)
)
@sonarr_exception_handler
async def async_update(self) -> None:
"""Update entity."""
local = dt_util.start_of_local_day().replace(microsecond=0)
start = dt_util.as_utc(local)
end = start + timedelta(days=self.days)
try:
res = requests.get(
ENDPOINTS[self.type].format(
self.ssl,
self.host,
self.port,
self.urlbase,
start.isoformat().replace("+00:00", "Z"),
end.isoformat().replace("+00:00", "Z"),
),
headers={"X-Api-Key": self.apikey},
timeout=10,
end = start + timedelta(days=self._days)
self._upcoming = await self.sonarr.calendar(
start=start.isoformat(), end=end.isoformat()
)
async def async_update_entry_options(self, options: dict) -> None:
"""Update sensor settings when config entry options are update."""
self._days = options[CONF_UPCOMING_DAYS]
@property
def device_state_attributes(self) -> Optional[Dict[str, Any]]:
"""Return the state attributes of the entity."""
attrs = {}
for episode in self._upcoming:
attrs[episode.series.title] = episode.identifier
return attrs
@property
def state(self) -> Union[None, str, int, float]:
"""Return the state of the sensor."""
return len(self._upcoming)
class SonarrWantedSensor(SonarrSensor):
"""Defines a Sonarr Wanted sensor."""
def __init__(self, sonarr: Sonarr, entry_id: str, max_items: int = 10) -> None:
"""Initialize Sonarr Wanted sensor."""
self._max_items = max_items
self._results = None
self._total = None
super().__init__(
sonarr=sonarr,
entry_id=entry_id,
icon="mdi:television",
key="wanted",
name=f"{sonarr.app.info.app_name} Wanted",
unit_of_measurement="Episodes",
enabled_default=False,
)
async def async_added_to_hass(self):
"""Listen for signals."""
await super().async_added_to_hass()
self.async_on_remove(
async_dispatcher_connect(
self.hass,
f"sonarr.{self._entry_id}.entry_options_update",
self.async_update_entry_options,
)
except OSError:
_LOGGER.warning("Host %s is not available", self.host)
self._available = False
self._state = None
return
)
if res.status_code == HTTP_OK:
if self.type in ["upcoming", "queue", "series", "commands"]:
self.data = res.json()
self._state = len(self.data)
elif self.type == "wanted":
data = res.json()
res = requests.get(
"{}?pageSize={}".format(
ENDPOINTS[self.type].format(
self.ssl, self.host, self.port, self.urlbase
),
data["totalRecords"],
),
headers={"X-Api-Key": self.apikey},
timeout=10,
)
self.data = res.json()["records"]
self._state = len(self.data)
elif self.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._state = "{:.2f}".format(
to_unit(sum([data["freeSpace"] for data in self.data]), self._unit)
)
elif self.type == "status":
self.data = res.json()
self._state = self.data["version"]
self._available = True
@sonarr_exception_handler
async def async_update(self) -> None:
"""Update entity."""
self._results = await self.sonarr.wanted(page_size=self._max_items)
self._total = self._results.total
async def async_update_entry_options(self, options: dict) -> None:
"""Update sensor settings when config entry options are update."""
self._max_items = options[CONF_WANTED_MAX_ITEMS]
def to_unit(value, unit):
"""Convert bytes to give unit."""
return value / 1024 ** BYTE_SIZES.index(unit)
@property
def device_state_attributes(self) -> Optional[Dict[str, Any]]:
"""Return the state attributes of the entity."""
attrs = {}
if self._results is not None:
for episode in self._results.episodes:
name = f"{episode.series.title} {episode.identifier}"
attrs[name] = episode.airdate
return attrs
@property
def state(self) -> Union[None, str, int, float]:
"""Return the state of the sensor."""
return self._total

View File

@ -0,0 +1,37 @@
{
"title": "Sonarr",
"config": {
"flow_title": "Sonarr: {name}",
"step": {
"user": {
"title": "Connect to Sonarr",
"data": {
"host": "[%key:common::config_flow::data::host%]",
"api_key": "[%key:common::config_flow::data::api_key%]",
"base_path": "Path to API",
"port": "[%key:common::config_flow::data::port%]",
"ssl": "Sonarr uses a SSL certificate",
"verify_ssl": "Sonarr uses a proper certificate"
}
}
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
}
},
"options": {
"step": {
"init": {
"data": {
"upcoming_days": "Number of upcoming days to display",
"wanted_max_items": "Max number of wanted items to display"
}
}
}
}
}

View File

@ -0,0 +1,37 @@
{
"config": {
"abort": {
"already_configured": "Sonarr is already configured",
"unknown": "Unexpected error"
},
"error": {
"cannot_connect": "Failed to connect, please try again",
"invalid_auth": "Invalid authentication"
},
"flow_title": "Sonarr: {name}",
"step": {
"user": {
"data": {
"api_key": "API Key",
"base_path": "Path to API",
"host": "Host or IP address",
"port": "Port",
"ssl": "Sonarr uses a SSL certificate",
"verify_ssl": "Sonarr uses a proper certificate"
},
"title": "Connect to Sonarr"
}
}
},
"options": {
"step": {
"init": {
"data": {
"upcoming_days": "Number of upcoming days to display",
"wanted_max_items": "Max number of wanted items to display"
}
}
}
},
"title": "Sonarr"
}

View File

@ -135,6 +135,7 @@ FLOWS = [
"solarlog",
"soma",
"somfy",
"sonarr",
"songpal",
"sonos",
"spotify",

View File

@ -1992,6 +1992,9 @@ somecomfort==0.5.2
# homeassistant.components.somfy_mylink
somfy-mylink-synergy==1.0.6
# homeassistant.components.sonarr
sonarr==0.2.1
# homeassistant.components.marytts
speak2mary==1.4.0

View File

@ -809,6 +809,9 @@ solaredge==0.0.2
# homeassistant.components.honeywell
somecomfort==0.5.2
# homeassistant.components.sonarr
sonarr==0.2.1
# homeassistant.components.marytts
speak2mary==1.4.0

View File

@ -1 +1,217 @@
"""Tests for the sonarr component."""
"""Tests for the Sonarr component."""
from socket import gaierror as SocketGIAError
from homeassistant.components.sonarr.const import (
CONF_BASE_PATH,
CONF_UPCOMING_DAYS,
CONF_WANTED_MAX_ITEMS,
DEFAULT_UPCOMING_DAYS,
DEFAULT_WANTED_MAX_ITEMS,
DOMAIN,
)
from homeassistant.const import (
CONF_API_KEY,
CONF_HOST,
CONF_PORT,
CONF_SSL,
CONF_VERIFY_SSL,
)
from homeassistant.helpers.typing import HomeAssistantType
from tests.common import MockConfigEntry, load_fixture
from tests.test_util.aiohttp import AiohttpClientMocker
HOST = "192.168.1.189"
PORT = 8989
BASE_PATH = "/api"
API_KEY = "MOCK_API_KEY"
MOCK_SENSOR_CONFIG = {
"platform": DOMAIN,
"host": HOST,
"api_key": API_KEY,
"days": 3,
}
MOCK_USER_INPUT = {
CONF_HOST: HOST,
CONF_PORT: PORT,
CONF_BASE_PATH: BASE_PATH,
CONF_SSL: False,
CONF_API_KEY: API_KEY,
}
def mock_connection(
aioclient_mock: AiohttpClientMocker,
host: str = HOST,
port: str = PORT,
base_path: str = BASE_PATH,
error: bool = False,
invalid_auth: bool = False,
server_error: bool = False,
) -> None:
"""Mock Sonarr connection."""
if error:
mock_connection_error(
aioclient_mock, host=host, port=port, base_path=base_path,
)
return
if invalid_auth:
mock_connection_invalid_auth(
aioclient_mock, host=host, port=port, base_path=base_path,
)
return
if server_error:
mock_connection_server_error(
aioclient_mock, host=host, port=port, base_path=base_path,
)
return
sonarr_url = f"http://{host}:{port}{base_path}"
aioclient_mock.get(
f"{sonarr_url}/system/status",
text=load_fixture(f"sonarr/system-status.json"),
headers={"Content-Type": "application/json"},
)
aioclient_mock.get(
f"{sonarr_url}/diskspace",
text=load_fixture(f"sonarr/diskspace.json"),
headers={"Content-Type": "application/json"},
)
aioclient_mock.get(
f"{sonarr_url}/calendar",
text=load_fixture(f"sonarr/calendar.json"),
headers={"Content-Type": "application/json"},
)
aioclient_mock.get(
f"{sonarr_url}/command",
text=load_fixture(f"sonarr/command.json"),
headers={"Content-Type": "application/json"},
)
aioclient_mock.get(
f"{sonarr_url}/queue",
text=load_fixture(f"sonarr/queue.json"),
headers={"Content-Type": "application/json"},
)
aioclient_mock.get(
f"{sonarr_url}/series",
text=load_fixture(f"sonarr/series.json"),
headers={"Content-Type": "application/json"},
)
aioclient_mock.get(
f"{sonarr_url}/wanted/missing",
text=load_fixture(f"sonarr/wanted-missing.json"),
headers={"Content-Type": "application/json"},
)
def mock_connection_error(
aioclient_mock: AiohttpClientMocker,
host: str = HOST,
port: str = PORT,
base_path: str = BASE_PATH,
) -> None:
"""Mock Sonarr connection errors."""
sonarr_url = f"http://{host}:{port}{base_path}"
aioclient_mock.get(f"{sonarr_url}/system/status", exc=SocketGIAError)
aioclient_mock.get(f"{sonarr_url}/diskspace", exc=SocketGIAError)
aioclient_mock.get(f"{sonarr_url}/calendar", exc=SocketGIAError)
aioclient_mock.get(f"{sonarr_url}/command", exc=SocketGIAError)
aioclient_mock.get(f"{sonarr_url}/queue", exc=SocketGIAError)
aioclient_mock.get(f"{sonarr_url}/series", exc=SocketGIAError)
aioclient_mock.get(f"{sonarr_url}/missing/wanted", exc=SocketGIAError)
def mock_connection_invalid_auth(
aioclient_mock: AiohttpClientMocker,
host: str = HOST,
port: str = PORT,
base_path: str = BASE_PATH,
) -> None:
"""Mock Sonarr invalid auth errors."""
sonarr_url = f"http://{host}:{port}{base_path}"
aioclient_mock.get(f"{sonarr_url}/system/status", status=403)
aioclient_mock.get(f"{sonarr_url}/diskspace", status=403)
aioclient_mock.get(f"{sonarr_url}/calendar", status=403)
aioclient_mock.get(f"{sonarr_url}/command", status=403)
aioclient_mock.get(f"{sonarr_url}/queue", status=403)
aioclient_mock.get(f"{sonarr_url}/series", status=403)
aioclient_mock.get(f"{sonarr_url}/missing/wanted", status=403)
def mock_connection_server_error(
aioclient_mock: AiohttpClientMocker,
host: str = HOST,
port: str = PORT,
base_path: str = BASE_PATH,
) -> None:
"""Mock Sonarr server errors."""
sonarr_url = f"http://{host}:{port}{base_path}"
aioclient_mock.get(f"{sonarr_url}/system/status", status=500)
aioclient_mock.get(f"{sonarr_url}/diskspace", status=500)
aioclient_mock.get(f"{sonarr_url}/calendar", status=500)
aioclient_mock.get(f"{sonarr_url}/command", status=500)
aioclient_mock.get(f"{sonarr_url}/queue", status=500)
aioclient_mock.get(f"{sonarr_url}/series", status=500)
aioclient_mock.get(f"{sonarr_url}/missing/wanted", status=500)
async def setup_integration(
hass: HomeAssistantType,
aioclient_mock: AiohttpClientMocker,
host: str = HOST,
port: str = PORT,
base_path: str = BASE_PATH,
api_key: str = API_KEY,
unique_id: str = None,
skip_entry_setup: bool = False,
connection_error: bool = False,
invalid_auth: bool = False,
server_error: bool = False,
) -> MockConfigEntry:
"""Set up the Sonarr integration in Home Assistant."""
entry = MockConfigEntry(
domain=DOMAIN,
unique_id=unique_id,
data={
CONF_HOST: host,
CONF_PORT: port,
CONF_BASE_PATH: base_path,
CONF_SSL: False,
CONF_VERIFY_SSL: False,
CONF_API_KEY: api_key,
CONF_UPCOMING_DAYS: DEFAULT_UPCOMING_DAYS,
CONF_WANTED_MAX_ITEMS: DEFAULT_WANTED_MAX_ITEMS,
},
)
entry.add_to_hass(hass)
mock_connection(
aioclient_mock,
host=host,
port=port,
base_path=base_path,
error=connection_error,
invalid_auth=invalid_auth,
server_error=server_error,
)
if not skip_entry_setup:
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
return entry

View File

@ -0,0 +1,183 @@
"""Test the Sonarr config flow."""
from homeassistant.components.sonarr.const import (
CONF_UPCOMING_DAYS,
CONF_WANTED_MAX_ITEMS,
DEFAULT_UPCOMING_DAYS,
DEFAULT_WANTED_MAX_ITEMS,
DOMAIN,
)
from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER
from homeassistant.const import CONF_HOST, CONF_SOURCE, CONF_VERIFY_SSL
from homeassistant.data_entry_flow import (
RESULT_TYPE_ABORT,
RESULT_TYPE_CREATE_ENTRY,
RESULT_TYPE_FORM,
)
from homeassistant.helpers.typing import HomeAssistantType
from tests.async_mock import patch
from tests.components.sonarr import (
HOST,
MOCK_USER_INPUT,
mock_connection,
mock_connection_error,
mock_connection_invalid_auth,
setup_integration,
)
from tests.test_util.aiohttp import AiohttpClientMocker
async def test_options(hass, aioclient_mock: AiohttpClientMocker):
"""Test updating options."""
entry = await setup_integration(hass, aioclient_mock)
assert entry.options[CONF_UPCOMING_DAYS] == DEFAULT_UPCOMING_DAYS
assert entry.options[CONF_WANTED_MAX_ITEMS] == DEFAULT_WANTED_MAX_ITEMS
result = await hass.config_entries.options.async_init(entry.entry_id)
assert result["type"] == RESULT_TYPE_FORM
assert result["step_id"] == "init"
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={CONF_UPCOMING_DAYS: 2, CONF_WANTED_MAX_ITEMS: 100},
)
assert result["type"] == RESULT_TYPE_CREATE_ENTRY
assert result["data"][CONF_UPCOMING_DAYS] == 2
assert result["data"][CONF_WANTED_MAX_ITEMS] == 100
async def test_show_user_form(hass: HomeAssistantType) -> 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"] == RESULT_TYPE_FORM
async def test_cannot_connect(
hass: HomeAssistantType, aioclient_mock: AiohttpClientMocker
) -> None:
"""Test we show user form on connection error."""
mock_connection_error(aioclient_mock)
user_input = MOCK_USER_INPUT.copy()
result = await hass.config_entries.flow.async_init(
DOMAIN, context={CONF_SOURCE: SOURCE_USER}, data=user_input,
)
assert result["type"] == RESULT_TYPE_FORM
assert result["step_id"] == "user"
assert result["errors"] == {"base": "cannot_connect"}
async def test_invalid_auth(
hass: HomeAssistantType, aioclient_mock: AiohttpClientMocker
) -> None:
"""Test we show user form on invalid auth."""
mock_connection_invalid_auth(aioclient_mock)
user_input = MOCK_USER_INPUT.copy()
result = await hass.config_entries.flow.async_init(
DOMAIN, context={CONF_SOURCE: SOURCE_USER}, data=user_input,
)
assert result["type"] == RESULT_TYPE_FORM
assert result["step_id"] == "user"
assert result["errors"] == {"base": "invalid_auth"}
async def test_unknown_error(
hass: HomeAssistantType, aioclient_mock: AiohttpClientMocker
) -> None:
"""Test we show user form on unknown error."""
user_input = MOCK_USER_INPUT.copy()
with patch(
"homeassistant.components.sonarr.config_flow.Sonarr.update",
side_effect=Exception,
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={CONF_SOURCE: SOURCE_USER}, data=user_input,
)
assert result["type"] == RESULT_TYPE_ABORT
assert result["reason"] == "unknown"
async def test_full_import_flow_implementation(
hass: HomeAssistantType, aioclient_mock: AiohttpClientMocker
) -> None:
"""Test the full manual user flow from start to finish."""
mock_connection(aioclient_mock)
user_input = MOCK_USER_INPUT.copy()
result = await hass.config_entries.flow.async_init(
DOMAIN, context={CONF_SOURCE: SOURCE_IMPORT}, data=user_input,
)
assert result["type"] == RESULT_TYPE_CREATE_ENTRY
assert result["title"] == HOST
assert result["data"]
assert result["data"][CONF_HOST] == HOST
assert result["result"]
assert result["result"].options[CONF_UPCOMING_DAYS] == DEFAULT_UPCOMING_DAYS
assert result["result"].options[CONF_WANTED_MAX_ITEMS] == DEFAULT_WANTED_MAX_ITEMS
async def test_full_user_flow_implementation(
hass: HomeAssistantType, 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"] == RESULT_TYPE_FORM
assert result["step_id"] == "user"
user_input = MOCK_USER_INPUT.copy()
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=user_input,
)
assert result["type"] == RESULT_TYPE_CREATE_ENTRY
assert result["title"] == HOST
assert result["data"]
assert result["data"][CONF_HOST] == HOST
async def test_full_user_flow_advanced_options(
hass: HomeAssistantType, aioclient_mock: AiohttpClientMocker
) -> None:
"""Test the full manual user flow with advanced options."""
mock_connection(aioclient_mock)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={CONF_SOURCE: SOURCE_USER, "show_advanced_options": True}
)
assert result["type"] == RESULT_TYPE_FORM
assert result["step_id"] == "user"
user_input = {
**MOCK_USER_INPUT,
CONF_VERIFY_SSL: True,
}
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=user_input,
)
assert result["type"] == RESULT_TYPE_CREATE_ENTRY
assert result["title"] == HOST
assert result["data"]
assert result["data"][CONF_HOST] == HOST
assert result["data"][CONF_VERIFY_SSL]

View File

@ -0,0 +1,36 @@
"""Tests for the Sonsrr integration."""
from homeassistant.components.sonarr.const import DOMAIN
from homeassistant.config_entries import (
ENTRY_STATE_LOADED,
ENTRY_STATE_NOT_LOADED,
ENTRY_STATE_SETUP_RETRY,
)
from homeassistant.core import HomeAssistant
from tests.components.sonarr import setup_integration
from tests.test_util.aiohttp import AiohttpClientMocker
async def test_config_entry_not_ready(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
) -> None:
"""Test the configuration entry not ready."""
entry = await setup_integration(hass, aioclient_mock, connection_error=True)
assert entry.state == ENTRY_STATE_SETUP_RETRY
async def test_unload_config_entry(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
) -> None:
"""Test the configuration entry unloading."""
entry = await setup_integration(hass, aioclient_mock)
assert hass.data[DOMAIN]
assert entry.entry_id in hass.data[DOMAIN]
assert entry.state == ENTRY_STATE_LOADED
await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
assert entry.entry_id not in hass.data[DOMAIN]
assert entry.state == ENTRY_STATE_NOT_LOADED

View File

@ -1,722 +1,203 @@
"""The tests for the Sonarr platform."""
from datetime import datetime
import time
import unittest
"""Tests for the Sonarr sensor platform."""
from datetime import timedelta
import pytest
import homeassistant.components.sonarr.sensor as sonarr
from homeassistant.const import DATA_GIGABYTES, UNIT_PERCENTAGE
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.components.sonarr.const import (
CONF_BASE_PATH,
CONF_UPCOMING_DAYS,
DOMAIN,
)
from homeassistant.const import (
ATTR_ICON,
ATTR_UNIT_OF_MEASUREMENT,
DATA_GIGABYTES,
STATE_UNAVAILABLE,
)
from homeassistant.helpers.typing import HomeAssistantType
from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util
from tests.async_mock import patch
from tests.common import get_test_home_assistant
from tests.common import async_fire_time_changed
from tests.components.sonarr import (
MOCK_SENSOR_CONFIG,
mock_connection,
setup_integration,
)
from tests.test_util.aiohttp import AiohttpClientMocker
UPCOMING_ENTITY_ID = f"{SENSOR_DOMAIN}.sonarr_upcoming"
def mocked_exception(*args, **kwargs):
"""Mock exception thrown by requests.get."""
raise OSError
async def test_import_from_sensor_component(
hass: HomeAssistantType, aioclient_mock: AiohttpClientMocker
) -> None:
"""Test import from sensor platform."""
mock_connection(aioclient_mock)
assert await async_setup_component(
hass, SENSOR_DOMAIN, {SENSOR_DOMAIN: MOCK_SENSOR_CONFIG}
)
await hass.async_block_till_done()
entries = hass.config_entries.async_entries(DOMAIN)
assert len(entries) == 1
assert entries[0].data[CONF_BASE_PATH] == "/api"
assert entries[0].options[CONF_UPCOMING_DAYS] == 3
assert hass.states.get(UPCOMING_ENTITY_ID)
def mocked_requests_get(*args, **kwargs):
"""Mock requests.get invocations."""
async def test_sensors(
hass: HomeAssistantType, aioclient_mock: AiohttpClientMocker
) -> None:
"""Test the creation and values of the sensors."""
entry = await setup_integration(hass, aioclient_mock, skip_entry_setup=True)
registry = await hass.helpers.entity_registry.async_get_registry()
class MockResponse:
"""Class to represent a mocked response."""
# Pre-create registry entries for disabled by default sensors
sensors = {
"commands": "sonarr_commands",
"diskspace": "sonarr_disk_space",
"queue": "sonarr_queue",
"series": "sonarr_shows",
"wanted": "sonarr_wanted",
}
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
today = datetime.date(datetime.fromtimestamp(time.time()))
url = str(args[0])
if "api/calendar" in url:
return MockResponse(
[
{
"seriesId": 3,
"episodeFileId": 0,
"seasonNumber": 4,
"episodeNumber": 11,
"title": "Easy Com-mercial, Easy Go-mercial",
"airDate": str(today),
"airDateUtc": "2014-01-27T01:30:00Z",
"overview": "To compete with fellow “restaurateur,” Ji...",
"hasFile": "false",
"monitored": "true",
"sceneEpisodeNumber": 0,
"sceneSeasonNumber": 0,
"tvDbEpisodeId": 0,
"series": {
"tvdbId": 194031,
"tvRageId": 24607,
"imdbId": "tt1561755",
"title": "Bob's Burgers",
"cleanTitle": "bobsburgers",
"status": "continuing",
"overview": "Bob's Burgers follows a third-generation ...",
"airTime": "5:30pm",
"monitored": "true",
"qualityProfileId": 1,
"seasonFolder": "true",
"lastInfoSync": "2014-01-26T19:25:55.4555946Z",
"runtime": 30,
"images": [
{
"coverType": "banner",
"url": "http://slurm.trakt.us/images/bann.jpg",
},
{
"coverType": "poster",
"url": "http://slurm.trakt.us/images/poster00.jpg",
},
{
"coverType": "fanart",
"url": "http://slurm.trakt.us/images/fan6.jpg",
},
],
"seriesType": "standard",
"network": "FOX",
"useSceneNumbering": "false",
"titleSlug": "bobs-burgers",
"path": "T:\\Bob's Burgers",
"year": 0,
"firstAired": "2011-01-10T01:30:00Z",
"qualityProfile": {
"value": {
"name": "SD",
"allowed": [
{"id": 1, "name": "SDTV", "weight": 1},
{"id": 8, "name": "WEBDL-480p", "weight": 2},
{"id": 2, "name": "DVD", "weight": 3},
],
"cutoff": {"id": 1, "name": "SDTV", "weight": 1},
"id": 1,
},
"isLoaded": "true",
},
"seasons": [
{"seasonNumber": 4, "monitored": "true"},
{"seasonNumber": 3, "monitored": "true"},
{"seasonNumber": 2, "monitored": "true"},
{"seasonNumber": 1, "monitored": "true"},
{"seasonNumber": 0, "monitored": "false"},
],
"id": 66,
},
"downloading": "false",
"id": 14402,
}
],
200,
for (unique, oid) in sensors.items():
registry.async_get_or_create(
SENSOR_DOMAIN,
DOMAIN,
f"{entry.entry_id}_{unique}",
suggested_object_id=oid,
disabled_by=None,
)
if "api/command" in url:
return MockResponse(
[
{
"name": "RescanSeries",
"startedOn": "0001-01-01T00:00:00Z",
"stateChangeTime": "2014-02-05T05:09:09.2366139Z",
"sendUpdatesToClient": "true",
"state": "pending",
"id": 24,
}
],
200,
)
if "api/wanted/missing" in url or "totalRecords" in url:
return MockResponse(
{
"page": 1,
"pageSize": 15,
"sortKey": "airDateUtc",
"sortDirection": "descending",
"totalRecords": 1,
"records": [
{
"seriesId": 1,
"episodeFileId": 0,
"seasonNumber": 5,
"episodeNumber": 4,
"title": "Archer Vice: House Call",
"airDate": "2014-02-03",
"airDateUtc": "2014-02-04T03:00:00Z",
"overview": "Archer has to stage an that ... ",
"hasFile": "false",
"monitored": "true",
"sceneEpisodeNumber": 0,
"sceneSeasonNumber": 0,
"tvDbEpisodeId": 0,
"absoluteEpisodeNumber": 50,
"series": {
"tvdbId": 110381,
"tvRageId": 23354,
"imdbId": "tt1486217",
"title": "Archer (2009)",
"cleanTitle": "archer2009",
"status": "continuing",
"overview": "At ISIS, an international spy ...",
"airTime": "7:00pm",
"monitored": "true",
"qualityProfileId": 1,
"seasonFolder": "true",
"lastInfoSync": "2014-02-05T04:39:28.550495Z",
"runtime": 30,
"images": [
{
"coverType": "banner",
"url": "http://slurm.trakt.us//57.12.jpg",
},
{
"coverType": "poster",
"url": "http://slurm.trakt.u/57.12-300.jpg",
},
{
"coverType": "fanart",
"url": "http://slurm.trakt.us/image.12.jpg",
},
],
"seriesType": "standard",
"network": "FX",
"useSceneNumbering": "false",
"titleSlug": "archer-2009",
"path": "E:\\Test\\TV\\Archer (2009)",
"year": 2009,
"firstAired": "2009-09-18T02:00:00Z",
"qualityProfile": {
"value": {
"name": "SD",
"cutoff": {"id": 1, "name": "SDTV"},
"items": [
{
"quality": {"id": 1, "name": "SDTV"},
"allowed": "true",
},
{
"quality": {"id": 8, "name": "WEBDL-480p"},
"allowed": "true",
},
{
"quality": {"id": 2, "name": "DVD"},
"allowed": "true",
},
{
"quality": {"id": 4, "name": "HDTV-720p"},
"allowed": "false",
},
{
"quality": {"id": 9, "name": "HDTV-1080p"},
"allowed": "false",
},
{
"quality": {"id": 10, "name": "Raw-HD"},
"allowed": "false",
},
{
"quality": {"id": 5, "name": "WEBDL-720p"},
"allowed": "false",
},
{
"quality": {"id": 6, "name": "Bluray-720p"},
"allowed": "false",
},
{
"quality": {"id": 3, "name": "WEBDL-1080p"},
"allowed": "false",
},
{
"quality": {
"id": 7,
"name": "Bluray-1080p",
},
"allowed": "false",
},
],
"id": 1,
},
"isLoaded": "true",
},
"seasons": [
{"seasonNumber": 5, "monitored": "true"},
{"seasonNumber": 4, "monitored": "true"},
{"seasonNumber": 3, "monitored": "true"},
{"seasonNumber": 2, "monitored": "true"},
{"seasonNumber": 1, "monitored": "true"},
{"seasonNumber": 0, "monitored": "false"},
],
"id": 1,
},
"downloading": "false",
"id": 55,
}
],
},
200,
)
if "api/queue" in url:
return MockResponse(
[
{
"series": {
"title": "Game of Thrones",
"sortTitle": "game thrones",
"seasonCount": 6,
"status": "continuing",
"overview": "Seven noble families fight for land ...",
"network": "HBO",
"airTime": "21:00",
"images": [
{
"coverType": "fanart",
"url": "http://thetvdb.com/banners/fanart/-83.jpg",
},
{
"coverType": "banner",
"url": "http://thetvdb.com/banners/-g19.jpg",
},
{
"coverType": "poster",
"url": "http://thetvdb.com/banners/posters-34.jpg",
},
],
"seasons": [
{"seasonNumber": 0, "monitored": "false"},
{"seasonNumber": 1, "monitored": "false"},
{"seasonNumber": 2, "monitored": "true"},
{"seasonNumber": 3, "monitored": "false"},
{"seasonNumber": 4, "monitored": "false"},
{"seasonNumber": 5, "monitored": "true"},
{"seasonNumber": 6, "monitored": "true"},
],
"year": 2011,
"path": "/Volumes/Media/Shows/Game of Thrones",
"profileId": 5,
"seasonFolder": "true",
"monitored": "true",
"useSceneNumbering": "false",
"runtime": 60,
"tvdbId": 121361,
"tvRageId": 24493,
"tvMazeId": 82,
"firstAired": "2011-04-16T23:00:00Z",
"lastInfoSync": "2016-02-05T16:40:11.614176Z",
"seriesType": "standard",
"cleanTitle": "gamethrones",
"imdbId": "tt0944947",
"titleSlug": "game-of-thrones",
"certification": "TV-MA",
"genres": ["Adventure", "Drama", "Fantasy"],
"tags": [],
"added": "2015-12-28T13:44:24.204583Z",
"ratings": {"votes": 1128, "value": 9.4},
"qualityProfileId": 5,
"id": 17,
},
"episode": {
"seriesId": 17,
"episodeFileId": 0,
"seasonNumber": 3,
"episodeNumber": 8,
"title": "Second Sons",
"airDate": "2013-05-19",
"airDateUtc": "2013-05-20T01:00:00Z",
"overview": "Kings Landing hosts a wedding, and ...",
"hasFile": "false",
"monitored": "false",
"absoluteEpisodeNumber": 28,
"unverifiedSceneNumbering": "false",
"id": 889,
},
"quality": {
"quality": {"id": 7, "name": "Bluray-1080p"},
"revision": {"version": 1, "real": 0},
},
"size": 4472186820,
"title": "Game.of.Thrones.S03E08.Second.Sons.2013.1080p.",
"sizeleft": 0,
"timeleft": "00:00:00",
"estimatedCompletionTime": "2016-02-05T22:46:52.440104Z",
"status": "Downloading",
"trackedDownloadStatus": "Ok",
"statusMessages": [],
"downloadId": "SABnzbd_nzo_Mq2f_b",
"protocol": "usenet",
"id": 1503378561,
}
],
200,
)
if "api/series" in url:
return MockResponse(
[
{
"title": "Marvel's Daredevil",
"alternateTitles": [{"title": "Daredevil", "seasonNumber": -1}],
"sortTitle": "marvels daredevil",
"seasonCount": 2,
"totalEpisodeCount": 26,
"episodeCount": 26,
"episodeFileCount": 26,
"sizeOnDisk": 79282273693,
"status": "continuing",
"overview": "Matt Murdock was blinded in a tragic accident...",
"previousAiring": "2016-03-18T04:01:00Z",
"network": "Netflix",
"airTime": "00:01",
"images": [
{
"coverType": "fanart",
"url": "/sonarr/MediaCover/7/fanart.jpg?lastWrite=",
},
{
"coverType": "banner",
"url": "/sonarr/MediaCover/7/banner.jpg?lastWrite=",
},
{
"coverType": "poster",
"url": "/sonarr/MediaCover/7/poster.jpg?lastWrite=",
},
],
"seasons": [
{
"seasonNumber": 1,
"monitored": "false",
"statistics": {
"previousAiring": "2015-04-10T04:01:00Z",
"episodeFileCount": 13,
"episodeCount": 13,
"totalEpisodeCount": 13,
"sizeOnDisk": 22738179333,
"percentOfEpisodes": 100,
},
},
{
"seasonNumber": 2,
"monitored": "false",
"statistics": {
"previousAiring": "2016-03-18T04:01:00Z",
"episodeFileCount": 13,
"episodeCount": 13,
"totalEpisodeCount": 13,
"sizeOnDisk": 56544094360,
"percentOfEpisodes": 100,
},
},
],
"year": 2015,
"path": "F:\\TV_Shows\\Marvels Daredevil",
"profileId": 6,
"seasonFolder": "true",
"monitored": "true",
"useSceneNumbering": "false",
"runtime": 55,
"tvdbId": 281662,
"tvRageId": 38796,
"tvMazeId": 1369,
"firstAired": "2015-04-10T04:00:00Z",
"lastInfoSync": "2016-09-09T09:02:49.4402575Z",
"seriesType": "standard",
"cleanTitle": "marvelsdaredevil",
"imdbId": "tt3322312",
"titleSlug": "marvels-daredevil",
"certification": "TV-MA",
"genres": ["Action", "Crime", "Drama"],
"tags": [],
"added": "2015-05-15T00:20:32.7892744Z",
"ratings": {"votes": 461, "value": 8.9},
"qualityProfileId": 6,
"id": 7,
}
],
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": "2.0.0.1121",
"buildTime": "2014-02-08T20:49:36.5560392Z",
"isDebug": "false",
"isProduction": "true",
"isAdmin": "true",
"isUserInteractive": "false",
"startupPath": "C:\\ProgramData\\NzbDrone\\bin",
"appData": "C:\\ProgramData\\NzbDrone",
"osVersion": "6.2.9200.0",
"isMono": "false",
"isLinux": "false",
"isWindows": "true",
"branch": "develop",
"authentication": "false",
"startOfWeek": 0,
"urlBase": "",
},
200,
)
return MockResponse({"error": "Unauthorized"}, 401)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
for (unique, oid) in sensors.items():
entity = registry.async_get(f"sensor.{oid}")
assert entity
assert entity.unique_id == f"{entry.entry_id}_{unique}"
state = hass.states.get("sensor.sonarr_commands")
assert state
assert state.attributes.get(ATTR_ICON) == "mdi:code-braces"
assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "Commands"
assert state.state == "2"
state = hass.states.get("sensor.sonarr_disk_space")
assert state
assert state.attributes.get(ATTR_ICON) == "mdi:harddisk"
assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == DATA_GIGABYTES
assert state.state == "263.10"
state = hass.states.get("sensor.sonarr_queue")
assert state
assert state.attributes.get(ATTR_ICON) == "mdi:download"
assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "Episodes"
assert state.state == "1"
state = hass.states.get("sensor.sonarr_shows")
assert state
assert state.attributes.get(ATTR_ICON) == "mdi:television"
assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "Series"
assert state.state == "1"
state = hass.states.get("sensor.sonarr_upcoming")
assert state
assert state.attributes.get(ATTR_ICON) == "mdi:television"
assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "Episodes"
assert state.state == "1"
state = hass.states.get("sensor.sonarr_wanted")
assert state
assert state.attributes.get(ATTR_ICON) == "mdi:television"
assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "Episodes"
assert state.state == "2"
class TestSonarrSetup(unittest.TestCase):
"""Test the Sonarr platform."""
@pytest.mark.parametrize(
"entity_id",
(
"sensor.sonarr_commands",
"sensor.sonarr_disk_space",
"sensor.sonarr_queue",
"sensor.sonarr_shows",
"sensor.sonarr_wanted",
),
)
async def test_disabled_by_default_sensors(
hass: HomeAssistantType, aioclient_mock: AiohttpClientMocker, entity_id: str
) -> None:
"""Test the disabled by default sensors."""
await setup_integration(hass, aioclient_mock)
registry = await hass.helpers.entity_registry.async_get_registry()
print(registry.entities)
# pylint: disable=invalid-name
DEVICES = []
state = hass.states.get(entity_id)
assert state is None
def add_entities(self, devices, update):
"""Mock add devices."""
for device in devices:
self.DEVICES.append(device)
entry = registry.async_get(entity_id)
assert entry
assert entry.disabled
assert entry.disabled_by == "integration"
def setUp(self):
"""Initialize values for this testcase class."""
self.DEVICES = []
self.hass = get_test_home_assistant()
self.hass.config.time_zone = "America/Los_Angeles"
def tearDown(self): # pylint: disable=invalid-name
"""Stop everything that was started."""
self.hass.stop()
async def test_availability(
hass: HomeAssistantType, aioclient_mock: AiohttpClientMocker
) -> None:
"""Test entity availability."""
now = dt_util.utcnow()
@patch("requests.get", side_effect=mocked_requests_get)
def test_diskspace_no_paths(self, req_mock):
"""Test getting all disk space."""
config = {
"platform": "sonarr",
"api_key": "foo",
"days": "2",
"unit": DATA_GIGABYTES,
"include_paths": [],
"monitored_conditions": ["diskspace"],
}
sonarr.setup_platform(self.hass, config, self.add_entities, None)
for device in self.DEVICES:
device.update()
assert "263.10" == device.state
assert "mdi:harddisk" == device.icon
assert DATA_GIGABYTES == device.unit_of_measurement
assert "Sonarr Disk Space" == device.name
assert "263.10/465.42GB (56.53%)" == device.device_state_attributes["/data"]
with patch("homeassistant.util.dt.utcnow", return_value=now):
await setup_integration(hass, aioclient_mock)
@patch("requests.get", side_effect=mocked_requests_get)
def test_diskspace_paths(self, req_mock):
"""Test getting diskspace for included paths."""
config = {
"platform": "sonarr",
"api_key": "foo",
"days": "2",
"unit": DATA_GIGABYTES,
"include_paths": ["/data"],
"monitored_conditions": ["diskspace"],
}
sonarr.setup_platform(self.hass, config, self.add_entities, None)
for device in self.DEVICES:
device.update()
assert "263.10" == device.state
assert "mdi:harddisk" == device.icon
assert DATA_GIGABYTES == device.unit_of_measurement
assert "Sonarr Disk Space" == device.name
assert "263.10/465.42GB (56.53%)" == device.device_state_attributes["/data"]
assert hass.states.get(UPCOMING_ENTITY_ID).state == "1"
@patch("requests.get", side_effect=mocked_requests_get)
def test_commands(self, req_mock):
"""Test getting running commands."""
config = {
"platform": "sonarr",
"api_key": "foo",
"days": "2",
"unit": DATA_GIGABYTES,
"include_paths": ["/data"],
"monitored_conditions": ["commands"],
}
sonarr.setup_platform(self.hass, config, self.add_entities, None)
for device in self.DEVICES:
device.update()
assert 1 == device.state
assert "mdi:code-braces" == device.icon
assert "Commands" == device.unit_of_measurement
assert "Sonarr Commands" == device.name
assert "pending" == device.device_state_attributes["RescanSeries"]
# state to unavailable
aioclient_mock.clear_requests()
mock_connection(aioclient_mock, error=True)
@patch("requests.get", side_effect=mocked_requests_get)
def test_queue(self, req_mock):
"""Test getting downloads in the queue."""
config = {
"platform": "sonarr",
"api_key": "foo",
"days": "2",
"unit": DATA_GIGABYTES,
"include_paths": ["/data"],
"monitored_conditions": ["queue"],
}
sonarr.setup_platform(self.hass, config, self.add_entities, None)
for device in self.DEVICES:
device.update()
assert 1 == device.state
assert "mdi:download" == device.icon
assert "Episodes" == device.unit_of_measurement
assert "Sonarr Queue" == device.name
assert (
f"100.00{UNIT_PERCENTAGE}"
== device.device_state_attributes["Game of Thrones S03E08"]
)
future = now + timedelta(minutes=1)
with patch("homeassistant.util.dt.utcnow", return_value=future):
async_fire_time_changed(hass, future)
await hass.async_block_till_done()
@patch("requests.get", side_effect=mocked_requests_get)
def test_series(self, req_mock):
"""Test getting the number of series."""
config = {
"platform": "sonarr",
"api_key": "foo",
"days": "2",
"unit": DATA_GIGABYTES,
"include_paths": ["/data"],
"monitored_conditions": ["series"],
}
sonarr.setup_platform(self.hass, config, self.add_entities, None)
for device in self.DEVICES:
device.update()
assert 1 == device.state
assert "mdi:television" == device.icon
assert "Shows" == device.unit_of_measurement
assert "Sonarr Series" == device.name
assert (
"26/26 Episodes" == device.device_state_attributes["Marvel's Daredevil"]
)
assert hass.states.get(UPCOMING_ENTITY_ID).state == STATE_UNAVAILABLE
@patch("requests.get", side_effect=mocked_requests_get)
def test_wanted(self, req_mock):
"""Test getting wanted episodes."""
config = {
"platform": "sonarr",
"api_key": "foo",
"days": "2",
"unit": DATA_GIGABYTES,
"include_paths": ["/data"],
"monitored_conditions": ["wanted"],
}
sonarr.setup_platform(self.hass, config, self.add_entities, None)
for device in self.DEVICES:
device.update()
assert 1 == device.state
assert "mdi:television" == device.icon
assert "Episodes" == device.unit_of_measurement
assert "Sonarr Wanted" == device.name
assert (
"2014-02-03" == device.device_state_attributes["Archer (2009) S05E04"]
)
# state to available
aioclient_mock.clear_requests()
mock_connection(aioclient_mock)
@patch("requests.get", side_effect=mocked_requests_get)
def test_upcoming_multiple_days(self, req_mock):
"""Test the upcoming episodes for multiple days."""
config = {
"platform": "sonarr",
"api_key": "foo",
"days": "2",
"unit": DATA_GIGABYTES,
"include_paths": ["/data"],
"monitored_conditions": ["upcoming"],
}
sonarr.setup_platform(self.hass, config, self.add_entities, None)
for device in self.DEVICES:
device.update()
assert 1 == device.state
assert "mdi:television" == device.icon
assert "Episodes" == device.unit_of_measurement
assert "Sonarr Upcoming" == device.name
assert "S04E11" == device.device_state_attributes["Bob's Burgers"]
future += timedelta(minutes=1)
with patch("homeassistant.util.dt.utcnow", return_value=future):
async_fire_time_changed(hass, future)
await hass.async_block_till_done()
@pytest.mark.skip
@patch("requests.get", side_effect=mocked_requests_get)
def test_upcoming_today(self, req_mock):
"""Test filtering for a single day.
assert hass.states.get(UPCOMING_ENTITY_ID).state == "1"
Sonarr needs to respond with at least 2 days
"""
config = {
"platform": "sonarr",
"api_key": "foo",
"days": "1",
"unit": DATA_GIGABYTES,
"include_paths": ["/data"],
"monitored_conditions": ["upcoming"],
}
sonarr.setup_platform(self.hass, config, self.add_entities, None)
for device in self.DEVICES:
device.update()
assert 1 == device.state
assert "mdi:television" == device.icon
assert "Episodes" == device.unit_of_measurement
assert "Sonarr Upcoming" == device.name
assert "S04E11" == device.device_state_attributes["Bob's Burgers"]
# state to unavailable
aioclient_mock.clear_requests()
mock_connection(aioclient_mock, invalid_auth=True)
@patch("requests.get", side_effect=mocked_requests_get)
def test_system_status(self, req_mock):
"""Test getting system status."""
config = {
"platform": "sonarr",
"api_key": "foo",
"days": "2",
"unit": DATA_GIGABYTES,
"include_paths": ["/data"],
"monitored_conditions": ["status"],
}
sonarr.setup_platform(self.hass, config, self.add_entities, None)
for device in self.DEVICES:
device.update()
assert "2.0.0.1121" == device.state
assert "mdi:information" == device.icon
assert "Sonarr Status" == device.name
assert "6.2.9200.0" == device.device_state_attributes["osVersion"]
future += timedelta(minutes=1)
with patch("homeassistant.util.dt.utcnow", return_value=future):
async_fire_time_changed(hass, future)
await hass.async_block_till_done()
@pytest.mark.skip
@patch("requests.get", side_effect=mocked_requests_get)
def test_ssl(self, req_mock):
"""Test SSL being enabled."""
config = {
"platform": "sonarr",
"api_key": "foo",
"days": "1",
"unit": DATA_GIGABYTES,
"include_paths": ["/data"],
"monitored_conditions": ["upcoming"],
"ssl": "true",
}
sonarr.setup_platform(self.hass, config, self.add_entities, None)
for device in self.DEVICES:
device.update()
assert 1 == device.state
assert "s" == device.ssl
assert "mdi:television" == device.icon
assert "Episodes" == device.unit_of_measurement
assert "Sonarr Upcoming" == device.name
assert "S04E11" == device.device_state_attributes["Bob's Burgers"]
assert hass.states.get(UPCOMING_ENTITY_ID).state == STATE_UNAVAILABLE
@patch("requests.get", side_effect=mocked_exception)
def test_exception_handling(self, req_mock):
"""Test exception being handled."""
config = {
"platform": "sonarr",
"api_key": "foo",
"days": "1",
"unit": DATA_GIGABYTES,
"include_paths": ["/data"],
"monitored_conditions": ["upcoming"],
}
sonarr.setup_platform(self.hass, config, self.add_entities, None)
for device in self.DEVICES:
device.update()
assert device.state is None
# state to available
aioclient_mock.clear_requests()
mock_connection(aioclient_mock)
future += timedelta(minutes=1)
with patch("homeassistant.util.dt.utcnow", return_value=future):
async_fire_time_changed(hass, future)
await hass.async_block_till_done()
assert hass.states.get(UPCOMING_ENTITY_ID).state == "1"

116
tests/fixtures/sonarr/calendar.json vendored Normal file
View File

@ -0,0 +1,116 @@
[
{
"seriesId": 3,
"episodeFileId": 0,
"seasonNumber": 4,
"episodeNumber": 11,
"title": "Easy Com-mercial, Easy Go-mercial",
"airDate": "2014-01-26",
"airDateUtc": "2014-01-27T01:30:00Z",
"overview": "To compete with fellow \"restaurateur,\" Jimmy Pesto, and his blowout Super Bowl event, Bob is determined to create a Bob's Burgers commercial to air during the \"big game.\" In an effort to outshine Pesto, the Belchers recruit Randy, a documentarian, to assist with the filmmaking and hire on former pro football star Connie Frye to be the celebrity endorser.",
"hasFile": false,
"monitored": true,
"sceneEpisodeNumber": 0,
"sceneSeasonNumber": 0,
"tvDbEpisodeId": 0,
"series": {
"tvdbId": 194031,
"tvRageId": 24607,
"imdbId": "tt1561755",
"title": "Bob's Burgers",
"sortTitle": "bob burgers",
"cleanTitle": "bobsburgers",
"seasonCount": 4,
"status": "continuing",
"overview": "Bob's Burgers follows a third-generation restaurateur, Bob, as he runs Bob's Burgers with the help of his wife and their three kids. Bob and his quirky family have big ideas about burgers, but fall short on service and sophistication. Despite the greasy counters, lousy location and a dearth of customers, Bob and his family are determined to make Bob's Burgers \"grand re-re-re-opening\" a success.",
"airTime": "17:30",
"monitored": true,
"qualityProfileId": 1,
"seasonFolder": true,
"lastInfoSync": "2014-01-26T19:25:55.455594Z",
"runtime": 30,
"images": [
{
"coverType": "banner",
"url": "http://slurm.trakt.us/images/banners/1387.6.jpg"
},
{
"coverType": "poster",
"url": "http://slurm.trakt.us/images/posters/1387.6-300.jpg"
},
{
"coverType": "fanart",
"url": "http://slurm.trakt.us/images/fanart/1387.6.jpg"
}
],
"seriesType": "standard",
"network": "FOX",
"useSceneNumbering": false,
"titleSlug": "bobs-burgers",
"certification": "TV-14",
"path": "T:\\Bob's Burgers",
"year": 2011,
"firstAired": "2011-01-10T01:30:00Z",
"genres": [
"Animation",
"Comedy"
],
"tags": [],
"added": "2011-01-26T19:25:55.455594Z",
"qualityProfile": {
"value": {
"name": "SD",
"allowed": [
{
"id": 1,
"name": "SDTV",
"weight": 1
},
{
"id": 8,
"name": "WEBDL-480p",
"weight": 2
},
{
"id": 2,
"name": "DVD",
"weight": 3
}
],
"cutoff": {
"id": 1,
"name": "SDTV",
"weight": 1
},
"id": 1
},
"isLoaded": true
},
"seasons": [
{
"seasonNumber": 4,
"monitored": true
},
{
"seasonNumber": 3,
"monitored": true
},
{
"seasonNumber": 2,
"monitored": true
},
{
"seasonNumber": 1,
"monitored": true
},
{
"seasonNumber": 0,
"monitored": false
}
],
"id": 66
},
"downloading": false,
"id": 14402
}
]

36
tests/fixtures/sonarr/command.json vendored Normal file
View File

@ -0,0 +1,36 @@
[
{
"name": "RefreshSeries",
"body": {
"isNewSeries": false,
"sendUpdatesToClient": true,
"updateScheduledTask": true,
"completionMessage": "Completed",
"requiresDiskAccess": false,
"isExclusive": false,
"name": "RefreshSeries",
"trigger": "manual",
"suppressMessages": false
},
"priority": "normal",
"status": "started",
"queued": "2020-04-06T16:54:06.41945Z",
"started": "2020-04-06T16:54:06.421322Z",
"trigger": "manual",
"state": "started",
"manual": true,
"startedOn": "2020-04-06T16:54:06.41945Z",
"stateChangeTime": "2020-04-06T16:54:06.421322Z",
"sendUpdatesToClient": true,
"updateScheduledTask": true,
"id": 368621
},
{
"name": "RefreshSeries",
"state": "started",
"startedOn": "2020-04-06T16:57:51.406504Z",
"stateChangeTime": "2020-04-06T16:57:51.417931Z",
"sendUpdatesToClient": true,
"id": 368629
}
]

8
tests/fixtures/sonarr/diskspace.json vendored Normal file
View File

@ -0,0 +1,8 @@
[
{
"path": "C:\\",
"label": "",
"freeSpace": 282500067328,
"totalSpace": 499738734592
}
]

129
tests/fixtures/sonarr/queue.json vendored Normal file
View File

@ -0,0 +1,129 @@
[
{
"series": {
"title": "The Andy Griffith Show",
"sortTitle": "andy griffith show",
"seasonCount": 8,
"status": "ended",
"overview": "Down-home humor and an endearing cast of characters helped make The Andy Griffith Show one of the most beloved comedies in the history of TV. The show centered around widower Andy Taylor, who divided his time between raising his young son Opie, and his job as sheriff of the sleepy North Carolina town, Mayberry. Andy and Opie live with Andy's Aunt Bee, who serves as a surrogate mother to both father and son. Andy's nervous cousin, Barney Fife, is his deputy sheriff whose incompetence is tolerated because Mayberry is virtually crime-free.",
"network": "CBS",
"airTime": "21:30",
"images": [
{
"coverType": "fanart",
"url": "https://artworks.thetvdb.com/banners/fanart/original/77754-5.jpg"
},
{
"coverType": "banner",
"url": "https://artworks.thetvdb.com/banners/graphical/77754-g.jpg"
},
{
"coverType": "poster",
"url": "https://artworks.thetvdb.com/banners/posters/77754-4.jpg"
}
],
"seasons": [
{
"seasonNumber": 0,
"monitored": false
},
{
"seasonNumber": 1,
"monitored": false
},
{
"seasonNumber": 2,
"monitored": true
},
{
"seasonNumber": 3,
"monitored": false
},
{
"seasonNumber": 4,
"monitored": false
},
{
"seasonNumber": 5,
"monitored": true
},
{
"seasonNumber": 6,
"monitored": true
},
{
"seasonNumber": 7,
"monitored": true
},
{
"seasonNumber": 8,
"monitored": true
}
],
"year": 1960,
"path": "F:\\The Andy Griffith Show",
"profileId": 5,
"seasonFolder": true,
"monitored": true,
"useSceneNumbering": false,
"runtime": 25,
"tvdbId": 77754,
"tvRageId": 5574,
"tvMazeId": 3853,
"firstAired": "1960-02-15T06:00:00Z",
"lastInfoSync": "2016-02-05T16:40:11.614176Z",
"seriesType": "standard",
"cleanTitle": "theandygriffithshow",
"imdbId": "",
"titleSlug": "the-andy-griffith-show",
"certification": "TV-G",
"genres": [
"Comedy"
],
"tags": [],
"added": "2008-02-04T13:44:24.204583Z",
"ratings": {
"votes": 547,
"value": 8.6
},
"qualityProfileId": 5,
"id": 17
},
"episode": {
"seriesId": 17,
"episodeFileId": 0,
"seasonNumber": 1,
"episodeNumber": 1,
"title": "The New Housekeeper",
"airDate": "1960-10-03",
"airDateUtc": "1960-10-03T01:00:00Z",
"overview": "Sheriff Andy Taylor and his young son Opie are in need of a new housekeeper. Andy's Aunt Bee looks like the perfect candidate and moves in, but her presence causes friction with Opie.",
"hasFile": false,
"monitored": false,
"absoluteEpisodeNumber": 1,
"unverifiedSceneNumbering": false,
"id": 889
},
"quality": {
"quality": {
"id": 7,
"name": "SD"
},
"revision": {
"version": 1,
"real": 0
}
},
"size": 4472186820,
"title": "The.Andy.Griffith.Show.S01E01.x264-GROUP",
"sizeleft": 0,
"timeleft": "00:00:00",
"estimatedCompletionTime": "2016-02-05T22:46:52.440104Z",
"status": "Downloading",
"trackedDownloadStatus": "Ok",
"statusMessages": [],
"downloadId": "SABnzbd_nzo_Mq2f_b",
"protocol": "usenet",
"id": 1503378561
}
]

163
tests/fixtures/sonarr/series.json vendored Normal file
View File

@ -0,0 +1,163 @@
[
{
"title": "The Andy Griffith Show",
"alternateTitles": [],
"sortTitle": "andy griffith show",
"seasonCount": 8,
"totalEpisodeCount": 253,
"episodeCount": 0,
"episodeFileCount": 0,
"sizeOnDisk": 0,
"status": "ended",
"overview": "Down-home humor and an endearing cast of characters helped make The Andy Griffith Show one of the most beloved comedies in the history of TV. The show centered around widower Andy Taylor, who divided his time between raising his young son Opie, and his job as sheriff of the sleepy North Carolina town, Mayberry. Andy and Opie live with Andy's Aunt Bee, who serves as a surrogate mother to both father and son. Andy's nervous cousin, Barney Fife, is his deputy sheriff whose incompetence is tolerated because Mayberry is virtually crime-free.",
"network": "CBS",
"airTime": "21:30",
"images": [
{
"coverType": "fanart",
"url": "/MediaCover/105/fanart.jpg?lastWrite=637217160281262470",
"remoteUrl": "https://artworks.thetvdb.com/banners/fanart/original/77754-5.jpg"
},
{
"coverType": "banner",
"url": "/MediaCover/105/banner.jpg?lastWrite=637217160301222320",
"remoteUrl": "https://artworks.thetvdb.com/banners/graphical/77754-g.jpg"
},
{
"coverType": "poster",
"url": "/MediaCover/105/poster.jpg?lastWrite=637217160322182160",
"remoteUrl": "https://artworks.thetvdb.com/banners/posters/77754-1.jpg"
}
],
"seasons": [
{
"seasonNumber": 0,
"monitored": false,
"statistics": {
"episodeFileCount": 0,
"episodeCount": 0,
"totalEpisodeCount": 4,
"sizeOnDisk": 0,
"percentOfEpisodes": 0.0
}
},
{
"seasonNumber": 1,
"monitored": false,
"statistics": {
"episodeFileCount": 0,
"episodeCount": 0,
"totalEpisodeCount": 32,
"sizeOnDisk": 0,
"percentOfEpisodes": 0.0
}
},
{
"seasonNumber": 2,
"monitored": false,
"statistics": {
"episodeFileCount": 0,
"episodeCount": 0,
"totalEpisodeCount": 31,
"sizeOnDisk": 0,
"percentOfEpisodes": 0.0
}
},
{
"seasonNumber": 3,
"monitored": false,
"statistics": {
"episodeFileCount": 8,
"episodeCount": 8,
"totalEpisodeCount": 32,
"sizeOnDisk": 8000000000,
"percentOfEpisodes": 100.0
}
},
{
"seasonNumber": 4,
"monitored": false,
"statistics": {
"episodeFileCount": 0,
"episodeCount": 0,
"totalEpisodeCount": 32,
"sizeOnDisk": 0,
"percentOfEpisodes": 0.0
}
},
{
"seasonNumber": 5,
"monitored": false,
"statistics": {
"episodeFileCount": 0,
"episodeCount": 0,
"totalEpisodeCount": 32,
"sizeOnDisk": 0,
"percentOfEpisodes": 0.0
}
},
{
"seasonNumber": 6,
"monitored": false,
"statistics": {
"episodeFileCount": 0,
"episodeCount": 0,
"totalEpisodeCount": 30,
"sizeOnDisk": 0,
"percentOfEpisodes": 0.0
}
},
{
"seasonNumber": 7,
"monitored": false,
"statistics": {
"episodeFileCount": 0,
"episodeCount": 0,
"totalEpisodeCount": 30,
"sizeOnDisk": 0,
"percentOfEpisodes": 0.0
}
},
{
"seasonNumber": 8,
"monitored": true,
"statistics": {
"episodeFileCount": 0,
"episodeCount": 0,
"totalEpisodeCount": 30,
"sizeOnDisk": 0,
"percentOfEpisodes": 0.0
}
}
],
"year": 1960,
"path": "F:\\The Andy Griffith Show",
"profileId": 2,
"languageProfileId": 1,
"seasonFolder": true,
"monitored": true,
"useSceneNumbering": false,
"runtime": 25,
"tvdbId": 77754,
"tvRageId": 5574,
"tvMazeId": 3853,
"firstAired": "1960-02-15T06:00:00Z",
"lastInfoSync": "2020-04-05T20:40:21.545669Z",
"seriesType": "standard",
"cleanTitle": "theandygriffithshow",
"imdbId": "tt0053479",
"titleSlug": "the-andy-griffith-show",
"certification": "TV-G",
"genres": [
"Comedy"
],
"tags": [],
"added": "2020-04-05T20:40:20.050044Z",
"ratings": {
"votes": 547,
"value": 8.6
},
"qualityProfileId": 2,
"id": 105
}
]

View File

@ -0,0 +1,18 @@
{
"version": "2.0.0.1121",
"buildTime": "2014-02-08T20:49:36.5560392Z",
"isDebug": false,
"isProduction": true,
"isAdmin": true,
"isUserInteractive": false,
"startupPath": "C:\\ProgramData\\NzbDrone\\bin",
"appData": "C:\\ProgramData\\NzbDrone",
"osVersion": "6.2.9200.0",
"isMono": false,
"isLinux": false,
"isWindows": true,
"branch": "develop",
"authentication": false,
"startOfWeek": 0,
"urlBase": ""
}

View File

@ -0,0 +1,253 @@
{
"page": 1,
"pageSize": 10,
"sortKey": "airDateUtc",
"sortDirection": "descending",
"totalRecords": 2,
"records": [
{
"seriesId": 3,
"episodeFileId": 0,
"seasonNumber": 4,
"episodeNumber": 11,
"title": "Easy Com-mercial, Easy Go-mercial",
"airDate": "2014-01-26",
"airDateUtc": "2014-01-27T01:30:00Z",
"overview": "To compete with fellow \"restaurateur,\" Jimmy Pesto, and his blowout Super Bowl event, Bob is determined to create a Bob's Burgers commercial to air during the \"big game.\" In an effort to outshine Pesto, the Belchers recruit Randy, a documentarian, to assist with the filmmaking and hire on former pro football star Connie Frye to be the celebrity endorser.",
"hasFile": false,
"monitored": true,
"sceneEpisodeNumber": 0,
"sceneSeasonNumber": 0,
"tvDbEpisodeId": 0,
"series": {
"tvdbId": 194031,
"tvRageId": 24607,
"imdbId": "tt1561755",
"title": "Bob's Burgers",
"sortTitle": "bob burgers",
"cleanTitle": "bobsburgers",
"seasonCount": 4,
"status": "continuing",
"overview": "Bob's Burgers follows a third-generation restaurateur, Bob, as he runs Bob's Burgers with the help of his wife and their three kids. Bob and his quirky family have big ideas about burgers, but fall short on service and sophistication. Despite the greasy counters, lousy location and a dearth of customers, Bob and his family are determined to make Bob's Burgers \"grand re-re-re-opening\" a success.",
"airTime": "17:30",
"monitored": true,
"qualityProfileId": 1,
"seasonFolder": true,
"lastInfoSync": "2014-01-26T19:25:55.455594Z",
"runtime": 30,
"images": [
{
"coverType": "banner",
"url": "http://slurm.trakt.us/images/banners/1387.6.jpg"
},
{
"coverType": "poster",
"url": "http://slurm.trakt.us/images/posters/1387.6-300.jpg"
},
{
"coverType": "fanart",
"url": "http://slurm.trakt.us/images/fanart/1387.6.jpg"
}
],
"seriesType": "standard",
"network": "FOX",
"useSceneNumbering": false,
"titleSlug": "bobs-burgers",
"certification": "TV-14",
"path": "T:\\Bob's Burgers",
"year": 2011,
"firstAired": "2011-01-10T01:30:00Z",
"genres": [
"Animation",
"Comedy"
],
"tags": [],
"added": "2011-01-26T19:25:55.455594Z",
"qualityProfile": {
"value": {
"name": "SD",
"allowed": [
{
"id": 1,
"name": "SDTV",
"weight": 1
},
{
"id": 8,
"name": "WEBDL-480p",
"weight": 2
},
{
"id": 2,
"name": "DVD",
"weight": 3
}
],
"cutoff": {
"id": 1,
"name": "SDTV",
"weight": 1
},
"id": 1
},
"isLoaded": true
},
"seasons": [
{
"seasonNumber": 4,
"monitored": true
},
{
"seasonNumber": 3,
"monitored": true
},
{
"seasonNumber": 2,
"monitored": true
},
{
"seasonNumber": 1,
"monitored": true
},
{
"seasonNumber": 0,
"monitored": false
}
],
"id": 66
},
"downloading": false,
"id": 14402
},
{
"seriesId": 17,
"episodeFileId": 0,
"seasonNumber": 1,
"episodeNumber": 1,
"title": "The New Housekeeper",
"airDate": "1960-10-03",
"airDateUtc": "1960-10-03T01:00:00Z",
"overview": "Sheriff Andy Taylor and his young son Opie are in need of a new housekeeper. Andy's Aunt Bee looks like the perfect candidate and moves in, but her presence causes friction with Opie.",
"hasFile": false,
"monitored": true,
"sceneEpisodeNumber": 0,
"sceneSeasonNumber": 0,
"tvDbEpisodeId": 0,
"series": {
"imdbId": "",
"tvdbId": 77754,
"tvRageId": 5574,
"tvMazeId": 3853,
"title": "The Andy Griffith Show",
"sortTitle": "andy griffith show",
"cleanTitle": "theandygriffithshow",
"seasonCount": 8,
"status": "ended",
"overview": "Down-home humor and an endearing cast of characters helped make The Andy Griffith Show one of the most beloved comedies in the history of TV. The show centered around widower Andy Taylor, who divided his time between raising his young son Opie, and his job as sheriff of the sleepy North Carolina town, Mayberry. Andy and Opie live with Andy's Aunt Bee, who serves as a surrogate mother to both father and son. Andy's nervous cousin, Barney Fife, is his deputy sheriff whose incompetence is tolerated because Mayberry is virtually crime-free.",
"airTime": "21:30",
"monitored": true,
"qualityProfileId": 1,
"seasonFolder": true,
"lastInfoSync": "2016-02-05T16:40:11.614176Z",
"runtime": 25,
"images": [
{
"coverType": "fanart",
"url": "https://artworks.thetvdb.com/banners/fanart/original/77754-5.jpg"
},
{
"coverType": "banner",
"url": "https://artworks.thetvdb.com/banners/graphical/77754-g.jpg"
},
{
"coverType": "poster",
"url": "https://artworks.thetvdb.com/banners/posters/77754-4.jpg"
}
],
"seriesType": "standard",
"network": "CBS",
"useSceneNumbering": false,
"titleSlug": "the-andy-griffith-show",
"certification": "TV-G",
"path": "F:\\The Andy Griffith Show",
"year": 1960,
"firstAired": "1960-02-15T06:00:00Z",
"genres": [
"Comedy"
],
"tags": [],
"added": "2008-02-04T13:44:24.204583Z",
"qualityProfile": {
"value": {
"name": "SD",
"allowed": [
{
"id": 1,
"name": "SDTV",
"weight": 1
},
{
"id": 8,
"name": "WEBDL-480p",
"weight": 2
},
{
"id": 2,
"name": "DVD",
"weight": 3
}
],
"cutoff": {
"id": 1,
"name": "SDTV",
"weight": 1
},
"id": 1
},
"isLoaded": true
},
"seasons": [
{
"seasonNumber": 0,
"monitored": false
},
{
"seasonNumber": 1,
"monitored": false
},
{
"seasonNumber": 2,
"monitored": true
},
{
"seasonNumber": 3,
"monitored": false
},
{
"seasonNumber": 4,
"monitored": false
},
{
"seasonNumber": 5,
"monitored": true
},
{
"seasonNumber": 6,
"monitored": true
},
{
"seasonNumber": 7,
"monitored": true
},
{
"seasonNumber": 8,
"monitored": true
}
],
"id": 17
},
"downloading": false,
"id": 889
}
]
}