diff --git a/.coveragerc b/.coveragerc index bd09465f643..7e0269784a4 100644 --- a/.coveragerc +++ b/.coveragerc @@ -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/* diff --git a/homeassistant/components/sonarr/__init__.py b/homeassistant/components/sonarr/__init__.py index 63c194cc969..4228d6c8400 100644 --- a/homeassistant/components/sonarr/__init__.py +++ b/homeassistant/components/sonarr/__init__.py @@ -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", + } diff --git a/homeassistant/components/sonarr/config_flow.py b/homeassistant/components/sonarr/config_flow.py new file mode 100644 index 00000000000..e82ecb49fda --- /dev/null +++ b/homeassistant/components/sonarr/config_flow.py @@ -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)) diff --git a/homeassistant/components/sonarr/const.py b/homeassistant/components/sonarr/const.py new file mode 100644 index 00000000000..52079a9416c --- /dev/null +++ b/homeassistant/components/sonarr/const.py @@ -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 diff --git a/homeassistant/components/sonarr/manifest.json b/homeassistant/components/sonarr/manifest.json index 2fe375fb1df..61c30102e34 100644 --- a/homeassistant/components/sonarr/manifest.json +++ b/homeassistant/components/sonarr/manifest.json @@ -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" } diff --git a/homeassistant/components/sonarr/sensor.py b/homeassistant/components/sonarr/sensor.py index 65513db3571..f1945f26836 100644 --- a/homeassistant/components/sonarr/sensor.py +++ b/homeassistant/components/sonarr/sensor.py @@ -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 diff --git a/homeassistant/components/sonarr/strings.json b/homeassistant/components/sonarr/strings.json new file mode 100644 index 00000000000..481a3d381f0 --- /dev/null +++ b/homeassistant/components/sonarr/strings.json @@ -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" + } + } + } + } +} diff --git a/homeassistant/components/sonarr/translations/en.json b/homeassistant/components/sonarr/translations/en.json new file mode 100644 index 00000000000..d60565ee19d --- /dev/null +++ b/homeassistant/components/sonarr/translations/en.json @@ -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" +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index b0309482205..65bfd461bc1 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -135,6 +135,7 @@ FLOWS = [ "solarlog", "soma", "somfy", + "sonarr", "songpal", "sonos", "spotify", diff --git a/requirements_all.txt b/requirements_all.txt index 9381750ab56..bbccd9de4aa 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -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 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 466d0ea99ed..436374f28a7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -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 diff --git a/tests/components/sonarr/__init__.py b/tests/components/sonarr/__init__.py index 573575cedc0..49a092c97e7 100644 --- a/tests/components/sonarr/__init__.py +++ b/tests/components/sonarr/__init__.py @@ -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 diff --git a/tests/components/sonarr/test_config_flow.py b/tests/components/sonarr/test_config_flow.py new file mode 100644 index 00000000000..cf5f1d15d55 --- /dev/null +++ b/tests/components/sonarr/test_config_flow.py @@ -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] diff --git a/tests/components/sonarr/test_init.py b/tests/components/sonarr/test_init.py new file mode 100644 index 00000000000..852befcb31c --- /dev/null +++ b/tests/components/sonarr/test_init.py @@ -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 diff --git a/tests/components/sonarr/test_sensor.py b/tests/components/sonarr/test_sensor.py index 96585f87068..8fbca025404 100644 --- a/tests/components/sonarr/test_sensor.py +++ b/tests/components/sonarr/test_sensor.py @@ -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": "King’s 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" diff --git a/tests/fixtures/sonarr/calendar.json b/tests/fixtures/sonarr/calendar.json new file mode 100644 index 00000000000..e24a48d227a --- /dev/null +++ b/tests/fixtures/sonarr/calendar.json @@ -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 + } +] diff --git a/tests/fixtures/sonarr/command.json b/tests/fixtures/sonarr/command.json new file mode 100644 index 00000000000..97acc2f9f82 --- /dev/null +++ b/tests/fixtures/sonarr/command.json @@ -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 + } +] diff --git a/tests/fixtures/sonarr/diskspace.json b/tests/fixtures/sonarr/diskspace.json new file mode 100644 index 00000000000..bc867cf21e5 --- /dev/null +++ b/tests/fixtures/sonarr/diskspace.json @@ -0,0 +1,8 @@ +[ + { + "path": "C:\\", + "label": "", + "freeSpace": 282500067328, + "totalSpace": 499738734592 + } +] diff --git a/tests/fixtures/sonarr/queue.json b/tests/fixtures/sonarr/queue.json new file mode 100644 index 00000000000..1a8eb0924c3 --- /dev/null +++ b/tests/fixtures/sonarr/queue.json @@ -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 + } +] diff --git a/tests/fixtures/sonarr/series.json b/tests/fixtures/sonarr/series.json new file mode 100644 index 00000000000..ea727c14a97 --- /dev/null +++ b/tests/fixtures/sonarr/series.json @@ -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 + } +] diff --git a/tests/fixtures/sonarr/system-status.json b/tests/fixtures/sonarr/system-status.json new file mode 100644 index 00000000000..c3969df08fe --- /dev/null +++ b/tests/fixtures/sonarr/system-status.json @@ -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": "" +} diff --git a/tests/fixtures/sonarr/wanted-missing.json b/tests/fixtures/sonarr/wanted-missing.json new file mode 100644 index 00000000000..5db7c52f469 --- /dev/null +++ b/tests/fixtures/sonarr/wanted-missing.json @@ -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 + } + ] +}