Skip to content

Commit

Permalink
wip
Browse files Browse the repository at this point in the history
  • Loading branch information
CastagnaIT committed Oct 26, 2023
1 parent d11ac04 commit a3e754c
Show file tree
Hide file tree
Showing 10 changed files with 181 additions and 54 deletions.
108 changes: 81 additions & 27 deletions resources/lib/services/nfsession/msl/converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,49 +21,95 @@ 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 this 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: Player.GetProperties kodi api used in action_controller.py can provide wrong data.
# 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 before the movie.
# - If above problem are somewhat solved, there is to find a solution for action_controller.py and his features
# in order to know when an ads is currently played, this to avoid problems such as language track selection.
# - 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 = 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))

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'])

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):
Expand Down Expand Up @@ -133,10 +179,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:
Expand All @@ -151,12 +198,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):
Expand Down Expand Up @@ -210,12 +262,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'
Expand All @@ -224,6 +276,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',
Expand All @@ -242,7 +295,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(
Expand All @@ -261,7 +314,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:
Expand All @@ -274,6 +327,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',
Expand Down
2 changes: 1 addition & 1 deletion resources/lib/services/nfsession/msl/events_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 2 additions & 4 deletions resources/lib/services/nfsession/msl/msl_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,10 +123,8 @@ 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 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)
Expand Down
2 changes: 1 addition & 1 deletion resources/lib/services/nfsession/msl/msl_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
47 changes: 47 additions & 0 deletions resources/lib/services/playback/action_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
See LICENSES/MIT.md for more information.
"""
import json
import re
import threading
import time
from typing import TYPE_CHECKING
Expand Down Expand Up @@ -84,6 +85,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
Expand Down Expand Up @@ -215,6 +217,20 @@ 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 !!
# todo: TO TAKE IN ACCOUNT FOR FUTURE ADS IMPROVEMENTS <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<

# When InputStream Adaptive add-on send the DEMUX_SPECIALID_STREAMCHANGE used to switch stream quality
# kodi core influence immediately Player.GetProperties without following what is actually played on screen
# the worst case scenario is in the case of ADS chapters, that will result we have FULL FALSE PLAYER INFO HERE!
# because while an ADS chapter is actually in playing and at same time the DEMUX_SPECIALID_STREAMCHANGE is sent
# to the kodi buffer (because after ADS the movie have to start) Player.GetProperties starts to provide
# info of the movie instead of the actually ADS in playing, this will break all add-on features around here!

# So in short, Player.GetProperties dont provide data in realtime and depends on kodi buffer instead of
# what shown on screen. A partial solution is mapping all manifest periods streams timing here to know at least
# where are the ADS in the timeline by taking in account / checking the elapsed time only,
# and so ignoring all kodi API info
try:
player_state = common.json_rpc('Player.GetProperties', {
'playerid': self.active_player_id if player_id is None else player_id,
Expand Down Expand Up @@ -258,6 +274,37 @@ def _get_player_state(self, player_id=None, time_override=None):
# use saved player state
player_state = self._last_player_state

# Get additional video track info added in the track name
video_stream = player_state['videostreams'][0]
# Try to find the crop info from the track name
result = re.match(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)
# This value is taken from DASH manifest, see AdaptationSet tag in the converter.py
result = re.match(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 "movie" start
# since all the ADS video parts are inserted before the "movie"
result = re.match(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']
# current_pts is the current player time without the duration of previous ADS video parts (if any)
# todo: workaround for kodi bug (read above)
# make sure that current elapsed time dont fall in to ADS stream chapter
# if so fix data because Player.GetProperties provided the info of currently not played stream in advance
if not player_state['nf_is_ads_stream'] and player_state['elapsed_seconds'] <= pts_offset:
# Fall here when DEMUX_SPECIALID_STREAMCHANGE has been sent by ISAdaptive and Kodi core wrongly send
# info of next stream chapter to be played instead of the currently in playing
player_state['nf_is_ads_stream'] = True
player_state['current_pts'] = player_state['elapsed_seconds']
else:
# We need to remove the ADS duration from the current PTS because website API dont take in account of
# each ADS time duration, ADS are handled separately as if they were not part of the video stream
player_state['current_pts'] = player_state['elapsed_seconds'] - pts_offset
player_state['nf_pts_offset'] = pts_offset
return player_state


Expand Down
4 changes: 3 additions & 1 deletion resources/lib/services/playback/am_playback.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,14 +85,16 @@ 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%)
# https://kodi.wiki/view/HOW-TO:Modify_automatic_watch_and_resume_points#Settings_explained
# 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
Expand Down
12 changes: 8 additions & 4 deletions resources/lib/services/playback/am_section_skipping.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}'
Expand All @@ -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']:
Expand All @@ -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):
Expand Down
Loading

0 comments on commit a3e754c

Please sign in to comment.