class VrtMonitor(Monitor): ''' This is the class that monitors Kodi for the VRT NU video plugin ''' def __init__(self): ''' VRT Monitor initialisiation ''' self._resumepoints = ResumePoints() self._container = None self._playerinfo = None self._favorites = None self._apihelper = None self.init_watching_activity() Monitor.__init__(self) def run(self): ''' Main loop ''' while not self.abortRequested(): if self.waitForAbort(10): break def init_watching_activity(self): ''' Only load components for watching activity when needed ''' if self._resumepoints.is_activated(): if not self._playerinfo: self._playerinfo = PlayerInfo(info=self.handle_info) if not self._favorites: self._favorites = Favorites() if not self._apihelper: self._apihelper = ApiHelper(self._favorites, self._resumepoints) def onNotification(self, sender, method, data): # pylint: disable=invalid-name ''' Handler for notifications ''' log(2, '[Notification] sender={sender}, method={method}, data={data}', sender=sender, method=method, data=to_unicode(data)) if method.endswith('source_container'): from json import loads self._container = loads(data).get('container') return if not sender.startswith('upnextprovider'): return if not method.endswith('plugin.video.vrt.nu_play_action'): return from json import loads hexdata = loads(data) if not hexdata: return from binascii import unhexlify data = loads(unhexlify(hexdata[0])) log(2, '[Up Next notification] sender={sender}, method={method}, data={data}', sender=sender, method=method, data=to_unicode(data)) jsonrpc(method='Player.Open', params=dict(item=dict( file='plugin://plugin.video.vrt.nu/play/whatson/%s' % data.get('whatson_id')))) def onSettingsChanged(self): # pylint: disable=invalid-name ''' Handler for changes to settings ''' log(1, 'Settings changed') TokenResolver().refresh_login() invalidate_caches('continue-*.json', 'favorites.json', 'my-offline-*.json', 'my-recent-*.json', 'resume_points.json', 'watchlater-*.json') # Init watching activity again when settings change self.init_watching_activity() # Refresh container when settings change container_refresh() def handle_info(self, info): ''' Handle information from PlayerInfo class ''' log(2, 'Got VRT NU Player info: {info}', info=str(info)) # Push resume position if info.get('position'): self.push_position(info) # Push up next episode info if info.get('program'): self.push_upnext(info) def push_position(self, info): ''' Push player position to VRT NU resumepoints API ''' # Get uuid, title and url from api based on video.get('publication_id') or video.get('video_url') ep_id = play_url_to_id(info.get('path')) if ep_id.get('video_id'): episode = self._apihelper.get_episodes( video_id=ep_id.get('video_id'), variety='single')[0] elif ep_id.get('whatson_id'): episode = self._apihelper.get_episodes( whatson_id=ep_id.get('whatson_id'), variety='single')[0] elif ep_id.get('video_url'): episode = self._apihelper.get_episodes( video_url=ep_id.get('video_url'), variety='single')[0] uuid = self._resumepoints.assetpath_to_uuid(episode.get('assetPath')) title = episode.get('program') url = url_to_episode(episode.get('url', '')) # Push resumepoint to VRT NU self._resumepoints.update(uuid=uuid, title=title, url=url, watch_later=None, position=info.get('position'), total=info.get('total')) # Only update container if the play action was initiated from it current_container = current_container_url() log(2, '[PlayerPosition] resumepoint update {info} {container}', info=episode.get('title'), container=current_container) if current_container is None or self._container == current_container: log(2, '[PlayerPosition] update container {info}', info=self._container) container_update(self._container) def push_upnext(self, info): ''' Push episode info to Up Next service add-on''' if has_addon('service.upnext') and get_setting('useupnext', 'true') == 'true': next_info = self._apihelper.get_upnext(info) if next_info: from binascii import hexlify from json import dumps data = [to_unicode(hexlify(dumps(next_info).encode()))] sender = '%s.SIGNAL' % addon_id() notify(sender=sender, message='upnext_data', data=data)
class PlayerInfo(Player, object): # pylint: disable=useless-object-inheritance """Class for communication with Kodi player""" def __init__(self): """PlayerInfo initialisation""" self.resumepoints = ResumePoints() self.apihelper = ApiHelper(Favorites(), self.resumepoints) self.last_pos = None self.listen = False self.paused = False self.total = 100 self.positionthread = None self.quit = Event() self.asset_id = None # FIXME On Kodi 17, use ListItem.Filenameandpath because Player.FilenameAndPath returns the stream manifest url and # this definitely breaks "Up Next" on Kodi 17, but this is not supported or available through the Kodi add-on repo anyway self.path_infolabel = 'ListItem.Filenameandpath' if kodi_version_major( ) < 18 else 'Player.FilenameAndPath' self.path = None self.title = None self.ep_id = None self.url = None self.whatson_id = None from random import randint self.thread_id = randint(1, 10001) log(3, '[PlayerInfo {id}] Initialized', id=self.thread_id) super(PlayerInfo, self).__init__() def onPlayBackStarted(self): # pylint: disable=invalid-name """Called when user starts playing a file""" self.path = getInfoLabel(self.path_infolabel) if self.path.startswith('plugin://plugin.video.vrt.nu/'): self.listen = True else: self.listen = False return log(3, '[PlayerInfo {id}] Event onPlayBackStarted', id=self.thread_id) # Set property to let wait_for_resumepoints function know that update resume is busy set_property('vrtnu_resumepoints', 'busy') # Update previous episode when using "Up Next" if self.path.startswith('plugin://plugin.video.vrt.nu/play/upnext'): self.push_position(position=self.last_pos, total=self.total) # Reset episode data self.asset_id = None self.title = None self.url = None self.whatson_id = None ep_id = play_url_to_id(self.path) # Avoid setting resumepoints for livestreams for channel in CHANNELS: if ep_id.get('video_id') and ep_id.get('video_id') == channel.get( 'live_stream_id'): log(3, '[PlayerInfo {id}] Avoid setting resumepoints for livestream {video_id}', id=self.thread_id, video_id=ep_id.get('video_id')) self.listen = False # Reset vrtnu_resumepoints property before return set_property('vrtnu_resumepoints', None) return # Get episode data needed to update resumepoints from VRT NU Search API episode = self.apihelper.get_single_episode_data( video_id=ep_id.get('video_id'), whatson_id=ep_id.get('whatson_id'), video_url=ep_id.get('video_url')) # Avoid setting resumepoints without episode data if episode is None: # Reset vrtnu_resumepoints property before return set_property('vrtnu_resumepoints', None) return from metadata import Metadata self.asset_id = Metadata(None, None).get_asset_id(episode) self.title = episode.get('program') self.url = url_to_episode(episode.get('url', '')) self.whatson_id = episode.get( 'whatsonId') or None # Avoid empty string # Kodi 17 doesn't have onAVStarted if kodi_version_major() < 18: self.onAVStarted() def onAVStarted(self): # pylint: disable=invalid-name """Called when Kodi has a video or audiostream""" if not self.listen: return log(3, '[PlayerInfo {id}] Event onAVStarted', id=self.thread_id) self.quit.clear() self.update_position() self.update_total() self.push_upnext() # StreamPosition thread keeps running when watching multiple episode with "Up Next" # only start StreamPosition thread when it doesn't exist yet. if not self.positionthread: self.positionthread = Thread(target=self.stream_position, name='StreamPosition') self.positionthread.start() def onAVChange(self): # pylint: disable=invalid-name """Called when Kodi has a video, audio or subtitle stream. Also happens when the stream changes.""" def onPlayBackSeek(self, time, seekOffset): # pylint: disable=invalid-name """Called when user seeks to a time""" if not self.listen: return log(3, '[PlayerInfo {id}] Event onPlayBackSeek time={time} offset={offset}', id=self.thread_id, time=time, offset=seekOffset) self.last_pos = time // 1000 # If we seek beyond the end, exit Player if self.last_pos >= self.total: self.quit.set() self.stop() def onPlayBackPaused(self): # pylint: disable=invalid-name """Called when user pauses a playing file""" if not self.listen: return log(3, '[PlayerInfo {id}] Event onPlayBackPaused', id=self.thread_id) self.update_position() self.push_position(position=self.last_pos, total=self.total) self.paused = True def onPlayBackResumed(self): # pylint: disable=invalid-name """Called when user resumes a paused file or a next playlist item is started""" if not self.listen: return suffix = 'after pausing' if self.paused else 'after playlist change' log(3, '[PlayerInfo {id}] Event onPlayBackResumed {suffix}', id=self.thread_id, suffix=suffix) self.paused = False def onPlayBackEnded(self): # pylint: disable=invalid-name """Called when Kodi has ended playing a file""" if not self.listen: return self.last_pos = self.total self.quit.set() log(3, '[PlayerInfo {id}] Event onPlayBackEnded', id=self.thread_id) def onPlayBackError(self): # pylint: disable=invalid-name """Called when playback stops due to an error""" if not self.listen: return self.quit.set() log(3, '[PlayerInfo {id}] Event onPlayBackError', id=self.thread_id) def onPlayBackStopped(self): # pylint: disable=invalid-name """Called when user stops Kodi playing a file""" if not self.listen: return self.quit.set() log(3, '[PlayerInfo {id}] Event onPlayBackStopped', id=self.thread_id) def onPlayerExit(self): # pylint: disable=invalid-name """Called when player exits""" log(3, '[PlayerInfo {id}] Event onPlayerExit', id=self.thread_id) self.positionthread = None self.push_position(position=self.last_pos, total=self.total) # Set property to let wait_for_resumepoints function know that update resume is done set_property('vrtnu_resumepoints', 'ready') def stream_position(self): """Get latest stream position while playing""" while self.isPlaying() and not self.quit.is_set(): self.update_position() if self.quit.wait(timeout=0.2): break self.onPlayerExit() def add_upnext(self, video_id): """Add Up Next url to Kodi Player""" # Reset vrtnu_resumepoints property set_property('vrtnu_resumepoints', None) url = 'plugin://plugin.video.vrt.nu/play/upnext/{video_id}'.format( video_id=video_id) self.update_position() self.update_total() if self.isPlaying() and self.total - self.last_pos < 1: log(3, '[PlayerInfo {id}] Add {url} to Kodi Playlist', id=self.thread_id, url=url) PlayList(1).add(url) else: log(3, '[PlayerInfo {id}] Add {url} to Kodi Player', id=self.thread_id, url=url) self.play(url) def push_upnext(self): """Push episode info to Up Next service add-on""" if has_addon('service.upnext') and get_setting_bool( 'useupnext', default=True) and self.isPlaying(): info_tag = self.getVideoInfoTag() next_info = self.apihelper.get_upnext( dict( program=to_unicode(info_tag.getTVShowTitle()), playcount=info_tag.getPlayCount(), rating=info_tag.getRating(), path=self.path, runtime=self.total, )) if next_info: from base64 import b64encode from json import dumps data = [to_unicode(b64encode(dumps(next_info).encode()))] sender = '{addon_id}.SIGNAL'.format(addon_id=addon_id()) notify(sender=sender, message='upnext_data', data=data) def update_position(self): """Update the player position, when possible""" try: self.last_pos = self.getTime() except RuntimeError: pass def update_total(self): """Update the total video time""" try: total = self.getTotalTime() # Kodi Player sometimes returns a total time of 0.0 and this causes unwanted behaviour with VRT NU Resumepoints API. if total > 0.0: self.total = total except RuntimeError: pass def push_position(self, position, total): """Push player position to VRT NU resumepoints API and reload container""" # Not all content has an asset_id if not self.asset_id: return # Push resumepoint to VRT NU self.resumepoints.update(asset_id=self.asset_id, title=self.title, url=self.url, position=position, total=total, whatson_id=self.whatson_id, path=self.path)
class PlayerInfo(Player, object): # pylint: disable=useless-object-inheritance """Class for communication with Kodi player""" def __init__(self): """PlayerInfo initialisation""" self.resumepoints = ResumePoints() self.apihelper = ApiHelper(Favorites(), self.resumepoints) self.last_pos = None self.listen = False self.paused = False self.total = 100 self.positionthread = None self.quit = Event() self.asset_id = None # FIXME On Kodi 17, use ListItem.Filenameandpath because Player.FilenameAndPath returns the stream manifest url and # this definitely breaks "Up Next" on Kodi 17, but this is not supported or available through the Kodi add-on repo anyway self.path_infolabel = 'ListItem.Filenameandpath' if kodi_version_major( ) < 18 else 'Player.FilenameAndPath' self.path = None self.title = None self.ep_id = None self.url = None self.whatson_id = None from random import randint self.thread_id = randint(1, 10001) log(3, '[PlayerInfo {id}] Initialized', id=self.thread_id) super(PlayerInfo, self).__init__() def onPlayBackStarted(self): # pylint: disable=invalid-name """Called when user starts playing a file""" self.path = getInfoLabel(self.path_infolabel) if self.path.startswith('plugin://plugin.video.vrt.nu/'): self.listen = True else: self.listen = False return log(3, '[PlayerInfo {id}] Event onPlayBackStarted', id=self.thread_id) # Set property to let wait_for_resumepoints function know that update resume is busy set_property('vrtnu_resumepoints', 'busy') # Update previous episode when using "Up Next" if self.path.startswith('plugin://plugin.video.vrt.nu/play/upnext'): self.push_position(position=self.last_pos, total=self.total) # Reset episode data self.asset_id = None self.title = None self.url = None self.whatson_id = None ep_id = play_url_to_id(self.path) # Avoid setting resumepoints for livestreams for channel in CHANNELS: if ep_id.get('video_id') and ep_id.get('video_id') == channel.get( 'live_stream_id'): log(3, '[PlayerInfo {id}] Avoid setting resumepoints for livestream {video_id}', id=self.thread_id, video_id=ep_id.get('video_id')) self.listen = False # Reset vrtnu_resumepoints property before return set_property('vrtnu_resumepoints', None) return # Get episode data needed to update resumepoints from VRT NU Search API episode = self.apihelper.get_single_episode_data( video_id=ep_id.get('video_id'), whatson_id=ep_id.get('whatson_id'), video_url=ep_id.get('video_url')) # Avoid setting resumepoints without episode data if episode is None: # Reset vrtnu_resumepoints property before return set_property('vrtnu_resumepoints', None) return from metadata import Metadata self.asset_id = Metadata(None, None).get_asset_id(episode) self.title = episode.get('program') self.url = url_to_episode(episode.get('url', '')) self.whatson_id = episode.get( 'whatsonId') or None # Avoid empty string # Kodi 17 doesn't have onAVStarted if kodi_version_major() < 18: self.onAVStarted() def onAVStarted(self): # pylint: disable=invalid-name """Called when Kodi has a video or audiostream""" if not self.listen: return log(3, '[PlayerInfo {id}] Event onAVStarted', id=self.thread_id) self.virtualsubclip_seektozero() self.quit.clear() self.update_position() self.update_total() self.push_upnext() # StreamPosition thread keeps running when watching multiple episode with "Up Next" # only start StreamPosition thread when it doesn't exist yet. if not self.positionthread: self.positionthread = Thread(target=self.stream_position, name='StreamPosition') self.positionthread.start() def onAVChange(self): # pylint: disable=invalid-name """Called when Kodi has a video, audio or subtitle stream. Also happens when the stream changes.""" def onPlayBackSeek(self, time, seekOffset): # pylint: disable=invalid-name """Called when user seeks to a time""" if not self.listen: return log(3, '[PlayerInfo {id}] Event onPlayBackSeek time={time} offset={offset}', id=self.thread_id, time=time, offset=seekOffset) self.last_pos = time // 1000 # If we seek beyond the end, exit Player if self.last_pos >= self.total: self.quit.set() self.stop() def onPlayBackPaused(self): # pylint: disable=invalid-name """Called when user pauses a playing file""" if not self.listen: return log(3, '[PlayerInfo {id}] Event onPlayBackPaused', id=self.thread_id) self.update_position() self.push_position(position=self.last_pos, total=self.total) self.paused = True def onPlayBackResumed(self): # pylint: disable=invalid-name """Called when user resumes a paused file or a next playlist item is started""" if not self.listen: return suffix = 'after pausing' if self.paused else 'after playlist change' log(3, '[PlayerInfo {id}] Event onPlayBackResumed {suffix}', id=self.thread_id, suffix=suffix) self.paused = False def onPlayBackEnded(self): # pylint: disable=invalid-name """Called when Kodi has ended playing a file""" if not self.listen: return self.last_pos = self.total self.quit.set() log(3, '[PlayerInfo {id}] Event onPlayBackEnded', id=self.thread_id) def onPlayBackError(self): # pylint: disable=invalid-name """Called when playback stops due to an error""" if not self.listen: return self.quit.set() log(3, '[PlayerInfo {id}] Event onPlayBackError', id=self.thread_id) def onPlayBackStopped(self): # pylint: disable=invalid-name """Called when user stops Kodi playing a file""" if not self.listen: return self.quit.set() log(3, '[PlayerInfo {id}] Event onPlayBackStopped', id=self.thread_id) def onPlayerExit(self): # pylint: disable=invalid-name """Called when player exits""" log(3, '[PlayerInfo {id}] Event onPlayerExit', id=self.thread_id) self.positionthread = None self.push_position(position=self.last_pos, total=self.total) # Set property to let wait_for_resumepoints function know that update resume is done set_property('vrtnu_resumepoints', 'ready') def stream_position(self): """Get latest stream position while playing""" while self.isPlaying() and not self.quit.is_set(): self.update_position() if self.quit.wait(timeout=0.2): break self.onPlayerExit() def add_upnext(self, video_id): """Add Up Next url to Kodi Player""" # Reset vrtnu_resumepoints property set_property('vrtnu_resumepoints', None) url = url_for('play_upnext', video_id=video_id) self.update_position() self.update_total() if self.isPlaying() and self.total - self.last_pos < 1: log(3, '[PlayerInfo {id}] Add {url} to Kodi Playlist', id=self.thread_id, url=url) PlayList(1).add(url) else: log(3, '[PlayerInfo {id}] Add {url} to Kodi Player', id=self.thread_id, url=url) self.play(url) def push_upnext(self): """Push episode info to Up Next service add-on""" if has_addon('service.upnext') and get_setting_bool( 'useupnext', default=True) and self.isPlaying(): info_tag = self.getVideoInfoTag() next_info = self.apihelper.get_upnext( dict( program=to_unicode(info_tag.getTVShowTitle()), playcount=info_tag.getPlayCount(), rating=info_tag.getRating(), path=self.path, runtime=self.total, )) if next_info: from base64 import b64encode from json import dumps data = [to_unicode(b64encode(dumps(next_info).encode()))] sender = '{addon_id}.SIGNAL'.format(addon_id=addon_id()) notify(sender=sender, message='upnext_data', data=data) def update_position(self): """Update the player position, when possible""" try: self.last_pos = self.getTime() except RuntimeError: pass def virtualsubclip_seektozero(self): """VRT NU already offers some programs (mostly current affairs programs) as video on demand while the program is still being broadcasted live. To do so, a start timestamp is added to the livestream url so the Unified Origin streaming platform knows it should return a time bounded manifest file that indicates the beginning of the program. This is called a Live-to-VOD stream or virtual subclip: https://docs.unified-streaming.com/documentation/vod/player-urls.html#virtual-subclips e.g. https://live-cf-vrt.akamaized.net/groupc/live/8edf3bdf-7db3-41c3-a318-72cb7f82de66/live.isml/.mpd?t=2020-07-20T11:07:00 For some unclear reason the virtual subclip defined by a single start timestamp still behaves as a ordinary livestream and starts at the live edge of the stream. It seems this is not a Kodi or Inputstream Adaptive bug, because other players like THEOplayer or DASH-IF's reference player treat this kind of manifest files the same way. The only difference is that seeking to the beginning of the program is possible. So if the url contains a single start timestamp, we can work around this problem by automatically seeking to the beginning of the program. """ playing_file = self.getPlayingFile() if any(param in playing_file for param in ('?t=', '&t=')): try: # Python 3 from urllib.parse import parse_qs, urlsplit except ImportError: # Python 2 from urlparse import parse_qs, urlsplit import re # Detect single start timestamp timestamp = parse_qs(urlsplit(playing_file).query).get('t')[0] rgx = re.compile(r'^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$') is_single_start_timestamp = bool(re.match(rgx, timestamp)) if is_single_start_timestamp: # Check resume status resume_info = jsonrpc(method='Player.GetItem', params=dict(playerid=1, properties=['resume' ])).get('result') if resume_info: resume_position = resume_info.get('item').get( 'resume').get('position') is_resumed = abs(resume_position - self.getTime()) < 1 # Seek to zero if the user didn't resume the program if not is_resumed: log(3, '[PlayerInfo {id}] Virtual subclip: seeking to the beginning of the program', id=self.thread_id) self.seekTime(0) else: log_error( 'Failed to start virtual subclip {playing_file} at start timestamp', playing_file=playing_file) def update_total(self): """Update the total video time""" try: total = self.getTotalTime() # Kodi Player sometimes returns a total time of 0.0 and this causes unwanted behaviour with VRT NU Resumepoints API. if total > 0.0: self.total = total except RuntimeError: pass def push_position(self, position, total): """Push player position to VRT NU resumepoints API and reload container""" # Not all content has an asset_id if not self.asset_id: return # Push resumepoint to VRT NU self.resumepoints.update(asset_id=self.asset_id, title=self.title, url=self.url, position=position, total=total, whatson_id=self.whatson_id, path=self.path)