CastagnaIT/plugin.video.netflix

View on GitHub
resources/lib/services/playback/am_video_events.py

Summary

Maintainability
A
55 mins
Test Coverage
# -*- coding: utf-8 -*-
"""
    Copyright (C) 2017 Sebastian Golasch (plugin.video.netflix)
    Copyright (C) 2019 Stefano Gottardo - @CastagnaIT (original implementation module)
    Manages events to send to the netflix service for the progress of the played video

    SPDX-License-Identifier: MIT
    See LICENSES/MIT.md for more information.
"""
from typing import TYPE_CHECKING

from resources.lib import common
from resources.lib.common.cache_utils import CACHE_BOOKMARKS, CACHE_COMMON, CACHE_MANIFESTS
from resources.lib.common.exceptions import InvalidVideoListTypeError
from resources.lib.globals import G
from resources.lib.services.nfsession.msl.msl_utils import EVENT_ENGAGE, EVENT_START, EVENT_STOP, EVENT_KEEP_ALIVE
from resources.lib.utils.api_paths import build_paths, EVENT_PATHS
from resources.lib.utils.esn import get_esn
from resources.lib.utils.logging import LOG
from .action_manager import ActionManager

if TYPE_CHECKING:  # This variable/imports are used only by the editor, so not at runtime
    from resources.lib.services.nfsession.nfsession_ops import NFSessionOperations
    from resources.lib.services.nfsession.directorybuilder.dir_builder import DirectoryBuilder
    from resources.lib.services.nfsession.msl.msl_handler import MSLHandler


