CastagnaIT/plugin.video.netflix

View on GitHub
resources/lib/services/nfsession/msl/events_handler.py

Summary

Maintainability
A
35 mins
Test Coverage
# -*- coding: utf-8 -*-
"""
    Copyright (C) 2017 Sebastian Golasch (plugin.video.netflix)
    Copyright (C) 2019 Stefano Gottardo - @CastagnaIT (original implementation module)
    Handle and build Netflix events

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

import xbmc

from resources.lib.database.db_utils import TABLE_SESSION
from resources.lib.globals import G
from resources.lib.services.nfsession.msl import msl_utils
from resources.lib.services.nfsession.msl.msl_utils import (ENDPOINTS, EVENT_ENGAGE, create_req_params)
from resources.lib.utils.esn import get_esn
from resources.lib.utils.logging import LOG

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


class EventsHandler(threading.Thread):
    """Handle and build Netflix event requests"""
    # This threaded class has been designed to handle a queue of HTTP requests in a sort of async way
    # this to avoid to freeze the 'xbmc.Monitor' notifications in the action_controller.py,
    # and also to avoid ugly delay in Kodi GUI when stop event occurs

    def __init__(self, chunked_request, nfsession: 'NFSessionOperations'):
        super().__init__()
        self.chunked_request = chunked_request
        self.nfsession = nfsession
        # session_id, app_id are common to all events
        self.session_id = int(time.time()) * 10000 + random.SystemRandom().randint(1, 10001)
        self.app_id = None
        self.queue_events = queue.Queue(maxsize=10)
        self.cache_data_events = {}
        self.banned_events_ids = []
        self._stop_requested = False
        self.loco_data = None

    def run(self):
        """Monitor and process the event queue"""
        LOG.debug('[Event queue monitor] Thread started')
        monitor = xbmc.Monitor()
        while not monitor.abortRequested() and not self._stop_requested:
            try:
                # Take the first queued item
                event_type, event_data, player_state = self.queue_events.get_nowait()
                # Process the request
                self._process_event_request(event_type, event_data, player_state)
            except queue.Empty:
                pass
            except Exception as exc:  # pylint: disable=broad-except
                LOG.error('[Event queue monitor] An error has occurred: {}', exc)
                import traceback
                LOG.error(traceback.format_exc())
                self.clear_queue()
            monitor.waitForAbort(0.5)

    def _process_event_request(self, event_type, event_data, player_state):
        """Build and make the event post request"""
        # if event_type == EVENT_START:
        #     # We get at every new video playback a fresh LoCo data
        #     self.loco_data = self.nfsession.get_loco_data()
        url = event_data['manifest']['links']['events']['href']
        from resources.lib.services.nfsession.msl.msl_request_builder import MSLRequestBuilder
        request_data = MSLRequestBuilder.build_request_data(url,
                                                            self._build_event_params(event_type,
                                                                                     event_data,
                                                                                     player_state,
                                                                                     event_data['manifest'],
                                                                                     self.loco_data))
        # Request attempts can be made up to a maximum of 3 times per event
        LOG.info('EVENT [{}] - Executing request', event_type)
        endpoint_url = ENDPOINTS['events'] + create_req_params(f'events/{event_type}')
        try:
            response = self.chunked_request(endpoint_url, request_data, get_esn())
            # Malformed/wrong content in requests are ignored without returning any error in the response or exception
            LOG.debug('EVENT [{}] - Request response: {}', event_type, response)
            # if event_type == EVENT_STOP:
            #     # 15/01/2023 update_loco_context looks like not more used on website when playback stop
            #     if event_data['allow_request_update_loco']:
            #         if 'list_context_name' in self.loco_data:
            #             self.nfsession.update_loco_context(
            #                 self.loco_data['root_id'],
            #                 self.loco_data['list_context_name'],
            #                 self.loco_data['list_id'],
            #                 self.loco_data['list_index'])
            #         else:
            #             LOG.debug('EventsHandler: LoCo list not updated no list context data provided')
            #     self.loco_data = None
        except Exception as exc:  # pylint: disable=broad-except
            LOG.error('EVENT [{}] - The request has failed: {}', event_type, exc)
            # Ban future event requests from this event xid
            # self.banned_events_xid.append(request_data['xid'])
            # Todo: this has been disabled because unstable Wi-Fi connections may cause consecutive errors
            #   probably this should be handled in a different way

    def stop_join(self):
        self._stop_requested = True
        self.join()

    def add_event_to_queue(self, event_type, event_data, player_state):
        """Adds an event in the queue of events to be processed"""
        previous_data, _ = self.cache_data_events.get(event_data['videoid'].value, ({}, None))
        if previous_data.get('xid') in self.banned_events_ids:
            LOG.warn('EVENT [{}] - Not added to the queue. The xid {} is banned due to a previous failed request',
                     event_type, previous_data.get('xid'))
            return
        try:
            self.queue_events.put_nowait((event_type, event_data, player_state))
            LOG.debug('EVENT [{}] - Added to queue', event_type)
        except queue.Full:
            LOG.warn('EVENT [{}] - Not added to the queue. The event queue is full.', event_type)

    def clear_queue(self):
        """Clear all queued events"""
        with self.queue_events.mutex:
            self.queue_events.queue.clear()
        self.cache_data_events = {}
        self.banned_events_ids = []

    def _build_event_params(self, event_type, event_data, player_state, manifest, loco_data):  # pylint: disable=unused-argument
        """Build data params for an event request"""
        videoid_value = event_data['videoid'].value
        # Get previous elaborated data of the same video id
        # Some tags must remain unchanged between events
        previous_data, previous_player_state = self.cache_data_events.get(videoid_value, ({}, None))
        timestamp = int(time.time() * 1000)

        # Context location values can be easily viewed from tag data-ui-tracking-context
        # of a preview box in website html
        # play_ctx_location = 'WATCHNOW'
        play_ctx_location = 'MyListAsGallery' if event_data['is_in_mylist'] else 'browseTitles'

        # To now it is not mandatory, we leave support for future changes
        # if event_data['is_played_by_library']:
        #     list_id = 'unknown'
        # else:
        #     list_id = G.LOCAL_DB.get_value('last_menu_id', 'unknown')

        position = player_state['current_pts']
        if position != 1:
            position *= 1000

        if msl_utils.is_media_changed(previous_player_state, player_state):
            play_times, video_track_id, audio_track_id, sub_track_id = msl_utils.build_media_tag(player_state, manifest,
                                                                                                 position)
        else:
            play_times = previous_data['playTimes']
            msl_utils.update_play_times_duration(play_times, player_state)
            video_track_id = previous_data['videoTrackId']
            audio_track_id = previous_data['audioTrackId']
            sub_track_id = previous_data['timedTextTrackId']

        params = {
            'event': event_type,
            'xid': previous_data.get('xid', G.LOCAL_DB.get_value('xid', table=TABLE_SESSION)),
            'position': position,  # Video time elapsed
            'clientTime': timestamp,
            'sessionStartTime': previous_data.get('sessionStartTime', timestamp),
            'videoTrackId': video_track_id,
            'audioTrackId': audio_track_id,
            'timedTextTrackId': sub_track_id,
            'trackId': str(event_data['track_id']),
            'sessionId': str(self.session_id),
            'appId': str(self.app_id or self.session_id),
            'playTimes': play_times,
            'sessionParams': previous_data.get('sessionParams', {
                'isUIAutoPlay': False,  # Should be set equal to the manifest request
                'supportsPreReleasePin': True,  # Should be set equal to the manifest request
                'supportsWatermark': True,  # Should be set equal to the manifest request
                'preferUnletterboxed': False,  # Should be set equal to the manifest request
                'uiplaycontext': {
                    # 'list_id': list_id,  # not mandatory
                    # lolomo_id: use loco root id value
                    'lolomo_id': 'unknown', # loco_data['root_id'],
                    'location': play_ctx_location,
                    'rank': 0,  # Perhaps this is a reference of cdn rank used in the manifest? (we use always 0)
                    'request_id': event_data['request_id'],
                    'row': 0,  # Purpose not known
                    'track_id': event_data['track_id'],
                    'video_id': videoid_value
                },
                'uiVersion': G.LOCAL_DB.get_value('ui_version', '', table=TABLE_SESSION)
            })
        }

        if event_type == EVENT_ENGAGE:
            params['action'] = 'User_Interaction'

        self.cache_data_events[videoid_value] = (params, player_state)
        return params