From ffe52071b854d39d51233cad6ebe069ec5b72b08 Mon Sep 17 00:00:00 2001 From: CastagnaIT Date: Sat, 18 Nov 2023 18:39:41 +0100 Subject: [PATCH] wip --- .../lib/services/nfsession/msl/converter.py | 117 ++++++++++--- .../services/nfsession/msl/events_handler.py | 2 +- .../lib/services/nfsession/msl/msl_handler.py | 12 +- .../lib/services/nfsession/msl/msl_utils.py | 2 +- .../services/playback/action_controller.py | 157 ++++++++++++++---- .../lib/services/playback/am_playback.py | 4 +- .../services/playback/am_section_skipping.py | 12 +- .../services/playback/am_stream_continuity.py | 31 ++-- .../services/playback/am_upnext_notifier.py | 20 ++- .../lib/services/playback/am_video_events.py | 20 ++- resources/lib/utils/api_requests.py | 18 +- 11 files changed, 301 insertions(+), 94 deletions(-) diff --git a/resources/lib/services/nfsession/msl/converter.py b/resources/lib/services/nfsession/msl/converter.py index 3c2f12256..d981a1c58 100644 --- a/resources/lib/services/nfsession/msl/converter.py +++ b/resources/lib/services/nfsession/msl/converter.py @@ -21,20 +21,75 @@ def convert_to_dash(manifest): """Convert a Netflix style manifest to MPEG-DASH manifest""" # If a CDN server has stability problems it may cause errors with streaming, # we allow users to select a different CDN server - # (should be managed by ISA but is currently is not implemented) + # (should be managed automatically by add more MPD "BaseURL" tags, but is currently is not implemented in ISA) cdn_index = int(G.ADDON.getSettingString('cdn_server')[-1]) - 1 + mpd_tag = _create_mpd_tag() + + # Netflix ADS appear to have a complex customization with the browser/player this leads us to several headaches + # to be able to implement it in the add-on. + # Things to solve to have a decent ADS playback implementation: + # - Their player, once an ad is displayed is removed from the video timeline in real time, there is no way to do + # a similar thing with Kodi platform. But could be not a big problem, but we need somewhat find a solution + # to know when same ads is played multiple times to avoid send multiple MSL events (see next point) + # - Every time an ADS is played the website player send a MSL event like adStart/adProgress/... in similar way + # as done to send playback progress updates, his data should be related to "adverts/adBreaks" json path + # from MSL manifest data, i think this is used by netflix to know when an ad is displayed for their business. + # Here its difficult know when a specific ads is played and then make a callback to send the MSL event, due to: + # Problem 1: There is a Kodi bug that when a chapter change cause JSON RPC Player.GetProperties api + # to provide wrong info, this problem is reflected also on Kodi GUI + # Problem 2: we should not send multiple times these events because with kodi same ads may be played more times. + # - Manifest DASH conversion problem: Im not sure how to split the main stream in the manifest in to multiple + # periods by injecting the ads in the middle of stream, because usually DASH SegmentBase needs to know the + # segments ranges (e.g. init) that we dont have(?). For now as workaround all ads (periods) are add before the movie. + # - JSON RPC Player.GetProperties chapter bug prevent to have a good management of action_controller.py features + # (such as language track selection) however a bad workaround has been found, + # in addition to being not 100% reliable makes the code more mess... + # - When ADS is played you should prevent the user from skipping ads and also prevent them from forwarding the video + # now this should be managed by InputStream Adaptive addon, then changes to ISA will be required to fix this. + + ads_manifest_list = [] + if 'auxiliaryManifests' in manifest and manifest['auxiliaryManifests']: + # Find auxiliary ADS manifests + ads_manifest_list = [m for m in manifest['auxiliaryManifests'] if 'isAd' in m and m['isAd']] + + total_duration_secs = 0 + for ads_man in ads_manifest_list: + total_duration_secs += _add_period(mpd_tag, ads_man, cdn_index, total_duration_secs, False) + + total_duration_secs += _add_period(mpd_tag, manifest, cdn_index, total_duration_secs, True) + + mpd_tag.attrib['mediaPresentationDuration'] = _convert_secs_to_time(total_duration_secs) + + xml = ET.tostring(mpd_tag, encoding='utf-8', method='xml') + if LOG.is_enabled: + common.save_file_def('manifest.mpd', xml) + return xml.decode('utf-8').replace('\n', '').replace('\r', '').encode('utf-8') - seconds = manifest['duration'] / 1000 - duration = "PT" + str(int(seconds)) + ".00S" - root = _mpd_manifest_root(duration) - period = ET.SubElement(root, 'Period', start='PT0S', duration=duration) +def _add_period(mpd_tag, manifest, cdn_index, start_pts, add_pts_to_track_name): + seconds = int(manifest['duration'] / 1000) + movie_id = str(manifest['movieId']) + is_ads_stream = 'isAd' in manifest and manifest['isAd'] + if is_ads_stream: + movie_id += '_ads' + period_tag = ET.SubElement(mpd_tag, 'Period', id=movie_id, start=_convert_secs_to_time(start_pts), + duration=_convert_secs_to_time(seconds)) + + if is_ads_stream: # Custom ADS signal + # todo: could be used in future by ISAdaptive to identify ADS period, will require ISAdaptive implementation + ET.SubElement(period_tag, # Parent + 'EventStream', # Tag + schemeIdUri='urn:scte:scte35:2013:xml', + value='ads') has_video_drm_streams = manifest['video_tracks'][0].get('hasDrmStreams', False) video_protection_info = _get_protection_info(manifest['video_tracks'][0]) if has_video_drm_streams else None - for video_track in manifest['video_tracks']: - _convert_video_track(video_track, period, video_protection_info, has_video_drm_streams, cdn_index) + if not add_pts_to_track_name: # workaround for kodi bug, see action_controller.py + start_pts = 0 + for index, video_track in enumerate(manifest['video_tracks']): + _convert_video_track(index, video_track, period_tag, video_protection_info, has_video_drm_streams, cdn_index, + movie_id, start_pts) common.apply_lang_code_changes(manifest['audio_tracks']) common.apply_lang_code_changes(manifest['timedtexttracks']) @@ -42,28 +97,28 @@ def convert_to_dash(manifest): has_audio_drm_streams = manifest['audio_tracks'][0].get('hasDrmStreams', False) id_default_audio_tracks = _get_id_default_audio_tracks(manifest) - for audio_track in manifest['audio_tracks']: + for index, audio_track in enumerate(manifest['audio_tracks']): is_default = audio_track['id'] == id_default_audio_tracks - _convert_audio_track(audio_track, period, is_default, has_audio_drm_streams, cdn_index) + _convert_audio_track(index, audio_track, period_tag, is_default, has_audio_drm_streams, cdn_index) - for text_track in manifest['timedtexttracks']: + for index, text_track in enumerate(manifest['timedtexttracks']): if text_track['isNoneTrack']: continue is_default = _is_default_subtitle(manifest, text_track) - _convert_text_track(text_track, period, is_default, cdn_index) + _convert_text_track(index, text_track, period_tag, is_default, cdn_index) - xml = ET.tostring(root, encoding='utf-8', method='xml') - if LOG.is_enabled: - common.save_file_def('manifest.mpd', xml) - return xml.decode('utf-8').replace('\n', '').replace('\r', '').encode('utf-8') + return seconds + + +def _convert_secs_to_time(secs): + return "PT" + str(int(secs)) + ".00S" -def _mpd_manifest_root(duration): - root = ET.Element('MPD') - root.attrib['xmlns'] = 'urn:mpeg:dash:schema:mpd:2011' - root.attrib['xmlns:cenc'] = 'urn:mpeg:cenc:2013' - root.attrib['mediaPresentationDuration'] = duration - return root +def _create_mpd_tag(): + mpd_tag = ET.Element('MPD') + mpd_tag.attrib['xmlns'] = 'urn:mpeg:dash:schema:mpd:2011' + mpd_tag.attrib['xmlns:cenc'] = 'urn:mpeg:cenc:2013' + return mpd_tag def _add_base_url(representation, base_url): @@ -133,10 +188,11 @@ def _add_protection_info(video_track, adaptation_set, pssh, keyid): ET.SubElement(protection, 'cenc:pssh').text = pssh -def _convert_video_track(video_track, period, protection, has_drm_streams, cdn_index): +def _convert_video_track(index, video_track, period, protection, has_drm_streams, cdn_index, movie_id, pts_offset): adaptation_set = ET.SubElement( period, # Parent 'AdaptationSet', # Tag + id=str(index), mimeType='video/mp4', contentType='video') if protection: @@ -151,12 +207,17 @@ def _convert_video_track(video_track, period, protection, has_drm_streams, cdn_i if int(downloadable['res_h']) > limit_res: continue _convert_video_downloadable(downloadable, adaptation_set, cdn_index) + # Set the name to the AdaptationSet tag + # this will become the name of the video stream, that can be read in the Kodi GUI on the video stream track list + # and can be read also by using jsonrpc Player.GetProperties "videostreams" used by action_controller.py + name = f"(Id {movie_id})(pts offset {pts_offset})" # Calculate the crop factor, will be used on am_playback.py to set zoom viewmode try: factor = video_track['maxHeight'] / video_track['maxCroppedHeight'] - adaptation_set.set('name', f'(Crop {factor:0.2f})') + name += f'(Crop {factor:0.2f})' except Exception as exc: # pylint: disable=broad-except LOG.error('Cannot calculate crop factor: {}', exc) + adaptation_set.set('name', name) def _limit_video_resolution(video_tracks, has_drm_streams): @@ -210,12 +271,12 @@ def _determine_video_codec(content_profile): if content_profile.startswith('vp9'): return f'vp9.{content_profile[11:12]}' if 'av1' in content_profile: - return 'av1' + return 'av01' return 'h264' # pylint: disable=unused-argument -def _convert_audio_track(audio_track, period, default, has_drm_streams, cdn_index): +def _convert_audio_track(index, audio_track, period, default, has_drm_streams, cdn_index): channels_count = {'1.0': '1', '2.0': '2', '5.1': '6', '7.1': '8'} impaired = 'true' if audio_track['trackType'] == 'ASSISTIVE' else 'false' original = 'true' if audio_track['isNative'] else 'false' @@ -224,6 +285,7 @@ def _convert_audio_track(audio_track, period, default, has_drm_streams, cdn_inde adaptation_set = ET.SubElement( period, # Parent 'AdaptationSet', # Tag + id=str(index), lang=audio_track['language'], contentType='audio', mimeType='audio/mp4', @@ -242,7 +304,7 @@ def _convert_audio_track(audio_track, period, default, has_drm_streams, cdn_inde def _convert_audio_downloadable(downloadable, adaptation_set, channels_count, cdn_index): - codec_type = 'aac' + codec_type = 'mp4a.40.5' # he-aac if 'ddplus-' in downloadable['content_profile'] or 'dd-' in downloadable['content_profile']: codec_type = 'ec-3' representation = ET.SubElement( @@ -261,7 +323,7 @@ def _convert_audio_downloadable(downloadable, adaptation_set, channels_count, cd _add_segment_base(representation, downloadable) -def _convert_text_track(text_track, period, default, cdn_index): +def _convert_text_track(index, text_track, period, default, cdn_index): # Only one subtitle representation per adaptationset downloadable = text_track.get('ttDownloadables') if not text_track: @@ -274,6 +336,7 @@ def _convert_text_track(text_track, period, default, cdn_index): adaptation_set = ET.SubElement( period, # Parent 'AdaptationSet', # Tag + id=str(index), lang=text_track['language'], codecs=('stpp', 'wvtt')[is_ios8], contentType='text', diff --git a/resources/lib/services/nfsession/msl/events_handler.py b/resources/lib/services/nfsession/msl/events_handler.py index c5db01b95..adbd6b3c8 100644 --- a/resources/lib/services/nfsession/msl/events_handler.py +++ b/resources/lib/services/nfsession/msl/events_handler.py @@ -146,7 +146,7 @@ def _build_event_params(self, event_type, event_data, player_state, manifest, lo # else: # list_id = G.LOCAL_DB.get_value('last_menu_id', 'unknown') - position = player_state['elapsed_seconds'] + position = player_state['current_pts'] if position != 1: position *= 1000 diff --git a/resources/lib/services/nfsession/msl/msl_handler.py b/resources/lib/services/nfsession/msl/msl_handler.py index 1f7ff0a9c..5030dc755 100644 --- a/resources/lib/services/nfsession/msl/msl_handler.py +++ b/resources/lib/services/nfsession/msl/msl_handler.py @@ -123,10 +123,12 @@ def get_manifest(self, viewable_id, challenge, sid): 'This problem could be solved in the future, but at the moment there is no solution.') raise ErrorMsgNoReport(err_msg) from exc raise - if manifest.get('adverts', {}).get('adBreaks', []): - # Todo: manifest converter should handle ads streams with additional DASH periods - raise ErrorMsgNoReport('This add-on dont support playback videos with ads. ' - 'Please use an account plan without ads.') + if G.KODI_VERSION < 20 and manifest.get('adverts', {}).get('adBreaks', []): + # InputStream Adaptive version on Kodi 19 is too old and dont handle correctly these manifests + raise ErrorMsgNoReport('On Kodi 19 the Netflix ADS plans are not supported. \n' + 'You must use Kodi 20 or higher versions.') + if manifest.get('streamingType', 'VOD') != 'VOD': + raise ErrorMsgNoReport('Live videos are not supported.') return self._tranform_to_dash(manifest) @measure_exec_time_decorator(is_immediate=True) @@ -285,7 +287,7 @@ def _build_manifest_v2(self, **kwargs): 'requestSegmentVmaf': False, 'supportsPartialHydration': False, 'contentPlaygraph': ['start'], - 'supportsAdBreakHydration': False, + 'supportsAdBreakHydration': True, # True if this client support separate ADS management, false to use ADS merged on stream (next future?) currently disallowed due to feature not implemented on server side 'liveMetadataFormat': 'INDEXED_SEGMENT_TEMPLATE', 'useBetterTextUrls': True, 'profileGroups': [{ diff --git a/resources/lib/services/nfsession/msl/msl_utils.py b/resources/lib/services/nfsession/msl/msl_utils.py index 8e9eda488..123d081a9 100644 --- a/resources/lib/services/nfsession/msl/msl_utils.py +++ b/resources/lib/services/nfsession/msl/msl_utils.py @@ -84,7 +84,7 @@ def is_media_changed(previous_player_state, player_state): def update_play_times_duration(play_times, player_state): """Update the playTimes duration values""" - duration = player_state['elapsed_seconds'] * 1000 + duration = player_state['current_pts'] * 1000 play_times['total'] = duration play_times['audio'][0]['duration'] = duration play_times['video'][0]['duration'] = duration diff --git a/resources/lib/services/playback/action_controller.py b/resources/lib/services/playback/action_controller.py index 864c7252a..25408e3ad 100644 --- a/resources/lib/services/playback/action_controller.py +++ b/resources/lib/services/playback/action_controller.py @@ -8,6 +8,7 @@ See LICENSES/MIT.md for more information. """ import json +import re import threading import time from typing import TYPE_CHECKING @@ -15,6 +16,7 @@ import xbmc import resources.lib.common as common +from resources.lib.database.db_utils import TABLE_SESSION from resources.lib.globals import G from resources.lib.kodi import ui from resources.lib.utils.logging import LOG @@ -51,6 +53,8 @@ def __init__(self, nfsession: 'NFSessionOperations', msl_handler: 'MSLHandler', self._is_pause_called = False self._is_av_started = False self._av_change_last_ts = None + self._is_delayed_seek = False + self._is_ads_plan = G.LOCAL_DB.get_value('isAdsPlan', None, table=TABLE_SESSION) common.register_slot(self.initialize_playback, common.Signals.PLAYBACK_INITIATED, is_signal=True) def initialize_playback(self, **kwargs): @@ -67,6 +71,7 @@ def _initialize_am(self): self._last_player_state = {} self._is_pause_called = False self._av_change_last_ts = None + self._is_delayed_seek = False if not self._init_data: return self.action_managers = [ @@ -84,6 +89,7 @@ def onNotification(self, sender, method, data): # pylint: disable=unused-argume """ Callback for Kodi notifications that handles and dispatches playback events """ + LOG.warn('ActionController: onNotification {} -- {}', method, data) # WARNING: Do not get playerid from 'data', # Because when Up Next add-on play a video while we are inside Netflix add-on and # not externally like Kodi library, the playerid become -1 this id does not exist @@ -104,7 +110,17 @@ def onNotification(self, sender, method, data): # pylint: disable=unused-argume self._playback_tick.daemon = True self._playback_tick.start() elif method == 'Player.OnSeek': - self._on_playback_seek(json.loads(data)['player']['time']) + if self._is_ads_plan: + # Workaround: + # Due to Kodi bug see JSONRPC "Player.GetProperties" info below, + # when a user do video seek while watching ADS parts, will change chapter and we receive "Player.OnSeek" + # but if we execute self._on_playback_seek immediately it will call JSONRPC "Player.GetProperties" + # that provide wrong data, so we have to delay it until we receive last "Player.OnAVChange" event + # at that time InputStreamAdaptive should have provided to kodi the streaming data and then + # JSONRPC "Player.GetProperties" should return the right data, at least most of the time + self._is_delayed_seek = True + else: + self._on_playback_seek(json.loads(data)['player']['time']) elif method == 'Player.OnPause': self._is_pause_called = True self._on_playback_pause() @@ -136,7 +152,11 @@ def onNotification(self, sender, method, data): # pylint: disable=unused-argume return self._on_playback_stopped() elif method == 'Player.OnAVChange': - if self._is_av_started: + # OnAVChange event can be sent by Kodi multiple times in a very short period of time, + # one event per stream type (audio/video/subs) so depends on what stream kodi core request to ISAdaptive + # this will try group all these events in a single one by storing the current time, + # it's not a so safe solution, and also delay things about 2 secs, atm i have not found anything better + if self._is_av_started or self._is_delayed_seek: self._av_change_last_ts = time.time() except Exception: # pylint: disable=broad-except import traceback @@ -156,14 +176,20 @@ def on_playback_tick(self): player_state = self._get_player_state() if not player_state: return - self._notify_all(ActionManager.call_on_tick, player_state) + # If we are waiting for OnAVChange events, dont send call_on_tick otherwise will mix old/new player_state info if not self._av_change_last_ts: - return - # av-change event can be sent by Kodi multiple times in a very short period of time, - # so we try group all these events in a single one by delaying it - if (time.time() - self._av_change_last_ts) > 1: - self._av_change_last_ts = None - self._on_avchange_delayed(player_state) + self._notify_all(ActionManager.call_on_tick, player_state) + else: + # If more than 1 second has elapsed since the last OnAVChange event received, process the following + # usually 1 sec is enough time to receive up to 3 OnAVChange events (audio/video/subs) + if (time.time() - self._av_change_last_ts) > 1: + if self._is_av_started: + self._is_av_started = False + self._on_avchange_delayed(player_state) + if self._is_delayed_seek: + self._is_delayed_seek = False + self._on_playback_seek(None) + self._av_change_last_ts = None def _on_avchange_delayed(self, player_state): self._notify_all(ActionManager.call_on_avchange_delayed, player_state) @@ -215,6 +241,29 @@ def _notify_all(self, notification, data=None): _notify_managers(manager, notification, data) def _get_player_state(self, player_id=None, time_override=None): + # !! WARNING KODI BUG ON: Player.GetProperties and KODI CORE / GUI, FOR STREAMS WITH ADS CHAPTERS !! + # todo: TO TAKE IN ACCOUNT FOR FUTURE ADS IMPROVEMENTS <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< + # When you are playing a stream with more chapters due to ADS, + # every time a chapter is ended and start the next one (chapter change) InputStream Adaptive add-on send the + # DEMUX_SPECIALID_STREAMCHANGE packet to Kodi buffer to signal the chapter change, but Kodi core instead of + # follow the stream buffer apply immediately the chapter change, this means e.g. that while you are watching an + # ADS, you can see on Kodi info GUI that the chapter is changed in advance (that should not happens) + # this will cause problems also on the JSON RPC Player.GetProperties, will no longer provide info of what the + # player is playing, but provides future information... therefore we have completely wrong playing info! + # Needless to say, this causes a huge mess with all addon features managed here... + + # A bad hack workaround solution: + # 1) With the DASH manifest converter (converter.py), we include to each chapter name a custom info to know what + # chapter is an ADS and the offset PTS of when it starts, this custom info is inserted in the "name" + # attribute of each Period/AdaptationSet tag with following format "(Id {movie_id})(pts offset {pts_offset})" + # 2) We can get the custom info above, here as video stream name + # 3) Being that the info retrieved JSON RPC Player.GetProperties could be "future" info and not the current + # played, the only reliable value will be the current time, therefore if the pts_offset is ahead of the + # current play time then (since ADS are placed all before the movie) means that kodi is still playing an ADS + # 4) If the 3rd point result in an ADS, we force "nf_is_ads_stream" value on "player_state" to be True. + + # So each addon feature, BEFORE doing any operation MUST check always if "nf_is_ads_stream" value on + # "player_state" is True, to prevent process wrong player_state info try: player_state = common.json_rpc('Player.GetProperties', { 'playerid': self.active_player_id if player_id is None else player_id, @@ -232,33 +281,75 @@ def _get_player_state(self, player_id=None, time_override=None): except IOError as exc: LOG.warn('_get_player_state: {}', exc) return {} + if not player_state['currentaudiostream'] and player_state['audiostreams']: + return {} # if audio stream has not been loaded yet, there is empty currentaudiostream + if not player_state['currentsubtitle'] and player_state['subtitles']: + return {} # if subtitle stream has not been loaded yet, there is empty currentsubtitle + try: + player_state['playerid'] = self.active_player_id if player_id is None else player_id + # convert time dict to elapsed seconds + player_state['elapsed_seconds'] = (player_state['time']['hours'] * 3600 + + player_state['time']['minutes'] * 60 + + player_state['time']['seconds']) - player_state['playerid'] = self.active_player_id if player_id is None else player_id - # convert time dict to elapsed seconds - player_state['elapsed_seconds'] = (player_state['time']['hours'] * 3600 + - player_state['time']['minutes'] * 60 + - player_state['time']['seconds']) - - if time_override: - player_state['time'] = time_override - elapsed_seconds = (time_override['hours'] * 3600 + - time_override['minutes'] * 60 + - time_override['seconds']) - player_state['percentage'] = player_state['percentage'] / player_state['elapsed_seconds'] * elapsed_seconds - player_state['elapsed_seconds'] = elapsed_seconds + if time_override: + player_state['time'] = time_override + elapsed_seconds = (time_override['hours'] * 3600 + + time_override['minutes'] * 60 + + time_override['seconds']) + player_state['percentage'] = player_state['percentage'] / player_state[ + 'elapsed_seconds'] * elapsed_seconds + player_state['elapsed_seconds'] = elapsed_seconds - # Sometimes may happen that when you stop playback the player status is partial, - # this is because the Kodi player stop immediately but the stop notification (from the Monitor) - # arrives late, meanwhile in this interval of time a service tick may occur. - if ((player_state['audiostreams'] and player_state['elapsed_seconds']) or - (player_state['audiostreams'] and not player_state['elapsed_seconds'] and not self._last_player_state)): - # save player state - self._last_player_state = player_state - else: - # use saved player state - player_state = self._last_player_state + # Sometimes may happen that when you stop playback the player status is partial, + # this is because the Kodi player stop immediately but the stop notification (from the Monitor) + # arrives late, meanwhile in this interval of time a service tick may occur. + if ((player_state['audiostreams'] and player_state['elapsed_seconds']) or + (player_state['audiostreams'] and not player_state[ + 'elapsed_seconds'] and not self._last_player_state)): + # save player state + self._last_player_state = player_state + else: + # use saved player state + player_state = self._last_player_state - return player_state + # Get additional video track info added in the track name + # These info are come from "name" attribute of "AdaptationSet" tag in the DASH manifest (see converter.py) + video_stream = player_state['videostreams'][0] + # Try to find the crop info from the track name + result = re.search(r'\(Crop (\d+\.\d+)\)', video_stream['name']) + player_state['nf_video_crop_factor'] = float(result.group(1)) if result else None + # Try to find the video id from the track name (may change if ADS video parts are played) + result = re.search(r'\(Id (\d+)(_[a-z]+)?\)', video_stream['name']) + player_state['nf_stream_videoid'] = result.group(1) if result else None + # Try to find the PTS offset from the track name + # The pts offset value is used with the ADS plan only, it provides the offset where the played chapter start + result = re.search(r'\(pts offset (\d+)\)', video_stream['name']) + pts_offset = 0 + if result: + pts_offset = int(result.group(1)) + player_state['nf_is_ads_stream'] = 'ads' in video_stream['name'] + # Since the JSON RPC Player.GetProperties can provide wrongly info of not yet played chapter (the next one) + # to check if the info retrieved by Player.GetProperties are they really referred about what is displayed on + # the screen or not, by checking if the "pts_offset" does not exceed the current time... + # ofc we do this check only when the last chapter is the "movie", because the ADS are placed all before it + # (so when 'nf_is_ads_stream' is false) + if not player_state['nf_is_ads_stream'] and pts_offset != 0 and player_state['elapsed_seconds'] <= pts_offset: + player_state['nf_is_ads_stream'] = True # Force as ADS, because Player.GetProperties provided wrong info + player_state['current_pts'] = player_state['elapsed_seconds'] + else: + # "current_pts" is the current player time without the duration of ADS video parts chapters (if any) + # ADS chapters are always placed before the "movie", + # addon features should never work with ADS chapters then must be excluded from current PTS + player_state['current_pts'] = player_state['elapsed_seconds'] - pts_offset + player_state['nf_pts_offset'] = pts_offset + return player_state + except Exception: # pylint: disable=broad-except + # For example may fail when buffering video + LOG.warn('_get_player_state fails with data: {}', player_state) + import traceback + LOG.error(traceback.format_exc()) + return {} def _notify_managers(manager, notification, data): diff --git a/resources/lib/services/playback/am_playback.py b/resources/lib/services/playback/am_playback.py index c06424e32..0f61afc6b 100644 --- a/resources/lib/services/playback/am_playback.py +++ b/resources/lib/services/playback/am_playback.py @@ -85,6 +85,8 @@ def on_playback_resume(self, player_state): self.is_player_in_pause = False def on_playback_stopped(self, player_state): + if player_state['nf_is_ads_stream']: + return # It could happen that Kodi does not assign as watched a video, # this because the credits can take too much time, then the point where playback is stopped # falls in the part that kodi recognizes as unwatched (playcountminimumpercent 90% + no-mans land 2%) @@ -92,7 +94,7 @@ def on_playback_stopped(self, player_state): # In these cases we try change/fix manually the watched status of the video by using netflix offset data if int(player_state['percentage']) > 92: return - if not self.watched_threshold or not player_state['elapsed_seconds'] > self.watched_threshold: + if not self.watched_threshold or not player_state['current_pts'] > self.watched_threshold: return if G.ADDON.getSettingBool('sync_watched_status') and not self.is_played_from_strm: # This have not to be applied with our custom watched status of Netflix sync, within the addon diff --git a/resources/lib/services/playback/am_section_skipping.py b/resources/lib/services/playback/am_section_skipping.py index bc649d39f..6c47b9897 100644 --- a/resources/lib/services/playback/am_section_skipping.py +++ b/resources/lib/services/playback/am_section_skipping.py @@ -29,6 +29,7 @@ def __init__(self): self.markers = {} self.auto_skip = False self.pause_on_skip = False + self.pts_offset = 0 def __str__(self): return f'enabled={self.enabled}, markers={self.markers}, auto_skip={self.auto_skip}, pause_on_skip={self.pause_on_skip}' @@ -39,8 +40,11 @@ def initialize(self, data): self.pause_on_skip = G.ADDON.getSettingBool('pause_on_skip') def on_tick(self, player_state): + if player_state['nf_is_ads_stream']: + return + self.pts_offset = player_state['nf_pts_offset'] for section in SKIPPABLE_SECTIONS: - self._check_section(section, player_state['elapsed_seconds']) + self._check_section(section, player_state['current_pts']) def _check_section(self, section, elapsed): if self.markers.get(section) and self.markers[section]['start'] <= elapsed <= self.markers[section]['end']: @@ -62,18 +66,18 @@ def _auto_skip(self, section): if self.pause_on_skip: player.pause() xbmc.sleep(1000) # give kodi the chance to execute - player.seekTime(self.markers[section]['end']) + player.seekTime(self.markers[section]['end'] + self.pts_offset) xbmc.sleep(1000) # give kodi the chance to execute player.pause() # unpause playback at seek position else: - player.seekTime(self.markers[section]['end']) + player.seekTime(self.markers[section]['end'] + self.pts_offset) def _ask_to_skip(self, section): LOG.debug('Asking to skip {}', section) dialog_duration = (self.markers[section]['end'] - self.markers[section]['start']) ui.show_skip_dialog(dialog_duration, - seek_time=self.markers[section]['end'], + seek_time=self.markers[section]['end'] + self.pts_offset, label=common.get_local_string(SKIPPABLE_SECTIONS[section])) def on_playback_stopped(self, player_state): diff --git a/resources/lib/services/playback/am_stream_continuity.py b/resources/lib/services/playback/am_stream_continuity.py index 969013e57..0844b9476 100644 --- a/resources/lib/services/playback/am_stream_continuity.py +++ b/resources/lib/services/playback/am_stream_continuity.py @@ -9,7 +9,6 @@ See LICENSES/MIT.md for more information. """ import copy -import re import xbmc @@ -61,6 +60,7 @@ def __init__(self): self.is_kodi_forced_subtitles_only = None self.is_prefer_alternative_lang = None self.ignore_av_change_event = False + self.need_delay_init = False def __str__(self): return f'enabled={self.enabled}, videoid_parent={self.videoid_parent}' @@ -70,6 +70,12 @@ def initialize(self, data): self.is_prefer_alternative_lang = G.ADDON.getSettingBool('prefer_alternative_lang') def on_playback_started(self, player_state): # pylint: disable=too-many-branches + if player_state['nf_is_ads_stream']: + self.need_delay_init = True + else: + self._init(player_state) + + def _init(self, player_state): # pylint: disable=too-many-branches is_enabled = G.ADDON.getSettingBool('StreamContinuityManager_enabled') # remember audio/subtitle preferences if is_enabled: # Get user saved preferences @@ -146,6 +152,11 @@ def on_playback_started(self, player_state): # pylint: disable=too-many-branche def on_tick(self, player_state): self.player_state = player_state + if player_state['nf_is_ads_stream']: + return + if self.need_delay_init: + self._init(player_state) + self.need_delay_init = False # Check if the audio stream is changed current_stream = self.current_streams['audio'] player_stream = player_state.get(STREAMS['audio']['current']) @@ -184,6 +195,8 @@ def on_tick(self, player_state): LOG.debug('subtitleenabled has changed from {} to {}', current_stream, player_stream) def on_playback_avchange_delayed(self, player_state): + if player_state['nf_is_ads_stream']: + return if self.ignore_av_change_event: self.ignore_av_change_event = False return @@ -492,12 +505,10 @@ def _determine_fixed_zoom_factor(player_state): # Calculate the zoom factor based on the percentage of the portion of the screen which black bands can occupy, # by taking in account that each video may have a different crop value. blackbar_perc = G.ADDON.getSettingInt('blackbars_minimizer_value') - # Try to find the crop info to the track name - stream = player_state['videostreams'][0] - result = re.match(r'\(Crop (\d+\.\d+)\)', stream['name']) + crop_factor = player_state['nf_video_crop_factor'] zoom_factor = 1.0 - if result: - crop_factor = float(result.group(1)) + if crop_factor: + stream = player_state['videostreams'][0] stream_height = stream['height'] video_height = stream['height'] / crop_factor blackbar_px = stream_height - video_height @@ -513,12 +524,10 @@ def _determine_relative_zoom_factor(player_state): # NOTE: Has been chosen to calculate the factor by using video height px instead of black bands height px # to have a more short scale for the user setting blackbar_perc = G.ADDON.getSettingInt('blackbars_minimizer_value') - # Try to find the crop info to the track name - stream = player_state['videostreams'][0] - result = re.match(r'\(Crop (\d+\.\d+)\)', stream['name']) + crop_factor = player_state['nf_video_crop_factor'] zoom_factor = 1.0 - if result: - crop_factor = float(result.group(1)) + if crop_factor: + stream = player_state['videostreams'][0] stream_height = stream['height'] video_height = stream['height'] / crop_factor video_zoomed_h = video_height + (video_height / 100 * blackbar_perc) diff --git a/resources/lib/services/playback/am_upnext_notifier.py b/resources/lib/services/playback/am_upnext_notifier.py index 9eb10d5a4..6040f8023 100644 --- a/resources/lib/services/playback/am_upnext_notifier.py +++ b/resources/lib/services/playback/am_upnext_notifier.py @@ -34,6 +34,7 @@ def __init__(self, nfsession: 'NFSessionOperations'): super().__init__() self.nfsession = nfsession self.upnext_info = None + self.need_delay_init = False def __str__(self): return f'enabled={self.enabled}' @@ -58,7 +59,23 @@ def initialize(self, data): LOG.warn('Up Next add-on signal skipped, the videoid for the next episode does not exist in the database') def on_playback_started(self, player_state): # pylint: disable=unused-argument + if player_state['nf_is_ads_stream']: + self.need_delay_init = True + return + self._init() + + def on_tick(self, player_state): + if player_state['nf_is_ads_stream']: + return + if self.need_delay_init: + self._init(player_state['nf_pts_offset']) + self.need_delay_init = False + + def _init(self, pts_offset=0): if self.upnext_info: + if pts_offset > 0 and 'notification_offset' in self.upnext_info: + # Fix notification offset + self.upnext_info['notification_offset'] += pts_offset LOG.debug('Sending initialization signal to Up Next Add-on') import AddonSignals AddonSignals.sendSignal( @@ -66,9 +83,6 @@ def on_playback_started(self, player_state): # pylint: disable=unused-argument signal='upnext_data', data=self.upnext_info) - def on_tick(self, player_state): - pass - def _get_upnext_info(self, videoid_next_ep, info_next_ep, metadata, is_played_from_strm): """Get the data to send to Up Next add-on""" upnext_info = { diff --git a/resources/lib/services/playback/am_video_events.py b/resources/lib/services/playback/am_video_events.py index 9b846c2b5..a8823d181 100644 --- a/resources/lib/services/playback/am_video_events.py +++ b/resources/lib/services/playback/am_video_events.py @@ -78,6 +78,8 @@ def on_playback_started(self, player_state): 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: @@ -91,9 +93,9 @@ def on_tick(self, player_state): # 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 elapsed_seconds value to 1 + # 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['elapsed_seconds'] = 1 + 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 @@ -101,7 +103,7 @@ def on_tick(self, player_state): # 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['elapsed_seconds']) + 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 @@ -109,34 +111,40 @@ def on_tick(self, player_state): 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['elapsed_seconds']) + 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['elapsed_seconds']) + 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['elapsed_seconds']) + 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""" diff --git a/resources/lib/utils/api_requests.py b/resources/lib/utils/api_requests.py index b75039823..69253097d 100644 --- a/resources/lib/utils/api_requests.py +++ b/resources/lib/utils/api_requests.py @@ -15,6 +15,7 @@ ErrorMsg) from .api_paths import EPISODES_PARTIAL_PATHS, ART_PARTIAL_PATHS, build_paths from .logging import LOG, measure_exec_time_decorator +from ..database.db_utils import TABLE_SESSION def logout(): @@ -25,8 +26,12 @@ def logout(): def login(ask_credentials=True): """Perform a login""" try: + is_success = False credentials = None is_login_with_credentials = True + # The database 'isAdsPlan' value is stored after the login, so the first time we have None value + # this avoids to show the notice message multiple times if more login attempts will be done over the time + show_ads_notice = G.LOCAL_DB.get_value('isAdsPlan', None, table=TABLE_SESSION) is None if ask_credentials: is_login_with_credentials = ui.show_yesno_dialog('Login', common.get_local_string(30340), yeslabel=common.get_local_string(30341), @@ -35,14 +40,23 @@ def login(ask_credentials=True): credentials = {'credentials': ui.ask_credentials()} if is_login_with_credentials: if common.make_call('login', credentials): - return True + is_success = True else: data = common.run_nf_authentication_key() if not data: raise MissingCredentialsError password = ui.ask_for_password() if password and common.make_call('login_auth_data', {'data': data, 'password': password}): - return True + is_success = True + if is_success: + if show_ads_notice and G.LOCAL_DB.get_value('isAdsPlan', False, table=TABLE_SESSION): + from resources.lib.kodi.ui import show_ok_dialog + show_ok_dialog('Netflix - ADS plan', + 'ADS PLAN support is EXPERIMENTAL! You may experience of ' + 'malfunctions of add-on features (e.g. language selection).\n' + 'ADS will be displayed at the beginning of the videos to allow the add-on ' + 'to work properly. Press OK to agree to these terms.') + return True except MissingCredentialsError: # Aborted from user or leave an empty field ui.show_notification(common.get_local_string(30112))