diff --git a/.coveragerc b/.coveragerc index ee095f77d1f..302a65e6e87 100644 --- a/.coveragerc +++ b/.coveragerc @@ -439,6 +439,7 @@ omit = homeassistant/components/sensor/pushbullet.py homeassistant/components/sensor/pvoutput.py homeassistant/components/sensor/qnap.py + homeassistant/components/sensor/radarr.py homeassistant/components/sensor/sabnzbd.py homeassistant/components/sensor/scrape.py homeassistant/components/sensor/sensehat.py diff --git a/homeassistant/components/sensor/radarr.py b/homeassistant/components/sensor/radarr.py new file mode 100644 index 00000000000..144485a4488 --- /dev/null +++ b/homeassistant/components/sensor/radarr.py @@ -0,0 +1,230 @@ +""" +Support for Radarr. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.radarr/ +""" +import logging +import time +from datetime import datetime, timedelta + +import requests +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.const import ( + CONF_API_KEY, CONF_HOST, CONF_PORT, CONF_MONITORED_CONDITIONS, + CONF_SSL) +from homeassistant.helpers.entity import Entity +from homeassistant.components.sensor import PLATFORM_SCHEMA + +_LOGGER = logging.getLogger(__name__) + +CONF_DAYS = 'days' +CONF_INCLUDED = 'include_paths' +CONF_UNIT = 'unit' +CONF_URLBASE = 'urlbase' + +DEFAULT_HOST = 'localhost' +DEFAULT_PORT = 7878 +DEFAULT_URLBASE = '' +DEFAULT_DAYS = '1' +DEFAULT_UNIT = 'GB' + +SCAN_INTERVAL = timedelta(minutes=10) + +SENSOR_TYPES = { + 'diskspace': ['Disk Space', 'GB', 'mdi:harddisk'], + 'upcoming': ['Upcoming', 'Movies', 'mdi:television'], + 'wanted': ['Wanted', 'Movies', 'mdi:television'], + 'movies': ['Movies', 'Movies', 'mdi:television'], + 'commands': ['Commands', 'Commands', 'mdi:code-braces'], + 'status': ['Status', 'Status', 'mdi:information'] +} + +ENDPOINTS = { + 'diskspace': 'http{0}://{1}:{2}/{3}api/diskspace', + 'upcoming': + 'http{0}://{1}:{2}/{3}api/calendar?start={4}&end={5}', + 'movies': 'http{0}://{1}:{2}/{3}api/movie', + 'commands': 'http{0}://{1}:{2}/{3}api/command', + 'status': 'http{0}://{1}:{2}/{3}api/system/status' +} + +# Support to Yottabytes for the future, why not +BYTE_SIZES = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_API_KEY): cv.string, + vol.Required(CONF_MONITORED_CONDITIONS, default=[]): + vol.All(cv.ensure_list, [vol.In(list(SENSOR_TYPES))]), + vol.Optional(CONF_INCLUDED, default=[]): cv.ensure_list, + vol.Optional(CONF_SSL, default=False): cv.boolean, + vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_URLBASE, default=DEFAULT_URLBASE): cv.string, + vol.Optional(CONF_DAYS, default=DEFAULT_DAYS): cv.string, + vol.Optional(CONF_UNIT, default=DEFAULT_UNIT): vol.In(BYTE_SIZES) +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Radarr platform.""" + conditions = config.get(CONF_MONITORED_CONDITIONS) + add_devices( + [RadarrSensor(hass, config, sensor) for sensor in conditions] + ) + return True + + +class RadarrSensor(Entity): + """Implemention of the Radarr sensor.""" + + def __init__(self, hass, conf, sensor_type): + """Create Radarr entity.""" + from pytz import timezone + 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 = 's' if conf.get(CONF_SSL) else '' + + # Object data + self.data = [] + self._tz = timezone(str(hass.config.time_zone)) + 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] + + # Update sensor + self._available = False + self.update() + + def update(self): + """Update the data for the sensor.""" + start = get_date(self._tz) + end = get_date(self._tz, self.days) + try: + res = requests.get( + ENDPOINTS[self.type].format( + self.ssl, self.host, self.port, + self.urlbase, start, end), + headers={'X-Api-Key': self.apikey}, + timeout=5) + except OSError: + _LOGGER.error('Host %s is not available', self.host) + self._available = False + self._state = None + return + + if res.status_code == 200: + if self.type in ['upcoming', 'movies', 'commands']: + self.data = res.json() + 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 + + @property + def name(self): + """Return the name of the sensor.""" + return '{} {}'.format('Radarr', self._name) + + @property + def state(self): + """Return sensor state.""" + return self._state + + @property + def available(self): + """Return sensor availability.""" + return self._available + + @property + def unit_of_measurement(self): + """Return the unit of the sensor.""" + return self._unit + + @property + def device_state_attributes(self): + """Return the state attributes of the sensor.""" + attributes = {} + if self.type == 'upcoming': + for movie in self.data: + attributes[to_key(movie)] = get_release_date(movie) + 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 == 'movies': + for movie in self.data: + attributes[to_key(movie)] = movie['downloaded'] + elif self.type == 'status': + attributes = self.data + + return attributes + + @property + def icon(self): + """Return the icon of the sensor.""" + return self._icon + + +def get_date(zone, offset=0): + """Get date based on timezone and offset of days.""" + day = 60 * 60 * 24 + return datetime.date( + datetime.fromtimestamp(time.time() + day*offset, tz=zone) + ) + + +def get_release_date(data): + date = data['physicalRelease'] + if not date: + date = data['inCinemas'] + return date + + +def to_key(data): + return '{} ({})'.format(data['title'], data['year']) + + +def to_unit(value, unit): + """Convert bytes to give unit.""" + return value / 1024**BYTE_SIZES.index(unit) diff --git a/tests/components/sensor/test_radarr.py b/tests/components/sensor/test_radarr.py new file mode 100644 index 00000000000..b0259b6352a --- /dev/null +++ b/tests/components/sensor/test_radarr.py @@ -0,0 +1,449 @@ +"""The tests for the radarr platform.""" +import unittest + +import pytest + +from homeassistant.components.sensor import radarr + +from tests.common import get_test_home_assistant + + +def mocked_exception(*args, **kwargs): + """Mock exception thrown by requests.get.""" + raise OSError + + +def mocked_requests_get(*args, **kwargs): + """Mock requests.get invocations.""" + class MockResponse: + """Class to represent a mocked response.""" + + def __init__(self, json_data, status_code): + """Initialize the mock response class.""" + self.json_data = json_data + self.status_code = status_code + + def json(self): + """Return the json of the response.""" + return self.json_data + + url = str(args[0]) + if 'api/calendar' in url: + return MockResponse([ + { + "title": "Resident Evil", + "sortTitle": "resident evil final chapter", + "sizeOnDisk": 0, + "status": "announced", + "overview": "Alice, Jill, Claire, Chris, Leon, Ada, and...", + "inCinemas": "2017-01-25T00:00:00Z", + "physicalRelease": "2017-01-27T00:00:00Z", + "images": [ + { + "coverType": "poster", + "url": ("/radarr/MediaCover/12/poster.jpg" + "?lastWrite=636208663600000000") + }, + { + "coverType": "banner", + "url": ("/radarr/MediaCover/12/banner.jpg" + "?lastWrite=636208663600000000") + } + ], + "website": "", + "downloaded": "false", + "year": 2017, + "hasFile": "false", + "youTubeTrailerId": "B5yxr7lmxhg", + "studio": "Impact Pictures", + "path": "/path/to/Resident Evil The Final Chapter (2017)", + "profileId": 3, + "monitored": "false", + "runtime": 106, + "lastInfoSync": "2017-01-24T14:52:40.315434Z", + "cleanTitle": "residentevilfinalchapter", + "imdbId": "tt2592614", + "tmdbId": 173897, + "titleSlug": "resident-evil-the-final-chapter-2017", + "genres": [ + "Action", + "Horror", + "Science Fiction" + ], + "tags": [], + "added": "2017-01-24T14:52:39.989964Z", + "ratings": { + "votes": 363, + "value": 4.3 + }, + "alternativeTitles": [ + "Resident Evil: Rising" + ], + "qualityProfileId": 3, + "id": 12 + } + ], 200) + elif 'api/command' in url: + return MockResponse([ + { + "name": "RescanMovie", + "startedOn": "0001-01-01T00:00:00Z", + "stateChangeTime": "2014-02-05T05:09:09.2366139Z", + "sendUpdatesToClient": "true", + "state": "pending", + "id": 24 + } + ], 200) + elif 'api/movie' in url: + return MockResponse([ + { + "title": "Assassin's Creed", + "sortTitle": "assassins creed", + "sizeOnDisk": 0, + "status": "released", + "overview": "Lynch discovers he is a descendant of...", + "inCinemas": "2016-12-21T00:00:00Z", + "images": [ + { + "coverType": "poster", + "url": ("/radarr/MediaCover/1/poster.jpg" + "?lastWrite=636200219330000000") + }, + { + "coverType": "banner", + "url": ("/radarr/MediaCover/1/banner.jpg" + "?lastWrite=636200219340000000") + } + ], + "website": "https://www.ubisoft.com/en-US/", + "downloaded": "false", + "year": 2016, + "hasFile": "false", + "youTubeTrailerId": "pgALJgMjXN4", + "studio": "20th Century Fox", + "path": "/path/to/Assassin's Creed (2016)", + "profileId": 6, + "monitored": "true", + "runtime": 115, + "lastInfoSync": "2017-01-23T22:05:32.365337Z", + "cleanTitle": "assassinscreed", + "imdbId": "tt2094766", + "tmdbId": 121856, + "titleSlug": "assassins-creed-121856", + "genres": [ + "Action", + "Adventure", + "Fantasy", + "Science Fiction" + ], + "tags": [], + "added": "2017-01-14T20:18:52.938244Z", + "ratings": { + "votes": 711, + "value": 5.2 + }, + "alternativeTitles": [ + "Assassin's Creed: The IMAX Experience" + ], + "qualityProfileId": 6, + "id": 1 + } + ], 200) + elif 'api/diskspace' in url: + return MockResponse([ + { + "path": "/data", + "label": "", + "freeSpace": 282500067328, + "totalSpace": 499738734592 + } + ], 200) + elif 'api/system/status' in url: + return MockResponse({ + "version": "0.2.0.210", + "buildTime": "2017-01-22T23:12:49Z", + "isDebug": "false", + "isProduction": "true", + "isAdmin": "false", + "isUserInteractive": "false", + "startupPath": "/path/to/radarr", + "appData": "/path/to/radarr/data", + "osVersion": "4.8.13.1", + "isMonoRuntime": "true", + "isMono": "true", + "isLinux": "true", + "isOsx": "false", + "isWindows": "false", + "branch": "develop", + "authentication": "forms", + "sqliteVersion": "3.16.2", + "urlBase": "", + "runtimeVersion": ("4.6.1 " + "(Stable 4.6.1.3/abb06f1 " + "Mon Oct 3 07:57:59 UTC 2016)") + }, 200) + else: + return MockResponse({ + "error": "Unauthorized" + }, 401) + + +class TestRadarrSetup(unittest.TestCase): + """Test the Radarr platform.""" + + # pylint: disable=invalid-name + DEVICES = [] + + def add_devices(self, devices): + """Mock add devices.""" + for device in devices: + self.DEVICES.append(device) + + 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() + + @unittest.mock.patch('requests.get', side_effect=mocked_requests_get) + def test_diskspace_no_paths(self, req_mock): + """Test getting all disk space.""" + config = { + 'platform': 'radarr', + 'api_key': 'foo', + 'days': '2', + 'unit': 'GB', + "include_paths": [], + 'monitored_conditions': [ + 'diskspace' + ] + } + radarr.setup_platform(self.hass, config, self.add_devices, None) + for device in self.DEVICES: + device.update() + self.assertEqual('263.10', device.state) + self.assertEqual('mdi:harddisk', device.icon) + self.assertEqual('GB', device.unit_of_measurement) + self.assertEqual('Radarr Disk Space', device.name) + self.assertEqual( + '263.10/465.42GB (56.53%)', + device.device_state_attributes["/data"] + ) + + @unittest.mock.patch('requests.get', side_effect=mocked_requests_get) + def test_diskspace_paths(self, req_mock): + """Test getting diskspace for included paths.""" + config = { + 'platform': 'radarr', + 'api_key': 'foo', + 'days': '2', + 'unit': 'GB', + "include_paths": [ + '/data' + ], + 'monitored_conditions': [ + 'diskspace' + ] + } + radarr.setup_platform(self.hass, config, self.add_devices, None) + for device in self.DEVICES: + device.update() + self.assertEqual('263.10', device.state) + self.assertEqual('mdi:harddisk', device.icon) + self.assertEqual('GB', device.unit_of_measurement) + self.assertEqual('Radarr Disk Space', device.name) + self.assertEqual( + '263.10/465.42GB (56.53%)', + device.device_state_attributes["/data"] + ) + + @unittest.mock.patch('requests.get', side_effect=mocked_requests_get) + def test_commands(self, req_mock): + """Test getting running commands.""" + config = { + 'platform': 'radarr', + 'api_key': 'foo', + 'days': '2', + 'unit': 'GB', + "include_paths": [ + '/data' + ], + 'monitored_conditions': [ + 'commands' + ] + } + radarr.setup_platform(self.hass, config, self.add_devices, None) + for device in self.DEVICES: + device.update() + self.assertEqual(1, device.state) + self.assertEqual('mdi:code-braces', device.icon) + self.assertEqual('Commands', device.unit_of_measurement) + self.assertEqual('Radarr Commands', device.name) + self.assertEqual( + 'pending', + device.device_state_attributes["RescanMovie"] + ) + + @unittest.mock.patch('requests.get', side_effect=mocked_requests_get) + def test_movies(self, req_mock): + """Test getting the number of movies.""" + config = { + 'platform': 'radarr', + 'api_key': 'foo', + 'days': '2', + 'unit': 'GB', + "include_paths": [ + '/data' + ], + 'monitored_conditions': [ + 'movies' + ] + } + radarr.setup_platform(self.hass, config, self.add_devices, None) + for device in self.DEVICES: + device.update() + self.assertEqual(1, device.state) + self.assertEqual('mdi:television', device.icon) + self.assertEqual('Movies', device.unit_of_measurement) + self.assertEqual('Radarr Movies', device.name) + self.assertEqual( + 'false', + device.device_state_attributes["Assassin's Creed (2016)"] + ) + + @unittest.mock.patch('requests.get', side_effect=mocked_requests_get) + def test_upcoming_multiple_days(self, req_mock): + """Test the upcoming movies for multiple days.""" + config = { + 'platform': 'radarr', + 'api_key': 'foo', + 'days': '2', + 'unit': 'GB', + "include_paths": [ + '/data' + ], + 'monitored_conditions': [ + 'upcoming' + ] + } + radarr.setup_platform(self.hass, config, self.add_devices, None) + for device in self.DEVICES: + device.update() + self.assertEqual(1, device.state) + self.assertEqual('mdi:television', device.icon) + self.assertEqual('Movies', device.unit_of_measurement) + self.assertEqual('Radarr Upcoming', device.name) + self.assertEqual( + '2017-01-27T00:00:00Z', + device.device_state_attributes["Resident Evil (2017)"] + ) + + @pytest.mark.skip + @unittest.mock.patch('requests.get', side_effect=mocked_requests_get) + def test_upcoming_today(self, req_mock): + """Test filtering for a single day. + + Radarr needs to respond with at least 2 days + """ + config = { + 'platform': 'radarr', + 'api_key': 'foo', + 'days': '1', + 'unit': 'GB', + "include_paths": [ + '/data' + ], + 'monitored_conditions': [ + 'upcoming' + ] + } + radarr.setup_platform(self.hass, config, self.add_devices, None) + for device in self.DEVICES: + device.update() + self.assertEqual(1, device.state) + self.assertEqual('mdi:television', device.icon) + self.assertEqual('Movies', device.unit_of_measurement) + self.assertEqual('Radarr Upcoming', device.name) + self.assertEqual( + '2017-01-27T00:00:00Z', + device.device_state_attributes["Resident Evil (2017)"] + ) + + @unittest.mock.patch('requests.get', side_effect=mocked_requests_get) + def test_system_status(self, req_mock): + """Test getting system status""" + config = { + 'platform': 'radarr', + 'api_key': 'foo', + 'days': '2', + 'unit': 'GB', + "include_paths": [ + '/data' + ], + 'monitored_conditions': [ + 'status' + ] + } + radarr.setup_platform(self.hass, config, self.add_devices, None) + for device in self.DEVICES: + device.update() + self.assertEqual('0.2.0.210', device.state) + self.assertEqual('mdi:information', device.icon) + self.assertEqual('Radarr Status', device.name) + self.assertEqual( + '4.8.13.1', + device.device_state_attributes['osVersion']) + + @pytest.mark.skip + @unittest.mock.patch('requests.get', side_effect=mocked_requests_get) + def test_ssl(self, req_mock): + """Test SSL being enabled.""" + config = { + 'platform': 'radarr', + 'api_key': 'foo', + 'days': '1', + 'unit': 'GB', + "include_paths": [ + '/data' + ], + 'monitored_conditions': [ + 'upcoming' + ], + "ssl": "true" + } + radarr.setup_platform(self.hass, config, self.add_devices, None) + for device in self.DEVICES: + device.update() + self.assertEqual(1, device.state) + self.assertEqual('s', device.ssl) + self.assertEqual('mdi:television', device.icon) + self.assertEqual('Movies', device.unit_of_measurement) + self.assertEqual('Radarr Upcoming', device.name) + self.assertEqual( + '2017-01-27T00:00:00Z', + device.device_state_attributes["Resident Evil (2017)"] + ) + + @unittest.mock.patch('requests.get', side_effect=mocked_exception) + def test_exception_handling(self, req_mock): + """Test exception being handled.""" + config = { + 'platform': 'radarr', + 'api_key': 'foo', + 'days': '1', + 'unit': 'GB', + "include_paths": [ + '/data' + ], + 'monitored_conditions': [ + 'upcoming' + ] + } + radarr.setup_platform(self.hass, config, self.add_devices, None) + for device in self.DEVICES: + device.update() + self.assertEqual(None, device.state)