class AMVideoEvents(ActionManager):
    """Detect the progress of the played video and send the data to the netflix service"""

    SETTING_ID = 'sync_watched_status'

    def __init__(self, nfsession: 'NFSessionOperations', msl_handler: 'MSLHandler',
                 directory_builder: 'DirectoryBuilder'):
        super().__init__()
        self.nfsession = nfsession
        self.msl_handler = msl_handler
        self.directory_builder = directory_builder
        self.event_data = {}
        self.is_event_start_sent = False
        self.last_tick_count = 0
        self.tick_elapsed = 0
        self.is_player_in_pause = False
        self.lock_events = False
        self.allow_request_update_loco = False

    def __str__(self):
        return f'enabled={self.enabled}'

    def initialize(self, data):
        if self.videoid.mediatype not in [common.VideoId.MOVIE, common.VideoId.EPISODE]:
            LOG.warn('AMVideoEvents: disabled due to no not supported videoid mediatype')
            self.enabled = False
            return
        if (not data['is_played_from_strm'] or
                (data['is_played_from_strm'] and G.ADDON.getSettingBool('sync_watched_status_library'))):
            self.event_data = self._get_event_data(self.videoid)
            self.event_data['videoid'] = self.videoid
            self.event_data['is_played_by_library'] = data['is_played_from_strm']
        else:
            self.enabled = False

    def on_playback_started(self, player_state):
        self.event_data['manifest'] = _get_manifest(self.videoid)
        # Clear continue watching list data on the cache, to force loading of new data
        # but only when the videoid not exists in the continue watching list
        try:
            videoid_exists, list_id = self.directory_builder.get_continuewatching_videoid_exists(
                str(self.videoid_parent.value))
            if not videoid_exists:
                # Delete the cache of continueWatching list
                G.CACHE.delete(CACHE_COMMON, list_id, including_suffixes=True)
                # When the continueWatching context is invalidated from a refreshListByContext call
                # the LoCo need to be updated to obtain the new list id, so we delete the cache to get new data
                G.CACHE.delete(CACHE_COMMON, 'loco_list')
        except InvalidVideoListTypeError:
            # Ignore possible "No lists with context xxx available" exception due to a new profile without data
            pass

    def on_tick(self, player_state):
        if player_state['nf_is_ads_stream']:
            return
        if self.lock_events:
            return
        if self.is_player_in_pause and (self.tick_elapsed - self.last_tick_count) >= 1800:
            # When the player is paused for more than 30 minutes we interrupt the sending of events (1800secs=30m)
            self._send_event(EVENT_ENGAGE, self.event_data, player_state)
            self._send_event(EVENT_STOP, self.event_data, player_state)
            self.is_event_start_sent = False
            self.lock_events = True
        else:
            if not self.is_event_start_sent:
                # We do not use _on_playback_started() to send EVENT_START, because the action managers
                # AMStreamContinuity and AMPlayback may cause inconsistencies with the content of player_state data

                # When the playback starts for the first time, for correctness should send current_pts value to 1
                if self.tick_elapsed < 5 and self.event_data['resume_position'] is None:
                    player_state['current_pts'] = 1
                self._send_event(EVENT_START, self.event_data, player_state)
                self.is_event_start_sent = True
                self.tick_elapsed = 0
            else:
                # Generate events to send to Netflix service every 1 minute (60secs=1m)
                if (self.tick_elapsed - self.last_tick_count) >= 60:
                    self._send_event(EVENT_KEEP_ALIVE, self.event_data, player_state)
                    self._save_resume_time(player_state['current_pts'])
                    self.last_tick_count = self.tick_elapsed
                    # Allow request of loco update (for continueWatching and bookmark) only after the first minute
                    # it seems that most of the time if sent earlier returns error
                    self.allow_request_update_loco = True
        self.tick_elapsed += 1  # One tick almost always represents one second

    def on_playback_pause(self, player_state):
        if player_state['nf_is_ads_stream']:
            return
        if not self.is_event_start_sent:
            return
        self._reset_tick_count()
        self.is_player_in_pause = True
        self._send_event(EVENT_ENGAGE, self.event_data, player_state)
        self._save_resume_time(player_state['current_pts'])

    def on_playback_resume(self, player_state):
        self.is_player_in_pause = False
        self.lock_events = False

    def on_playback_seek(self, player_state):
        if player_state['nf_is_ads_stream']:
            return
        if not self.is_event_start_sent or self.lock_events:
            # This might happen when the action manager AMPlayback perform a video skip
            return
        self._reset_tick_count()
        self._send_event(EVENT_ENGAGE, self.event_data, player_state)
        self._save_resume_time(player_state['current_pts'])
        self.allow_request_update_loco = True

    def on_playback_stopped(self, player_state):
        if player_state['nf_is_ads_stream']:
            return
        if not self.is_event_start_sent or self.lock_events:
            return
        self._reset_tick_count()
        self._send_event(EVENT_ENGAGE, self.event_data, player_state)
        self._send_event(EVENT_STOP, self.event_data, player_state)
        # Update the resume here may not always work due to race conditions with GUI directory refresh and Stop event
        self._save_resume_time(player_state['current_pts'])

    def _save_resume_time(self, resume_time):
        """Save resume time value in order to update the infolabel cache"""
        # Why this, the video lists are requests to the web service only once and then will be cached in order to
        # quickly get the data and speed up a lot the GUI response.
        # Watched status of a (video) list item is based on resume time, and the resume time is saved in the cache data.
        # To avoid slowing down the GUI by invalidating the cache to get new data from website service, one solution is
        # save the values in memory and override the bookmark value of the infolabel.
        # The callback _on_playback_stopped can not be used, because the loading of frontend happen before.
        G.CACHE.add(CACHE_BOOKMARKS, self.videoid.value, resume_time)

    def _reset_tick_count(self):
        self.tick_elapsed = 0
        self.last_tick_count = 0

    def _send_event(self, event_type, event_data, player_state):
        if not player_state:
            LOG.warn('AMVideoEvents: the event [{}] cannot be sent, missing player_state data', event_type)
            return
        event_data['allow_request_update_loco'] = self.allow_request_update_loco
        self.msl_handler.events_handler_thread.add_event_to_queue(event_type,
                                                                  event_data,
                                                                  player_state)

    def _get_event_data(self, videoid):
        """Get data needed to send event requests to Netflix"""
        is_episode = videoid.mediatype == common.VideoId.EPISODE
        req_videoids = [videoid]
        if is_episode:
            # Get also the tvshow data
            req_videoids.append(videoid.derive_parent(common.VideoId.SHOW))

        raw_data = self._get_video_raw_data(req_videoids)
        if not raw_data:
            return {}
        LOG.debug('Event data: {}', raw_data)
        videoid_data = raw_data['videos'][videoid.value]

        if is_episode:
            # Get inQueue from tvshow data
            is_in_mylist = raw_data['videos'][str(req_videoids[1].value)]['queue'].get('value', {}).get('inQueue', False)
        else:
            is_in_mylist = videoid_data['queue'].get('value', {}).get('inQueue', False)

        bookmark_pos = videoid_data['bookmarkPosition']['value']
        resume_position = bookmark_pos if bookmark_pos > -1 else None
        event_data = {'resume_position': resume_position,
                      'runtime': videoid_data['runtime']['value'],
                      'request_id': videoid_data['requestId']['value'],
                      'watched': videoid_data['watched']['value'],
                      'is_in_mylist': is_in_mylist}
        if videoid.mediatype == common.VideoId.EPISODE:
            event_data['track_id'] = videoid_data['trackIds']['value']['trackId_jawEpisode']
        else:
            event_data['track_id'] = videoid_data['trackIds']['value']['trackId_jaw']
        return event_data

    def _get_video_raw_data(self, videoids):
        """Retrieve raw data for specified video id's"""
        video_ids = [int(videoid.value) for videoid in videoids]
        LOG.debug('Requesting video raw data for {}', video_ids)
        return self.nfsession.path_request(build_paths(['videos', video_ids], EVENT_PATHS))


def _get_manifest(videoid):
    """Get the manifest from cache"""
    cache_identifier = f'{get_esn()}_{videoid.value}'
    return G.CACHE.get(CACHE_MANIFESTS, cache_identifier)