Skip to content

Commit

Permalink
wip
Browse files Browse the repository at this point in the history
  • Loading branch information
CastagnaIT committed Nov 3, 2023
1 parent d11ac04 commit d984f50
Show file tree
Hide file tree
Showing 10 changed files with 184 additions and 55 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
10 changes: 5 additions & 5 deletions resources/lib/services/nfsession/msl/msl_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,10 +123,10 @@ 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.')
return self._tranform_to_dash(manifest)

@measure_exec_time_decorator(is_immediate=True)
Expand Down Expand Up @@ -285,7 +285,7 @@ def _build_manifest_v2(self, **kwargs):
'requestSegmentVmaf': False,
'supportsPartialHydration': False,
'contentPlaygraph': ['start'],
'supportsAdBreakHydration': False,
'supportsAdBreakHydration': True,
'liveMetadataFormat': 'INDEXED_SEGMENT_TEMPLATE',
'useBetterTextUrls': True,
'profileGroups': [{
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
Loading

0 comments on commit d984f50

Please sign in to comment.