class BackgroundService(Monitor): """ Background service code """ def __init__(self): Monitor.__init__(self) self._kodi = KodiWrapper() self._player = PlayerMonitor(kodi=self._kodi) self.vtm_go = VtmGo(self._kodi) self.vtm_go_auth = VtmGoAuth(self._kodi) self.update_interval = 24 * 3600 # Every 24 hours self.cache_expiry = 30 * 24 * 3600 # One month def run(self): """ Background loop for maintenance tasks """ _LOGGER.debug('Service started') while not self.abortRequested(): # Update every `update_interval` after the last update if self._kodi.get_setting_as_bool('metadata_update') and int( self._kodi.get_setting('metadata_last_updated', 0)) + self.update_interval < time(): self._update_metadata() # Stop when abort requested if self.waitForAbort(10): break _LOGGER.debug('Service stopped') def onSettingsChanged(self): # pylint: disable=invalid-name """ Callback when a setting has changed """ # Refresh our VtmGo instance kodiwrapper.ADDON = Addon() self.vtm_go = VtmGo(self._kodi) if self.vtm_go_auth.has_credentials_changed(): _LOGGER.debug('Clearing auth tokens due to changed credentials') self.vtm_go_auth.clear_token() # Refresh container self._kodi.container_refresh() def _update_metadata(self): """ Update the metadata for the listings """ from resources.lib.modules.metadata import Metadata # Clear outdated metadata self._kodi.invalidate_cache(self.cache_expiry) def update_status(_i, _total): """ Allow to cancel the background job """ return self.abortRequested( ) or not self._kodi.get_setting_as_bool('metadata_update') success = Metadata(self._kodi).fetch_metadata(callback=update_status) # Update metadata_last_updated if success: self._kodi.set_setting('metadata_last_updated', str(int(time())))
def check_credentials(): """ Check credentials (called from settings) """ try: auth = VtmGoAuth(kodi) auth.clear_token() auth.get_token() kodi.show_ok_dialog( message=kodi.localize(30202)) # Credentials are correct! except InvalidLoginException: kodi.show_ok_dialog( message=kodi.localize(30203)) # Your credentials are not valid! kodi.open_settings()
def check_credentials(self): """ Check credentials (called from settings) """ try: from resources.lib.vtmgo.vtmgoauth import VtmGoAuth auth = VtmGoAuth(self._kodi) auth.clear_token() auth.get_token() self._kodi.show_ok_dialog( message=self._kodi.localize(30202)) # Credentials are correct! except InvalidLoginException: self._kodi.show_ok_dialog(message=self._kodi.localize( 30203)) # Your credentials are not valid! except LoginErrorException as e: self._kodi.show_ok_dialog(message=self._kodi.localize( 30702, code=e.code)) # Unknown error while logging in: {code} self._kodi.open_settings()
class BackgroundService(Monitor): """ Background service code """ def __init__(self): Monitor.__init__(self) self.kodi = KodiWrapper() self.vtm_go = VtmGo(self.kodi) self.vtm_go_auth = VtmGoAuth(self.kodi) self.update_interval = 24 * 3600 # Every 24 hours self.cache_expiry = 30 * 24 * 3600 # One month def run(self): """ Background loop for maintenance tasks """ self.kodi.log('Service started', LOG_INFO) while not self.abortRequested(): # Update every `update_interval` after the last update if self.kodi.get_setting_as_bool('metadata_update') and int( self.kodi.get_setting('metadata_last_updated', 0)) + self.update_interval < time(): self._update_metadata() # Stop when abort requested if self.waitForAbort(10): break self.kodi.log('Service stopped', LOG_INFO) def onSettingsChanged(self): """ Callback when a setting has changed """ # Refresh our VtmGo instance self.vtm_go = VtmGo(self.kodi) if self.vtm_go_auth.has_credentials_changed(): self.kodi.log('Clearing auth tokens due to changed credentials', LOG_INFO) self.vtm_go_auth.clear_token() # Refresh container self.kodi.container_refresh() def _update_metadata(self): """ Update the metadata for the listings. """ from resources.lib.modules.metadata import Metadata # Clear outdated metadata self.kodi.invalidate_cache(self.cache_expiry) # Create progress indicator progress = self.kodi.show_progress_background( message=self.kodi.localize(30715)) self.kodi.log('Updating metadata in the background') def update_status(i, total): """ Update the progress indicator """ progress.update(int(((i + 1) / total) * 100)) return self.abortRequested( ) or not self.kodi.get_setting_as_bool('metadata_update') success = Metadata(self.kodi).fetch_metadata(callback=update_status) # Close progress indicator progress.close() # Update metadata_last_updated if success: self.kodi.set_setting('metadata_last_updated', str(int(time())))
class VtmGo: """ VTM GO API """ API_ENDPOINT = 'https://api.vtmgo.be' CONTENT_TYPE_MOVIE = 'MOVIE' CONTENT_TYPE_PROGRAM = 'PROGRAM' CONTENT_TYPE_EPISODE = 'EPISODE' def __init__(self, kodi): """ Initialise object :type kodi: resources.lib.kodiwrapper.KodiWrapper """ self._kodi = kodi self._auth = VtmGoAuth(kodi) self._session = requests.session() self._session.proxies = kodi.get_proxies() self._authenticate() def _authenticate(self): """ Apply authentication headers in the session """ self._session.headers = { 'x-app-version': '8', 'x-persgroep-mobile-app': 'true', 'x-persgroep-os': 'android', 'x-persgroep-os-version': '23', } token = self._auth.get_token() if token: self._session.headers['x-dpp-jwt'] = token profile = self._auth.get_profile() if profile: self._session.headers['x-dpp-profile'] = profile else: # Select default profile default_profile = self.get_profiles()[0] self._session.headers['x-dpp-profile'] = default_profile.key def _mode(self): """ Return the mode that should be used for API calls """ return 'vtmgo-kids' if self.get_product() == 'VTM_GO_KIDS' else 'vtmgo' def get_config(self): """ Returns the config for the app """ # This is currently not used response = self._get_url('/config') info = json.loads(response) # This contains a player.updateIntervalSeconds that could be used to notify VTM GO about the playing progress return info def get_profiles(self, products='VTM_GO,VTM_GO_KIDS'): """ Returns the available profiles """ response = self._get_url('/profiles', {'products': products}) result = json.loads(response) profiles = [ Profile( key=profile.get('id'), product=profile.get('product'), name=profile.get('name'), gender=profile.get('gender'), birthdate=profile.get('birthDate'), color=profile.get('color', {}).get('start'), color2=profile.get('color', {}).get('end'), ) for profile in result ] return profiles def get_recommendations(self): """ Returns the config for the dashboard """ response = self._get_url('/%s/main' % self._mode()) recommendations = json.loads(response) categories = [] for cat in recommendations.get('rows', []): if cat.get('rowType') not in ['SWIMLANE_DEFAULT']: _LOGGER.debug('Skipping recommendation %s with type %s', cat.get('title'), cat.get('rowType')) continue items = [] for item in cat.get('teasers'): if item.get('target', {}).get('type') == self.CONTENT_TYPE_MOVIE: items.append(self._parse_movie_teaser(item)) elif item.get('target', {}).get('type') == self.CONTENT_TYPE_PROGRAM: items.append(self._parse_program_teaser(item)) categories.append( Category( category_id=cat.get('id'), title=cat.get('title'), content=items, )) return categories def get_swimlane(self, swimlane=None): """ Returns the contents of My List """ response = self._get_url('/%s/main/swimlane/%s' % (self._mode(), swimlane)) # Result can be empty if not response: return [] result = json.loads(response) items = [] for item in result.get('teasers'): if item.get('target', {}).get('type') == self.CONTENT_TYPE_MOVIE: items.append(self._parse_movie_teaser(item)) elif item.get('target', {}).get('type') == self.CONTENT_TYPE_PROGRAM: items.append(self._parse_program_teaser(item)) elif item.get('target', {}).get('type') == self.CONTENT_TYPE_EPISODE: items.append(self._parse_episode_teaser(item)) return items def add_mylist(self, video_type, content_id): """ Add an item to My List """ self._put_url('/%s/userData/myList/%s/%s' % (self._mode(), video_type, content_id)) def del_mylist(self, video_type, content_id): """ Delete an item from My List """ self._delete_url('/%s/userData/myList/%s/%s' % (self._mode(), video_type, content_id)) def get_live_channels(self): """ Get a list of all the live tv channels. :rtype list[LiveChannel] """ import dateutil.parser response = self._get_url('/%s/live' % self._mode()) info = json.loads(response) channels = [] for item in info.get('channels'): epg = [] for item_epg in item.get('broadcasts', []): epg.append( LiveChannelEpg( title=item_epg.get('name'), start=dateutil.parser.parse(item_epg.get('startsAt')), end=dateutil.parser.parse(item_epg.get('endsAt')), )) channels.append( LiveChannel( key=item.get('seoKey'), channel_id=item.get('channelId'), logo=item.get('channelLogoUrl'), background=item.get('channelPosterUrl'), name=item.get('name'), epg=epg, )) return channels def get_live_channel(self, key): """ Get a the specified live tv channel. :rtype LiveChannel """ channels = self.get_live_channels() return next(c for c in channels if c.key == key) def get_categories(self): """ Get a list of all the categories. :rtype list[Category] """ response = self._get_url('/%s/catalog/filters' % self._mode()) info = json.loads(response) categories = [] for item in info.get('catalogFilters', []): categories.append( Category( category_id=item.get('id'), title=item.get('title'), )) return categories def get_items(self, category=None): """ Get a list of all the items in a category. :type category: str :rtype list[Union[Movie, Program]] """ # Fetch from API if category is None: response = self._get_url('/%s/catalog' % self._mode(), {'pageSize': 1000}) else: response = self._get_url('/%s/catalog' % self._mode(), { 'pageSize': 1000, 'filter': quote(category) }) info = json.loads(response) content = info.get('pagedTeasers', {}).get('content', []) items = [] for item in content: if item.get('target', {}).get('type') == self.CONTENT_TYPE_MOVIE: items.append(self._parse_movie_teaser(item)) elif item.get('target', {}).get('type') == self.CONTENT_TYPE_PROGRAM: items.append(self._parse_program_teaser(item)) return items def get_movie(self, movie_id, cache=CACHE_AUTO): """ Get the details of the specified movie. :type movie_id: str :type cache: int :rtype Movie """ if cache in [CACHE_AUTO, CACHE_ONLY]: # Try to fetch from cache movie = self._kodi.get_cache(['movie', movie_id]) if movie is None and cache == CACHE_ONLY: return None else: movie = None if movie is None: # Fetch from API response = self._get_url('/%s/movies/%s' % (self._mode(), movie_id)) info = json.loads(response) movie = info.get('movie', {}) self._kodi.set_cache(['movie', movie_id], movie) return Movie( movie_id=movie.get('id'), name=movie.get('name'), description=movie.get('description'), duration=movie.get('durationSeconds'), cover=movie.get('bigPhotoUrl'), image=movie.get('bigPhotoUrl'), year=movie.get('productionYear'), geoblocked=movie.get('geoBlocked'), remaining=movie.get('remainingDaysAvailable'), legal=movie.get('legalIcons'), # aired=movie.get('broadcastTimestamp'), channel=self._parse_channel(movie.get('channelLogoUrl')), ) def get_program(self, program_id, cache=CACHE_AUTO): """ Get the details of the specified program. :type program_id: str :type cache: int :rtype Program """ if cache in [CACHE_AUTO, CACHE_ONLY]: # Try to fetch from cache program = self._kodi.get_cache(['program', program_id]) if program is None and cache == CACHE_ONLY: return None else: program = None if program is None: # Fetch from API response = self._get_url('/%s/programs/%s' % (self._mode(), program_id)) info = json.loads(response) program = info.get('program', {}) self._kodi.set_cache(['program', program_id], program) channel = self._parse_channel(program.get('channelLogoUrl')) seasons = {} for item_season in program.get('seasons', []): episodes = {} for item_episode in item_season.get('episodes', []): episodes[item_episode.get('index')] = Episode( episode_id=item_episode.get('id'), program_id=program_id, program_name=program.get('name'), number=item_episode.get('index'), season=item_season.get('index'), name=item_episode.get('name'), description=item_episode.get('description'), duration=item_episode.get('durationSeconds'), cover=item_episode.get('bigPhotoUrl'), geoblocked=program.get('geoBlocked'), remaining=item_episode.get('remainingDaysAvailable'), channel=channel, legal=program.get('legalIcons'), aired=item_episode.get('broadcastTimestamp'), progress=item_episode.get('playerPositionSeconds', 0), watched=item_episode.get('doneWatching', False), ) seasons[item_season.get('index')] = Season( number=item_season.get('index'), episodes=episodes, cover=item_season.get('episodes', [{}])[0].get('bigPhotoUrl') if episodes else program.get('bigPhotoUrl'), geoblocked=program.get('geoBlocked'), channel=channel, legal=program.get('legalIcons'), ) return Program( program_id=program.get('id'), name=program.get('name'), description=program.get('description'), cover=program.get('bigPhotoUrl'), image=program.get('bigPhotoUrl'), geoblocked=program.get('geoBlocked'), seasons=seasons, channel=channel, legal=program.get('legalIcons'), ) @staticmethod def get_episode_from_program(program, episode_id): """ Extract the specified episode from the program data. :type program: Program :type episode_id: str :rtype Episode """ for season in list(program.seasons.values()): for episode in list(season.episodes.values()): if episode.episode_id == episode_id: return episode return None @staticmethod def get_next_episode_from_program(program, season, number): """ Search for the next episode in the program data. :type program: Program :type season: int :type number: int :rtype Episode """ next_season_episode = None # First, try to find a match in the current season for episode in [ e for s in list(program.seasons.values()) for e in list(s.episodes.values()) ]: if episode.season == season and episode.number == number + 1: return episode if episode.season == season + 1 and episode.number == 1: next_season_episode = episode # No match, use the first episode of next season if next_season_episode: return next_season_episode # We are playing the last episode return None def get_episode(self, episode_id): """ Get some details of the specified episode. :type episode_id: str :rtype Episode """ response = self._get_url('/%s/play/episode/%s' % (self._mode(), episode_id)) episode = json.loads(response) # Extract next episode info if available next_playable = episode.get('nextPlayable') if next_playable: next_episode = Episode( episode_id=next_playable['id'], program_name=next_playable['title'], name=next_playable['subtitle'], description=next_playable['description'], cover=next_playable['imageUrl'], ) else: next_episode = None return Episode( episode_id=episode.get('id'), name=episode.get('title'), cover=episode.get('posterImageUrl'), progress=episode.get('playerPositionSeconds'), next_episode=next_episode, ) def do_search(self, search): """ Do a search in the full catalog. :type search: str :rtype list[Union[Movie, Program]] """ response = self._get_url('/%s/search/?query=%s' % (self._mode(), quote(search))) results = json.loads(response) items = [] for category in results.get('results', []): for item in category.get('teasers'): if item.get('target', {}).get('type') == self.CONTENT_TYPE_MOVIE: items.append(self._parse_movie_teaser(item)) elif item.get('target', {}).get('type') == self.CONTENT_TYPE_PROGRAM: items.append(self._parse_program_teaser(item)) return items def get_product(self): """ Return the product that is currently selected. """ profile = self._kodi.get_setting('profile') try: return profile.split(':')[1] except (IndexError, AttributeError): return None def _parse_movie_teaser(self, item): """ Parse the movie json and return an Movie instance. :type item: dict :rtype Movie """ movie = self.get_movie(item.get('target', {}).get('id'), cache=CACHE_ONLY) if movie: # We have a cover from the overview that we don't have in the details movie.cover = item.get('imageUrl') return movie return Movie( movie_id=item.get('target', {}).get('id'), name=item.get('title'), cover=item.get('imageUrl'), image=item.get('imageUrl'), geoblocked=item.get('geoBlocked'), ) def _parse_program_teaser(self, item): """ Parse the program json and return an Program instance. :type item: dict :rtype Program """ program = self.get_program(item.get('target', {}).get('id'), cache=CACHE_ONLY) if program: # We have a cover from the overview that we don't have in the details program.cover = item.get('imageUrl') return program return Program( program_id=item.get('target', {}).get('id'), name=item.get('title'), cover=item.get('imageUrl'), image=item.get('imageUrl'), geoblocked=item.get('geoBlocked'), ) def _parse_episode_teaser(self, item): """ Parse the episode json and return an Episode instance. :type item: dict :rtype Episode """ program = self.get_program(item.get('target', {}).get('programId'), cache=CACHE_ONLY) episode = self.get_episode_from_program( program, item.get('target', {}).get('id')) if program else None return Episode( episode_id=item.get('target', {}).get('id'), program_id=item.get('target', {}).get('programId'), program_name=item.get('title'), name=item.get('label'), description=episode.description if episode else None, geoblocked=item.get('geoBlocked'), cover=item.get('imageUrl'), progress=item.get('playerPositionSeconds'), watched=False, remaining=item.get('remainingDaysAvailable'), ) @staticmethod def _parse_channel(url): """ Parse the channel logo url, and return an icon that matches resource.images.studios.white :type url: str :rtype str """ if not url: return None import os.path # The channels id's we use in resources.lib.modules.CHANNELS neatly matches this part in the url. return str(os.path.basename(url).split('-')[0]) def _get_url(self, url, params=None): """ Makes a GET request for the specified URL. :type url: str :type params: dict :rtype str """ try: return self._request('GET', url, params=params) except InvalidLoginException: self._auth.clear_token() self._authenticate() # Retry the same request return self._request('GET', url, params=params) def _put_url(self, url, params=None): """ Makes a PUT request for the specified URL. :type url: str :type params: dict :rtype str """ try: return self._request('PUT', url, params=params) except InvalidLoginException: self._auth.clear_token() self._authenticate() # Retry the same request return self._request('PUT', url, params=params) def _post_url(self, url, params=None, data=None): """ Makes a POST request for the specified URL. :type url: str :type params: dict :type data: dict :rtype str """ try: return self._request('POST', url, params=params, data=data) except InvalidLoginException: self._auth.clear_token() self._authenticate() # Retry the same request return self._request('POST', url, params=params) def _delete_url(self, url, params=None): """ Makes a DELETE request for the specified URL. :type url: str :type params: dict :rtype str """ try: return self._request('DELETE', url, params=params) except InvalidLoginException: self._auth.clear_token() self._authenticate() # Retry the same request return self._request('DELETE', url, params=params) def _request(self, method, url, params=None, data=None): """ Makes a request for the specified URL. :type url: str :type params: dict :type data: dict :rtype str """ _LOGGER.debug('Sending %s %s...', method, url) response = self._session.request(method, self.API_ENDPOINT + url, params=params, json=data) # Set encoding to UTF-8 if no charset is indicated in http headers (https://github.com/psf/requests/issues/1604) if not response.encoding: response.encoding = 'utf-8' _LOGGER.debug('Got response (status=%s): %s', response.status_code, response.text) if response.status_code == 404: raise UnavailableException() if response.status_code == 401: raise InvalidLoginException() if response.status_code == 426: raise ApiUpdateRequired() if response.status_code not in [200, 204]: raise Exception('Error %s.' % response.status_code) return response.text