From 5d3dbd49303c6847b6357512903998f224853c98 Mon Sep 17 00:00:00 2001 From: Rami Mosleh Date: Tue, 21 Apr 2020 16:42:52 +0300 Subject: [PATCH] Trakt component with config flow support (#33) * Update Trakt integration to use config flow * update when options change * add hacs.json --- custom_components/trakt/.translations/en.json | 24 ++ custom_components/trakt/__init__.py | 279 +++++++++++++++++- custom_components/trakt/config_flow.py | 61 ++++ custom_components/trakt/const.py | 26 ++ custom_components/trakt/manifest.json | 6 +- custom_components/trakt/sensor.py | 240 +++------------ custom_components/trakt/strings.json | 24 ++ hacs.json | 6 + 8 files changed, 457 insertions(+), 209 deletions(-) create mode 100644 custom_components/trakt/.translations/en.json create mode 100644 custom_components/trakt/config_flow.py create mode 100644 custom_components/trakt/const.py create mode 100644 custom_components/trakt/strings.json create mode 100644 hacs.json diff --git a/custom_components/trakt/.translations/en.json b/custom_components/trakt/.translations/en.json new file mode 100644 index 0000000..3de6a29 --- /dev/null +++ b/custom_components/trakt/.translations/en.json @@ -0,0 +1,24 @@ +{ + "config": { + "step": { + "pick_implementation": { "title": "Pick Authentication Method" } + }, + "abort": { + "already_setup": "You can only configure one Trakt instance.", + "authorize_url_timeout": "Timeout generating authorize url.", + "missing_configuration": "The Trakt integration is not configured. Please follow the documentation." + }, + "create_entry": { "default": "Successfully authenticated with Trakt." } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Update frequency (minutes)", + "days": "Days to look forward for movies/shows", + "exclude": "list of excluded shows (comma separated)" + } + } + } + } +} diff --git a/custom_components/trakt/__init__.py b/custom_components/trakt/__init__.py index 1ae2566..31fc0d4 100644 --- a/custom_components/trakt/__init__.py +++ b/custom_components/trakt/__init__.py @@ -5,4 +5,281 @@ https://github.com/custom-components/sensor.trakt https://github.com/custom-cards/upcoming-media-card -""" \ No newline at end of file +""" +import asyncio +import json +import logging +from datetime import timedelta + +import async_timeout +import homeassistant.util.dt as dt_util +import trakt +import voluptuous as vol +from homeassistant.const import ( + CONF_ACCESS_TOKEN, + CONF_CLIENT_ID, + CONF_CLIENT_SECRET, + CONF_EXCLUDE, + CONF_SCAN_INTERVAL, +) +from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.event import async_track_time_interval +from trakt.calendar import MyShowCalendar +from trakt.tv import TVShow + +from . import config_flow +from .const import ( + CARD_DEFAULT, + CONF_DAYS, + DATA_TRAKT_CRED, + DATA_UPDATED, + DEFAULT_DAYS, + DEFAULT_SCAN_INTERVAL, + DOMAIN, + OAUTH2_AUTHORIZE, + OAUTH2_TOKEN, +) + +_LOGGER = logging.getLogger(__name__) + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Required(CONF_CLIENT_ID): cv.string, + vol.Required(CONF_CLIENT_SECRET): cv.string, + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +async def async_setup(hass, config) -> bool: + """Set up Trakt integration.""" + hass.data[DOMAIN] = {} + + if DOMAIN not in config: + return True + + config_flow.TraktOAuth2FlowHandler.async_register_implementation( + hass, + config_entry_oauth2_flow.LocalOAuth2Implementation( + hass, + DOMAIN, + config[DOMAIN][CONF_CLIENT_ID], + config[DOMAIN][CONF_CLIENT_SECRET], + OAUTH2_AUTHORIZE, + OAUTH2_TOKEN, + ), + ) + hass.data.setdefault( + DATA_TRAKT_CRED, + { + CONF_CLIENT_ID: config[DOMAIN][CONF_CLIENT_ID], + CONF_CLIENT_SECRET: config[DOMAIN][CONF_CLIENT_SECRET], + }, + ) + + return True + + +async def async_setup_entry(hass, entry) -> bool: + """Set up Trakt from config entry.""" + + implementation = await config_entry_oauth2_flow.async_get_config_entry_implementation( + hass, entry + ) + + session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) + await session.async_ensure_token_valid() + trakt_data = Trakt_Data(hass, entry, session) + + if not await trakt_data.async_setup(): + return False + hass.data[DOMAIN] = trakt_data + + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, "sensor") + ) + return True + + +async def async_unload_entry(hass, entry): + """Unload Trakt integration.""" + if hass.data[DOMAIN].unsub_timer: + hass.data[DOMAIN].unsub_timer() + + await hass.config_entries.async_forward_entry_unload(entry, "sensor") + + hass.data.pop(DOMAIN) + + return True + + +class Trakt_Data: + """Represent Trakt data.""" + + def __init__(self, hass, config_entry, implementation): + """Initialize trakt data.""" + self.hass = hass + self.config_entry = config_entry + self.session = config_entry_oauth2_flow.OAuth2Session( + hass, config_entry, implementation + ) + self.unsub_timer = None + self.details = {} + self.calendar = None + + @property + def days(self): + """Return number of days to look forward for movies/shows.""" + return self.config_entry.options[CONF_DAYS] + + @property + def exclude(self): + """Return list of show titles to exclude.""" + return self.config_entry.options[CONF_EXCLUDE] or [] + + async def async_update(self, *_): + """Update Trakt data.""" + card_json = [CARD_DEFAULT] + + try: + self.calendar = await self.hass.async_add_executor_job( + MyShowCalendar, {CONF_DAYS: self.days} + ) + except trakt.errors.OAuthException: + _LOGGER.error( + "Trakt api is unauthrized. Please remove the entry and reconfigure." + ) + return + + if not self.calendar: + _LOGGER.warning("Trakt upcoming calendar is empty") + return + + for show in self.calendar: + if not show or show.show in self.exclude: + continue + + try: + show_details = await self.hass.async_add_executor_job( + TVShow.search, show.show, show.year + ) + except AttributeError: + _LOGGER.error("Unable to retrieve show details for " + show.show) + + if not show_details: + continue + + if days_until(show.airs_at) < 0: + continue + if days_until(show.airs_at) <= 7: + release = "$day, $time" + else: + release = "$day, $date $time" + + session = aiohttp_client.async_get_clientsession(self.hass) + try: + with async_timeout.timeout(10): + response = await session.get( + f"http://api.tmdb.org/3/tv/{str(show_details[0].tmdb)}?api_key=0eee347e2333d7a97b724106353ca42f", + ) + except asyncio.TimeoutError: + _LOGGER.warning("api.themoviedb.org is not responding") + continue + + if response.status != 200: + _LOGGER.warning("Error retrving information from api.themoviedb.org") + continue + + tmdb_json = await response.json() + + image_url = "https://image.tmdb.org/t/p/w%s%s" + + card_item = { + "airdate": show.airs_at.isoformat() + "Z", + "release": release, + "flag": False, + "title": show.show, + "episode": show.title, + "number": "S" + str(show.season).zfill(2) + "E" + str(show.number), + "rating": tmdb_json.get("vote_average", ""), + "poster": image_url % ("500", tmdb_json.get("poster_path", "")), + "fanart": image_url % ("780", tmdb_json.get("backdrop_path", "")), + "runtime": tmdb_json.get("episode_run_time")[0] + if len(tmdb_json.get("episode_run_time", [])) > 0 + else "", + "studio": tmdb_json.get("networks")[0].get("name", "") + if len(tmdb_json.get("networks", [])) > 0 + else "", + } + card_json.append(card_item) + + self.details = json.dumps(card_json) + _LOGGER.debug("Trakt data updated") + + async_dispatcher_send(self.hass, DATA_UPDATED) + + async def async_setup(self): + """Set up Trakt Data.""" + await self.async_add_options() + trakt.core.OAUTH_TOKEN = self.session.token[CONF_ACCESS_TOKEN] + trakt.core.CLIENT_ID = self.hass.data[DATA_TRAKT_CRED][CONF_CLIENT_ID] + trakt.core.CLIENT_SECRET = self.hass.data[DATA_TRAKT_CRED][CONF_CLIENT_SECRET] + + try: + await self.async_update() + except trakt.errors.OAuthException: + _LOGGER.error( + "Trakt api is unauthrized. Please remove the entry and reconfigure." + ) + return False + + await self.async_set_scan_interval( + self.config_entry.options[CONF_SCAN_INTERVAL] + ) + self.config_entry.add_update_listener(self.async_options_updated) + + return True + + async def async_add_options(self): + """Add options for entry.""" + if not self.config_entry.options: + + options = { + CONF_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL, + CONF_DAYS: DEFAULT_DAYS, + CONF_EXCLUDE: None, + } + + self.hass.config_entries.async_update_entry( + self.config_entry, options=options + ) + + async def async_set_scan_interval(self, scan_interval): + """Update scan interval.""" + + if self.unsub_timer is not None: + self.unsub_timer() + self.unsub_timer = async_track_time_interval( + self.hass, self.async_update, timedelta(minutes=scan_interval) + ) + + @staticmethod + async def async_options_updated(hass, entry): + """Triggered by config entry options updates.""" + await hass.data[DOMAIN].async_set_scan_interval( + entry.options[CONF_SCAN_INTERVAL] + ) + await hass.data[DOMAIN].async_update() + + +def days_until(date): + """Calculate days until.""" + show_date = dt_util.as_local(date) + now = dt_util.as_local(dt_util.now()) + return int((show_date - now).total_seconds() / 86400) diff --git a/custom_components/trakt/config_flow.py b/custom_components/trakt/config_flow.py new file mode 100644 index 0000000..c6a79a0 --- /dev/null +++ b/custom_components/trakt/config_flow.py @@ -0,0 +1,61 @@ +"""Config flow for Trakt.""" +import logging +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_EXCLUDE, CONF_SCAN_INTERVAL +from homeassistant.core import callback +from homeassistant.helpers import config_entry_oauth2_flow +from .const import CONF_DAYS, DEFAULT_DAYS, DEFAULT_SCAN_INTERVAL, DOMAIN + + +class TraktOAuth2FlowHandler( + config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN +): + """Handle a Trakt config flow.""" + + DOMAIN = DOMAIN + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + @property + def logger(self) -> logging.Logger: + """Return logger.""" + return logging.getLogger(__name__) + + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Get the options flow for this handler.""" + return TraktOptionsFlowHandler(config_entry) + + +class TraktOptionsFlowHandler(config_entries.OptionsFlow): + """Handle Trakt options.""" + + def __init__(self, config_entry): + """Initialize Trakt options flow.""" + self.config_entry = config_entry + + async def async_step_init(self, user_input=None): + """Manage the Trakt options.""" + if user_input is not None: + user_input[CONF_EXCLUDE] = user_input[CONF_EXCLUDE].split(",") + return self.async_create_entry(title="", data=user_input) + + options = { + vol.Optional( + CONF_SCAN_INTERVAL, + default=self.config_entry.options.get( + CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL + ), + ): int, + vol.Optional( + CONF_DAYS, + default=self.config_entry.options.get(CONF_DAYS, DEFAULT_DAYS), + ): int, + vol.Optional( + CONF_EXCLUDE, default=self.config_entry.options.get(CONF_EXCLUDE, None), + ): str, + } + + return self.async_show_form(step_id="init", data_schema=vol.Schema(options)) diff --git a/custom_components/trakt/const.py b/custom_components/trakt/const.py new file mode 100644 index 0000000..5262c4b --- /dev/null +++ b/custom_components/trakt/const.py @@ -0,0 +1,26 @@ +"""Constants used in the Trakt integration.""" +DOMAIN = "trakt" + +OAUTH2_AUTHORIZE = "https://api-v2launch.trakt.tv/oauth/authorize" +OAUTH2_TOKEN = "https://api-v2launch.trakt.tv/oauth/token" + +ATTRIBUTION = "Data provided by trakt.tv" + +CONF_DAYS = "days" +CONF_EXCLUDE = "exclude" + +DATA_UPDATED = "trakt_data_updated" +DATA_TRAKT_CRED = "trakt_credentials" + +DEFAULT_DAYS = 30 +DEFAULT_SCAN_INTERVAL = 60 +DEFAULT_NAME = "Trakt Upcoming Calendar" + +CARD_DEFAULT = { + "title_default": "$title", + "line1_default": "$episode", + "line2_default": "$release", + "line3_default": "$rating - $runtime", + "line4_default": "$number - $studio", + "icon": "mdi:arrow-down-bold", +} diff --git a/custom_components/trakt/manifest.json b/custom_components/trakt/manifest.json index 3c2ec13..fbadebd 100644 --- a/custom_components/trakt/manifest.json +++ b/custom_components/trakt/manifest.json @@ -2,7 +2,7 @@ "domain": "trakt", "name": "Trakt", "documentation": "https://github.com/custom-components/sensor.trakt/blob/master/README.md", - "dependencies": [], - "codeowners": ["@iantrich"], - "requirements": ["trakt==2.8.2", "requests_oauthlib==1.0.0"] + "config_flow": true, + "codeowners": ["@iantrich", "@engrbm"], + "requirements": ["trakt==2.12.0"] } diff --git a/custom_components/trakt/sensor.py b/custom_components/trakt/sensor.py index d6c6cf9..9b6876a 100644 --- a/custom_components/trakt/sensor.py +++ b/custom_components/trakt/sensor.py @@ -1,234 +1,64 @@ """Sensor platform for Trakt""" -import json -import logging -import time -from datetime import datetime, timedelta - -import homeassistant.helpers.config_validation as cv -import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ATTR_ATTRIBUTION +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity -__version__ = '1.0.1' - -REQUIREMENTS = ['trakt==2.8.2', 'requests_oauthlib==1.0.0'] - -BASE_URL = 'https://api-v2launch.trakt.tv/' -CONF_CLIENT_ID = 'id' -CONF_CLIENT_SECRET = 'secret' -CONF_DAYS = 'days' -CONF_EXCLUDE = 'exclude' -CONF_NAME = 'name' -CONF_USERNAME = 'username' -DATA_UPCOMING = 'trakt_upcoming' -REDIRECT_URI = 'urn:ietf:wg:oauth:2.0:oob' -SCAN_INTERVAL = timedelta(minutes=30) -TOKEN_FILE = '.trakt.conf' - -LIST_SCHEMA = vol.Schema([str]) - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_CLIENT_ID): cv.string, - vol.Required(CONF_CLIENT_SECRET): cv.string, - vol.Required(CONF_USERNAME): cv.string, - vol.Optional(CONF_DAYS, default=30): cv.positive_int, - vol.Optional(CONF_NAME, default='Trakt Upcoming Calendar'): cv.string, - vol.Optional(CONF_EXCLUDE, default=[]): LIST_SCHEMA, -}) - -_CONFIGURING = {} -_LOGGER = logging.getLogger(__name__) - - -def request_app_setup(hass, config, add_devices, discovery_info=None): - """Request configuration steps from the user.""" - from requests.compat import urljoin - from requests_oauthlib import OAuth2Session - configurator = hass.components.configurator - authorization_base_url = urljoin(BASE_URL, '/oauth/authorize') - oauth = OAuth2Session(config[CONF_CLIENT_ID], redirect_uri=REDIRECT_URI, state=None) - - def trakt_configuration_callback(data): - """Run when the configuration callback is called.""" - token_url = urljoin(BASE_URL, '/oauth/token') - oauth.fetch_token(token_url, client_secret=config[CONF_CLIENT_SECRET], code=data.get('pin_code')) - token = oauth.token['access_token'] - save_token(hass, token) - continue_setup_platform(hass, config, token, add_devices, discovery_info) - - if 'trakt' not in _CONFIGURING: - authorization_url, _ = oauth.authorization_url(authorization_base_url, username=config[CONF_USERNAME]) - - _CONFIGURING['trakt'] = configurator.request_config( - 'Trakt', - trakt_configuration_callback, - description="Enter pin code from Trakt: " + authorization_url, - submit_caption='Verify', - fields=[{ - 'id': 'pin_code', - 'name': "Pin code", - 'type': 'string'}] - ) - - -def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up the Trakt component.""" - token = load_token(hass) - - if not token: - request_app_setup(hass, config, add_devices, discovery_info) - else: - continue_setup_platform(hass, config, token, add_devices, discovery_info) - - -def continue_setup_platform(hass, config, token, add_devices, discovery_info=None): - """Set up the Trakt component.""" - if "trakt" in _CONFIGURING: - hass.components.configurator.request_done(_CONFIGURING.pop("trakt")) - - add_devices([TraktUpcomingCalendarSensor(hass, config, token)], True) - - -def load_token(hass): - try: - with open(hass.config.path(TOKEN_FILE)) as data_file: - token = {} - try: - token = json.load(data_file) - except ValueError as err: - return {} - return token - except IOError as err: - return {} - - -def save_token(hass, token): - with open(hass.config.path(TOKEN_FILE), 'w') as data_file: - data_file.write(json.dumps(token)) +from .const import ATTRIBUTION, DATA_UPDATED, DEFAULT_NAME, DOMAIN + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up device tracker for Mikrotik component.""" + tk_data = hass.data[DOMAIN] + + async_add_entities([TraktUpcomingCalendarSensor(tk_data)], True) class TraktUpcomingCalendarSensor(Entity): """Representation of a Trakt Upcoming Calendar sensor.""" - def __init__(self, hass, config, token): + def __init__(self, tk_data): """Initialize the sensor.""" - import trakt - from pytz import timezone - self._tz = timezone(str(hass.config.time_zone)) - trakt.core.OAUTH_TOKEN = token - trakt.core.CLIENT_ID = config[CONF_CLIENT_ID] - trakt.core.CLIENT_SECRET = config[CONF_CLIENT_SECRET] - self._hass = hass - self._days = config[CONF_DAYS] - self._state = None - self._hass.data[DATA_UPCOMING] = {} - self._name = config[CONF_NAME] - self._exclude = config[CONF_EXCLUDE] - self.update() - - def update(self): - """Get the latest state of the sensor.""" - from trakt.calendar import MyShowCalendar - from trakt.tv import TVShow - import requests - attributes = {} - default = {} - card_json = [] - default['title_default'] = '$title' - default['line1_default'] = '$episode' - default['line2_default'] = '$release' - default['line3_default'] = '$rating - $runtime' - default['line4_default'] = '$number - $studio' - default['icon'] = 'mdi:arrow-down-bold' - card_json.append(default) - calendar = MyShowCalendar(days=self._days) - - if not calendar: - _LOGGER.error("Nothing in upcoming calendar") - return False - - self._state = len(calendar) - - for show in calendar: - if not show or show.show in self._exclude: - continue - - try: - show_details = TVShow.search(show.show, show.year) - except AttributeError: - _LOGGER.error('Unable to retrieve show details for ' + show.show) - - if not show_details: - continue - - session = requests.Session() - try: - tmdb_url = session.get('http://api.tmdb.org/3/tv/{}?api_key=0eee347e2333d7a97b724106353ca42f'.format( - str(show_details[0].tmdb))) - tmdb_json = tmdb_url.json() - except requests.exceptions.RequestException as e: - _LOGGER.warning('api.themoviedb.org is not responding') - return - image_url = 'https://image.tmdb.org/t/p/w%s%s' - if days_until(show.airs_at.isoformat() + 'Z', self._tz) < 0: - continue - if days_until(show.airs_at.isoformat() + 'Z', self._tz) <= 7: - release = '$day, $time' - else: - release = '$day, $date $time' - - card_item = { - 'airdate': show.airs_at.isoformat() + 'Z', - 'release': release, - 'flag': False, - 'title': show.show, - 'episode': show.title, - 'number': 'S' + str(show.season) + 'E' + str(show.number), - 'rating': tmdb_json.get('vote_average', ''), - 'poster': image_url % ('500', tmdb_json.get('poster_path', '')), - 'fanart': image_url % ('780', tmdb_json.get('backdrop_path', '')), - 'runtime': tmdb_json.get('episode_run_time')[0] if len(tmdb_json.get('episode_run_time', [])) > 0 else '', - 'studio': tmdb_json.get('networks')[0].get('name', '') if len(tmdb_json.get('networks', [])) > 0 else '' - } - card_json.append(card_item) - - attributes['data'] = json.dumps(card_json) - attributes['attribution'] = "Data provided by trakt.tv" - self._hass.data[DATA_UPCOMING] = attributes + self.tk_data = tk_data @property def name(self): """Return the name of the sensor.""" - return self._name + return DEFAULT_NAME + + @property + def unique_id(self): + """Return the unique id of the entity.""" + return DEFAULT_NAME + + @property + def should_poll(self): + """Disable polling.""" + return False @property def state(self): """Return the state of the sensor.""" - return self._state + return len(self.tk_data.calendar) @property def icon(self): """Return the icon to use in the frontend.""" - return 'mdi:calendar' + return "mdi:calendar" @property def unit_of_measurement(self): """Return the unit of measurement this sensor expresses itself in.""" - return 'shows' + return "shows" @property def device_state_attributes(self): """Return the state attributes of the sensor.""" - return self._hass.data[DATA_UPCOMING] - - -def days_until(date, tz): - from pytz import utc - date = datetime.strptime(date, '%Y-%m-%dT%H:%M:%SZ') - date = str(date.replace(tzinfo=utc).astimezone(tz))[:10] - date = time.strptime(date, '%Y-%m-%d') - date = time.mktime(date) - now = datetime.now().strftime('%Y-%m-%d') - now = time.strptime(now, '%Y-%m-%d') - now = time.mktime(now) - return int((date - now) / 86400) + attributes = {"data": self.tk_data.details, ATTR_ATTRIBUTION: ATTRIBUTION} + + return attributes + + async def async_added_to_hass(self): + """Handle entity which will be added.""" + self.async_on_remove( + async_dispatcher_connect(self.hass, DATA_UPDATED, self.async_write_ha_state) + ) diff --git a/custom_components/trakt/strings.json b/custom_components/trakt/strings.json new file mode 100644 index 0000000..3de6a29 --- /dev/null +++ b/custom_components/trakt/strings.json @@ -0,0 +1,24 @@ +{ + "config": { + "step": { + "pick_implementation": { "title": "Pick Authentication Method" } + }, + "abort": { + "already_setup": "You can only configure one Trakt instance.", + "authorize_url_timeout": "Timeout generating authorize url.", + "missing_configuration": "The Trakt integration is not configured. Please follow the documentation." + }, + "create_entry": { "default": "Successfully authenticated with Trakt." } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Update frequency (minutes)", + "days": "Days to look forward for movies/shows", + "exclude": "list of excluded shows (comma separated)" + } + } + } + } +} diff --git a/hacs.json b/hacs.json new file mode 100644 index 0000000..e4cdd13 --- /dev/null +++ b/hacs.json @@ -0,0 +1,6 @@ +{ + "name": "Trakt", + "domains": ["sensor"], + "homeassistant": "0.90.0", + "render_readme": true +}