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_str = 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.episode_id = None
        self.episode_title = None
        self.video_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.video_id = None
        self.episode_id = None
        self.episode_title = None
        self.asset_str = None
        self.title = 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.video_id = episode.get('videoId') or None
        self.episode_id = episode.get('episodeId') or None
        self.episode_title = episode.get('title') or None
        self.asset_str = Metadata(None, None).get_asset_str(episode)
        self.title = episode.get('program')

        # 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 video_id
        if not self.video_id:
            return

        # Push resumepoint to VRT NU
        self.resumepoints.update_resumepoint(video_id=self.video_id,
                                             asset_str=self.asset_str,
                                             title=self.title,
                                             position=position,
                                             total=total,
                                             path=self.path,
                                             episode_id=self.episode_id,
                                             episode_title=self.episode_title)
Beispiel #3
0
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)