From e498736a96c5add619f25161945ed22a522f8c95 Mon Sep 17 00:00:00 2001 From: croneter Date: Sun, 25 Aug 2019 13:46:47 +0200 Subject: [PATCH] Let PMS StreamingBrain decide on whether we need to force-transcode --- .../resource.language.en_gb/strings.po | 10 + resources/lib/playback.py | 12 +- resources/lib/playback_decision.py | 458 ++++++++++++++++++ resources/lib/playlist_func.py | 2 + resources/lib/playutils.py | 371 -------------- resources/lib/plex_api/__init__.py | 3 +- resources/lib/plex_api/media.py | 51 +- resources/lib/plex_api/playback.py | 107 ++++ resources/lib/plex_functions.py | 71 +++ resources/lib/service_entry.py | 1 - resources/lib/variables.py | 56 ++- 11 files changed, 723 insertions(+), 419 deletions(-) create mode 100644 resources/lib/playback_decision.py delete mode 100644 resources/lib/playutils.py create mode 100644 resources/lib/plex_api/playback.py diff --git a/resources/language/resource.language.en_gb/strings.po b/resources/language/resource.language.en_gb/strings.po index d8e3484c..42f054ec 100644 --- a/resources/language/resource.language.en_gb/strings.po +++ b/resources/language/resource.language.en_gb/strings.po @@ -668,6 +668,16 @@ msgctxt "#33003" msgid "Server is online" msgstr "" +# Plex notification when we need to transcode +msgctxt "#33004" +msgid "PMS enforced transcoding" +msgstr "" + +# Plex notification when we need to use direct streaming (instead of transcoding) +msgctxt "#33005" +msgid "PMS enforced direct streaming" +msgstr "" + # Error notification msgctxt "#33009" msgid "Invalid username or password" diff --git a/resources/lib/playback.py b/resources/lib/playback.py index cb228793..d3761d4e 100644 --- a/resources/lib/playback.py +++ b/resources/lib/playback.py @@ -19,7 +19,7 @@ from . import playlist_func as PL from . import playqueue as PQ from . import json_rpc as js from . import transfer -from .playutils import PlayUtils +from .playback_decision import set_playurl from . import variables as v from . import app @@ -460,17 +460,15 @@ def _conclude_playback(playqueue, pos): api = API(item.xml) api.part = item.part or 0 listitem = api.listitem(listitem=transfer.PKCListItem) - playutils = PlayUtils(api, item) - playurl = playutils.getPlayUrl() + set_playurl(api, item) else: listitem = transfer.PKCListItem() api = None - playurl = item.file - if not playurl: + if not item.file: LOG.info('Did not get a playurl, aborting playback silently') - _ensure_resolve(abort=True) + _ensure_resolve() return - listitem.setPath(playurl.encode('utf-8')) + listitem.setPath(item.file.encode('utf-8')) if item.playmethod == 'DirectStream': listitem.setSubtitles(api.cache_external_subs()) elif item.playmethod == 'Transcode': diff --git a/resources/lib/playback_decision.py b/resources/lib/playback_decision.py new file mode 100644 index 00000000..d286a2c9 --- /dev/null +++ b/resources/lib/playback_decision.py @@ -0,0 +1,458 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +from __future__ import absolute_import, division, unicode_literals +from logging import getLogger +from requests import exceptions + +from .downloadutils import DownloadUtils as DU +from .plex_api import API +from . import plex_functions as PF, utils, app, variables as v + + +LOG = getLogger('PLEX.playback_decision') + +# largest signed 32bit integer: 2147483 +MAX_SIGNED_INT = int(2**31 - 1) +# PMS answer codes +DIRECT_PLAY_OK = 1000 +CONVERSION_OK = 1001 # PMS can either direct stream or transcode + + +def set_playurl(api, item): + if api.mediastream_number() is None: + # E.g. user could choose between several media streams and cancelled + return + item.playmethod = int(utils.settings('playType')) + LOG.info('User chose playback method %s in PKC settings', + v.EXPLICIT_PLAYBACK_METHOD[item.playmethod]) + _initial_best_playback_method(api, item) + LOG.info('PKC decided on playback method %s', + v.EXPLICIT_PLAYBACK_METHOD[item.playmethod]) + if item.playmethod == v.PLAYBACK_METHOD_DIRECT_PATH: + # No need to ask the PMS whether we can play - we circumvent + # the PMS entirely + LOG.info('The playurl for %s is: %s', + v.EXPLICIT_PLAYBACK_METHOD[item.playmethod], item.file) + return + LOG.info('Lets ask the PMS next') + try: + _pms_playback_decision(api, item) + except (exceptions.RequestException, AttributeError, IndexError, SystemExit) as err: + LOG.warn('Could not find suitable settings for playback, aborting') + LOG.warn('Error received: %s', err) + item.playmethod = None + item.file = None + else: + item.file = api.transcode_video_path(item.playmethod, + quality=item.quality) + LOG.info('The playurl for %s is: %s', + v.EXPLICIT_PLAYBACK_METHOD[item.playmethod], item.file) + + +def _initial_best_playback_method(api, item): + """ + Sets the highest available playback method without talking to the PMS + Also sets self.path for a direct path, if available and accessible + """ + if api.should_stream(): + # True for e.g. plex.tv watch later + LOG.info('Plex item optimized for DirectPlay') + item.playmethod = v.PLAYBACK_METHOD_DIRECT_PLAY + return + item.file = api.file_path() + item.file = api.validate_playurl(item.file, api.plex_type, force_check=True) + # Check whether we have a strm file that we need to throw at Kodi 1:1 + if item.file is not None and item.file.endswith('.strm'): + # Use direct path in any case, regardless of user setting + LOG.debug('.strm file detected') + item.playmethod = v.PLAYBACK_METHOD_DIRECT_PATH + elif _must_transcode(api, item): + item.playmethod = v.PLAYBACK_METHOD_TRANSCODE + elif item.playmethod in (v.PLAYBACK_METHOD_DIRECT_PLAY, + v.PLAYBACK_METHOD_DIRECT_STREAM): + pass + elif item.file is None: + # E.g. direct path was not possible to access + item.playmethod = v.PLAYBACK_METHOD_DIRECT_PLAY + else: + item.playmethod = v.PLAYBACK_METHOD_DIRECT_PATH + + +def _pms_playback_decision(api, item): + """ + We CANNOT distinguish direct playing from direct streaming from the PMS' + answer + """ + ask_for_user_quality_settings = False + if item.playmethod <= 2: + LOG.info('Asking PMS with maximal quality settings') + item.quality = _max_quality() + decision_api = _ask_pms(api, item) + if decision_api.decision_code() > CONVERSION_OK: + ask_for_user_quality_settings = True + else: + ask_for_user_quality_settings = True + if ask_for_user_quality_settings: + item.quality = _transcode_quality() + LOG.info('Asking PMS with user quality settings') + decision_api = _ask_pms(api, item) + + # Process the PMS answer + if decision_api.decision_code() > CONVERSION_OK: + LOG.error('Neither DirectPlay, DirectStream nor transcoding possible') + error = '%s\n%s' % (decision_api.general_play_decision_text(), + decision_api.transcode_decision_text()) + utils.messageDialog(heading=utils.lang(29999), + msg=error) + raise AttributeError('Neither DirectPlay, DirectStream nor transcoding possible') + if (item.playmethod == v.PLAYBACK_METHOD_DIRECT_PLAY and + decision_api.decision_code() == DIRECT_PLAY_OK): + # All good + return + LOG.info('PMS video stream decision: %s, PMS audio stream decision: %s, ' + 'PMS subtitle stream decision: %s', + decision_api.video_decision(), + decision_api.audio_decision(), + decision_api.subtitle_decision()) + # Only look at the video stream since that'll be most CPU-intensive for + # the PMS + video_direct_streaming = decision_api.video_decision() == 'copy' + if video_direct_streaming: + if item.playmethod < v.PLAYBACK_METHOD_DIRECT_STREAM: + LOG.warn('The PMS forces us to direct stream') + # "PMS enforced direct streaming" + utils.dialog('notification', + utils.lang(29999), + utils.lang(33005), + icon='{plex}') + item.playmethod = v.PLAYBACK_METHOD_DIRECT_STREAM + else: + if item.playmethod < v.PLAYBACK_METHOD_TRANSCODE: + LOG.warn('The PMS forces us to transcode') + # "PMS enforced transcoding" + utils.dialog('notification', + utils.lang(29999), + utils.lang(33004), + icon='{plex}') + item.playmethod = v.PLAYBACK_METHOD_TRANSCODE + + +def _ask_pms(api, item): + xml = PF.playback_decision(path=api.path_and_plex_id(), + media=api.mediastream, + part=api.part, + playmethod=item.playmethod, + video=api.plex_type in v.PLEX_VIDEOTYPES, + args=item.quality) + decision_api = API(xml) + LOG.info('PMS general decision %s: %s', + decision_api.general_play_decision_code(), + decision_api.general_play_decision_text()) + LOG.info('PMS Direct Play decision %s: %s', + decision_api.direct_play_decision_code(), + decision_api.direct_play_decision_text()) + LOG.info('PMS MDE decision %s: %s', + decision_api.mde_play_decision_code(), + decision_api.mde_play_decision_text()) + LOG.info('PMS transcoding decision %s: %s', + decision_api.transcode_decision_code(), + decision_api.transcode_decision_text()) + return decision_api + + +def _must_transcode(api, item): + """ + Returns True if we need to transcode because + - codec is in h265 + - 10bit video codec + - HEVC codec + - playqueue_item force_transcode is set to True + - state variable FORCE_TRANSCODE set to True + (excepting trailers etc.) + - video bitrate above specified settings bitrate + if the corresponding file settings are set to 'true' + """ + if api.plex_type in (v.PLEX_TYPE_CLIP, v.PLEX_TYPE_SONG): + LOG.info('Plex clip or music track, not transcoding') + return False + if item.playmethod == v.PLAYBACK_METHOD_TRANSCODE: + return True + videoCodec = api.video_codec() + LOG.debug("videoCodec received from the PMS: %s", videoCodec) + if item.force_transcode is True: + LOG.info('User chose to force-transcode') + return True + codec = videoCodec['videocodec'] + if codec is None: + # e.g. trailers. Avoids TypeError with "'h265' in codec" + LOG.info('No codec from PMS, not transcoding.') + return False + if ((utils.settings('transcodeHi10P') == 'true' and + videoCodec['bitDepth'] == '10') and + ('h264' in codec)): + LOG.info('Option to transcode 10bit h264 video content enabled.') + return True + try: + bitrate = int(videoCodec['bitrate']) + except (TypeError, ValueError): + LOG.info('No video bitrate from PMS, not transcoding.') + return False + if bitrate > _get_max_bitrate(): + LOG.info('Video bitrate of %s is higher than the maximal video' + 'bitrate of %s that the user chose. Transcoding', + bitrate, _get_max_bitrate()) + return True + try: + resolution = int(videoCodec['resolution']) + except (TypeError, ValueError): + if videoCodec['resolution'] == '4k': + resolution = 2160 + else: + LOG.info('No video resolution from PMS, not transcoding.') + return False + if 'h265' in codec or 'hevc' in codec: + if resolution >= _getH265(): + LOG.info('Option to transcode h265/HEVC enabled. Resolution ' + 'of the media: %s, transcoding limit resolution: %s', + resolution, _getH265()) + return True + return False + + +def _transcode_quality(): + return { + 'maxVideoBitrate': get_bitrate(), + 'videoResolution': get_resolution(), + 'videoQuality': 100, + 'mediaBufferSize': int(float(utils.settings('kodi_video_cache')) / 1024.0), + } + + +def _max_quality(): + return { + 'maxVideoBitrate': MAX_SIGNED_INT, + 'videoResolution': '3840x2160', # 4K + 'videoQuality': 100, + 'mediaBufferSize': int(float(utils.settings('kodi_video_cache')) / 1024.0), + } + + +def get_bitrate(): + """ + Get the desired transcoding bitrate from the settings + """ + videoQuality = utils.settings('transcoderVideoQualities') + bitrate = { + '0': 320, + '1': 720, + '2': 1500, + '3': 2000, + '4': 3000, + '5': 4000, + '6': 8000, + '7': 10000, + '8': 12000, + '9': 20000, + '10': 40000, + } + # max bit rate supported by server (max signed 32bit integer) + return bitrate.get(videoQuality, MAX_SIGNED_INT) + + +def get_resolution(): + """ + Get the desired transcoding resolutions from the settings + """ + chosen = utils.settings('transcoderVideoQualities') + res = { + '0': '420x420', + '1': '576x320', + '2': '720x480', + '3': '1024x768', + '4': '1280x720', + '5': '1280x720', + '6': '1920x1080', + '7': '1920x1080', + '8': '1920x1080', + '9': '1920x1080', + '10': '1920x1080', + } + return res[chosen] + + +def _get_max_bitrate(): + max_bitrate = utils.settings('maxVideoQualities') + bitrate = { + '0': 320, + '1': 720, + '2': 1500, + '3': 2000, + '4': 3000, + '5': 4000, + '6': 8000, + '7': 10000, + '8': 12000, + '9': 20000, + '10': 40000, + '11': MAX_SIGNED_INT # deactivated + } + # max bit rate supported by server (max signed 32bit integer) + return bitrate.get(max_bitrate, MAX_SIGNED_INT) + + +def _getH265(): + """ + Returns the user settings for transcoding h265: boundary resolutions + of 480, 720 or 1080 as an int + + OR 2147483 (MAX_SIGNED_INT, int) if user chose not to transcode + """ + H265 = { + '0': MAX_SIGNED_INT, + '1': 480, + '2': 720, + '3': 1080 + } + return H265[utils.settings('transcodeH265')] + + +def audio_subtitle_prefs(api, listitem): + """ + For transcoding only + + Called at the very beginning of play; used to change audio and subtitle + stream by a PUT request to the PMS + """ + # Set media and part where we're at + if (api.mediastream is None and + api.mediastream_number() is None): + return + try: + mediastreams = api.plex_media_streams() + except (TypeError, IndexError): + LOG.error('Could not get media %s, part %s', + api.mediastream, api.part) + return + part_id = mediastreams.attrib['id'] + audio_streams_list = [] + audio_streams = [] + subtitle_streams_list = [] + # No subtitles as an option + subtitle_streams = [utils.lang(39706)] + downloadable_streams = [] + download_subs = [] + # selectAudioIndex = "" + select_subs_index = "" + audio_numb = 0 + # Remember 'no subtitles' + sub_num = 1 + default_sub = None + + for stream in mediastreams: + # Since Plex returns all possible tracks together, have to sort + # them. + index = stream.attrib.get('id') + typus = stream.attrib.get('streamType') + # Audio + if typus == "2": + codec = stream.attrib.get('codec') + channellayout = stream.attrib.get('audioChannelLayout', "") + try: + track = "%s %s - %s %s" % (audio_numb + 1, + stream.attrib['language'], + codec, + channellayout) + except KeyError: + track = "%s %s - %s %s" % (audio_numb + 1, + utils.lang(39707), # unknown + codec, + channellayout) + audio_streams_list.append(index) + audio_streams.append(utils.try_encode(track)) + audio_numb += 1 + + # Subtitles + elif typus == "3": + try: + track = "%s %s" % (sub_num + 1, stream.attrib['language']) + except KeyError: + track = "%s %s (%s)" % (sub_num + 1, + utils.lang(39707), # unknown + stream.attrib.get('codec')) + default = stream.attrib.get('default') + forced = stream.attrib.get('forced') + downloadable = stream.attrib.get('key') + + if default: + track = "%s - %s" % (track, utils.lang(39708)) # Default + if forced: + track = "%s - %s" % (track, utils.lang(39709)) # Forced + if downloadable: + # We do know the language - temporarily download + if 'language' in stream.attrib: + path = api.download_external_subtitles( + '{server}%s' % stream.attrib['key'], + "subtitle.%s.%s" % (stream.attrib['languageCode'], + stream.attrib['codec'])) + # We don't know the language - no need to download + else: + path = api.attach_plex_token_to_url( + "%s%s" % (app.CONN.server, + stream.attrib['key'])) + downloadable_streams.append(index) + download_subs.append(utils.try_encode(path)) + else: + track = "%s (%s)" % (track, utils.lang(39710)) # burn-in + if stream.attrib.get('selected') == '1' and downloadable: + # Only show subs without asking user if they can be + # turned off + default_sub = index + + subtitle_streams_list.append(index) + subtitle_streams.append(utils.try_encode(track)) + sub_num += 1 + + if audio_numb > 1: + resp = utils.dialog('select', utils.lang(33013), audio_streams) + if resp > -1: + # User selected some audio track + args = { + 'audioStreamID': audio_streams_list[resp], + 'allParts': 1 + } + DU().downloadUrl('{server}/library/parts/%s' % part_id, + action_type='PUT', + parameters=args) + + if sub_num == 1: + # No subtitles + return + + select_subs_index = None + if (utils.settings('pickPlexSubtitles') == 'true' and + default_sub is not None): + LOG.info('Using default Plex subtitle: %s', default_sub) + select_subs_index = default_sub + else: + resp = utils.dialog('select', utils.lang(33014), subtitle_streams) + if resp > 0: + select_subs_index = subtitle_streams_list[resp - 1] + else: + # User selected no subtitles or backed out of dialog + select_subs_index = '' + + LOG.debug('Adding external subtitles: %s', download_subs) + # Enable Kodi to switch autonomously to downloadable subtitles + if download_subs: + listitem.setSubtitles(download_subs) + # Don't additionally burn in subtitles + if select_subs_index in downloadable_streams: + select_subs_index = '' + # Now prep the PMS for our choice + args = { + 'subtitleStreamID': select_subs_index, + 'allParts': 1 + } + DU().downloadUrl('{server}/library/parts/%s' % part_id, + action_type='PUT', + parameters=args) diff --git a/resources/lib/playlist_func.py b/resources/lib/playlist_func.py index 7b93b587..dcc4ce4c 100644 --- a/resources/lib/playlist_func.py +++ b/resources/lib/playlist_func.py @@ -177,6 +177,8 @@ class PlaylistItem(object): self.playmethod = None self.playcount = None self.offset = None + # Transcoding quality, if needed + self.quality = None # If Plex video consists of several parts; part number self.part = 0 self.force_transcode = False diff --git a/resources/lib/playutils.py b/resources/lib/playutils.py deleted file mode 100644 index e69af876..00000000 --- a/resources/lib/playutils.py +++ /dev/null @@ -1,371 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -from __future__ import absolute_import, division, unicode_literals -from logging import getLogger - -from .downloadutils import DownloadUtils as DU -from . import utils, app -from . import variables as v - -############################################################################### -LOG = getLogger('PLEX.playutils') -############################################################################### - - -class PlayUtils(): - - def __init__(self, api, playqueue_item): - """ - init with api (PlexAPI wrapper of the PMS xml element) and - playqueue_item (PlaylistItem()) - """ - self.api = api - self.item = playqueue_item - - def getPlayUrl(self): - """ - Returns the playurl [unicode] for the part or returns None. - (movie might consist of several files) - """ - if self.api.mediastream_number() is None: - return - playurl = self.direct_path() - if playurl is not None: - LOG.info("File is direct playing.") - self.item.playmethod = 'DirectPath' - elif self.isDirectStream(): - LOG.info("File is direct streaming.") - playurl = self.api.transcode_video_path('DirectStream') - self.item.playmethod = 'DirectStream' - else: - LOG.info("File is transcoding.") - playurl = self.api.transcode_video_path( - 'Transcode', - quality={ - 'maxVideoBitrate': self.get_bitrate(), - 'videoResolution': self.get_resolution(), - 'videoQuality': '100', - 'mediaBufferSize': int( - utils.settings('kodi_video_cache')) / 1024, - }) - self.item.playmethod = 'Transcode' - LOG.info("The playurl is: %s", playurl) - self.item.file = playurl - return playurl - - def direct_path(self): - """ - Returns the path if we can use direct paths and the path is accessible - Returns None otherwise - """ - # True for e.g. plex.tv watch later - if self.api.should_stream() is True: - LOG.info("Plex item optimized for direct streaming") - return - # Check whether we have a strm file that we need to throw at Kodi 1:1 - path = self.api.file_path() - if path is not None and path.endswith('.strm'): - LOG.info('.strm file detected') - return self.api.validate_playurl(path, - self.api.plex_type, - force_check=True) - # set to either 'Direct Play=1' or 'Transcode=2' - # and NOT to 'Try Direct Path=0' - if utils.settings('playType') != "0": - # User forcing to play via HTTP - LOG.info("User chose to not use direct paths") - return - if self.mustTranscode(): - return - return self.api.validate_playurl(path, - self.api.plex_type, - force_check=True) - - def mustTranscode(self): - """ - Returns True if we need to transcode because - - codec is in h265 - - 10bit video codec - - HEVC codec - - playqueue_item force_transcode is set to True - - state variable FORCE_TRANSCODE set to True - (excepting trailers etc.) - - video bitrate above specified settings bitrate - if the corresponding file settings are set to 'true' - """ - if self.api.plex_type in (v.PLEX_TYPE_CLIP, v.PLEX_TYPE_SONG): - LOG.info('Plex clip or music track, not transcoding') - return False - videoCodec = self.api.video_codec() - LOG.info("videoCodec: %s", videoCodec) - if self.item.force_transcode is True: - LOG.info('User chose to force-transcode') - return True - codec = videoCodec['videocodec'] - if codec is None: - # e.g. trailers. Avoids TypeError with "'h265' in codec" - LOG.info('No codec from PMS, not transcoding.') - return False - if ((utils.settings('transcodeHi10P') == 'true' and - videoCodec['bitDepth'] == '10') and - ('h264' in codec)): - LOG.info('Option to transcode 10bit h264 video content enabled.') - return True - try: - bitrate = int(videoCodec['bitrate']) - except (TypeError, ValueError): - LOG.info('No video bitrate from PMS, not transcoding.') - return False - if bitrate > self.get_max_bitrate(): - LOG.info('Video bitrate of %s is higher than the maximal video' - 'bitrate of %s that the user chose. Transcoding', - bitrate, self.get_max_bitrate()) - return True - try: - resolution = int(videoCodec['resolution']) - except (TypeError, ValueError): - if videoCodec['resolution'] == '4k': - resolution = 2160 - else: - LOG.info('No video resolution from PMS, not transcoding.') - return False - if 'h265' in codec or 'hevc' in codec: - if resolution >= self.getH265(): - LOG.info('Option to transcode h265/HEVC enabled. Resolution ' - 'of the media: %s, transcoding limit resolution: %s', - resolution, self.getH265()) - return True - return False - - def isDirectStream(self): - # Never transcode Music - if self.api.plex_type == 'track': - return True - if utils.settings('playType') == '3': - # User forcing to play via HTTP - LOG.info("User chose to transcode") - return False - if self.mustTranscode(): - return False - return True - - @staticmethod - def get_max_bitrate(): - # get the addon video quality - videoQuality = utils.settings('maxVideoQualities') - bitrate = { - '0': 320, - '1': 720, - '2': 1500, - '3': 2000, - '4': 3000, - '5': 4000, - '6': 8000, - '7': 10000, - '8': 12000, - '9': 20000, - '10': 40000, - '11': 99999999 # deactivated - } - # max bit rate supported by server (max signed 32bit integer) - return bitrate.get(videoQuality, 2147483) - - @staticmethod - def getH265(): - """ - Returns the user settings for transcoding h265: boundary resolutions - of 480, 720 or 1080 as an int - - OR 9999999 (int) if user chose not to transcode - """ - H265 = { - '0': 99999999, - '1': 480, - '2': 720, - '3': 1080 - } - return H265[utils.settings('transcodeH265')] - - @staticmethod - def get_bitrate(): - """ - Get the desired transcoding bitrate from the settings - """ - videoQuality = utils.settings('transcoderVideoQualities') - bitrate = { - '0': 320, - '1': 720, - '2': 1500, - '3': 2000, - '4': 3000, - '5': 4000, - '6': 8000, - '7': 10000, - '8': 12000, - '9': 20000, - '10': 40000, - } - # max bit rate supported by server (max signed 32bit integer) - return bitrate.get(videoQuality, 2147483) - - @staticmethod - def get_resolution(): - """ - Get the desired transcoding resolutions from the settings - """ - chosen = utils.settings('transcoderVideoQualities') - res = { - '0': '420x420', - '1': '576x320', - '2': '720x480', - '3': '1024x768', - '4': '1280x720', - '5': '1280x720', - '6': '1920x1080', - '7': '1920x1080', - '8': '1920x1080', - '9': '1920x1080', - '10': '1920x1080', - } - return res[chosen] - - def audio_subtitle_prefs(self, listitem): - """ - For transcoding only - - Called at the very beginning of play; used to change audio and subtitle - stream by a PUT request to the PMS - """ - # Set media and part where we're at - if (self.api.mediastream is None and - self.api.mediastream_number() is None): - return - try: - mediastreams = self.api.plex_media_streams() - except (TypeError, IndexError): - LOG.error('Could not get media %s, part %s', - self.api.mediastream, self.api.part) - return - part_id = mediastreams.attrib['id'] - audio_streams_list = [] - audio_streams = [] - subtitle_streams_list = [] - # No subtitles as an option - subtitle_streams = [utils.lang(39706)] - downloadable_streams = [] - download_subs = [] - # selectAudioIndex = "" - select_subs_index = "" - audio_numb = 0 - # Remember 'no subtitles' - sub_num = 1 - default_sub = None - - for stream in mediastreams: - # Since Plex returns all possible tracks together, have to sort - # them. - index = stream.attrib.get('id') - typus = stream.attrib.get('streamType') - # Audio - if typus == "2": - codec = stream.attrib.get('codec') - channellayout = stream.attrib.get('audioChannelLayout', "") - try: - track = "%s %s - %s %s" % (audio_numb + 1, - stream.attrib['language'], - codec, - channellayout) - except KeyError: - track = "%s %s - %s %s" % (audio_numb + 1, - utils.lang(39707), # unknown - codec, - channellayout) - audio_streams_list.append(index) - audio_streams.append(utils.try_encode(track)) - audio_numb += 1 - - # Subtitles - elif typus == "3": - try: - track = "%s %s" % (sub_num + 1, stream.attrib['language']) - except KeyError: - track = "%s %s (%s)" % (sub_num + 1, - utils.lang(39707), # unknown - stream.attrib.get('codec')) - default = stream.attrib.get('default') - forced = stream.attrib.get('forced') - downloadable = stream.attrib.get('key') - - if default: - track = "%s - %s" % (track, utils.lang(39708)) # Default - if forced: - track = "%s - %s" % (track, utils.lang(39709)) # Forced - if downloadable: - # We do know the language - temporarily download - if 'language' in stream.attrib: - path = self.api.download_external_subtitles( - '{server}%s' % stream.attrib['key'], - "subtitle.%s.%s" % (stream.attrib['languageCode'], - stream.attrib['codec'])) - # We don't know the language - no need to download - else: - path = self.api.attach_plex_token_to_url( - "%s%s" % (app.CONN.server, - stream.attrib['key'])) - downloadable_streams.append(index) - download_subs.append(utils.try_encode(path)) - else: - track = "%s (%s)" % (track, utils.lang(39710)) # burn-in - if stream.attrib.get('selected') == '1' and downloadable: - # Only show subs without asking user if they can be - # turned off - default_sub = index - - subtitle_streams_list.append(index) - subtitle_streams.append(utils.try_encode(track)) - sub_num += 1 - - if audio_numb > 1: - resp = utils.dialog('select', utils.lang(33013), audio_streams) - if resp > -1: - # User selected some audio track - args = { - 'audioStreamID': audio_streams_list[resp], - 'allParts': 1 - } - DU().downloadUrl('{server}/library/parts/%s' % part_id, - action_type='PUT', - parameters=args) - - if sub_num == 1: - # No subtitles - return - - select_subs_index = None - if (utils.settings('pickPlexSubtitles') == 'true' and - default_sub is not None): - LOG.info('Using default Plex subtitle: %s', default_sub) - select_subs_index = default_sub - else: - resp = utils.dialog('select', utils.lang(33014), subtitle_streams) - if resp > 0: - select_subs_index = subtitle_streams_list[resp - 1] - else: - # User selected no subtitles or backed out of dialog - select_subs_index = '' - - LOG.debug('Adding external subtitles: %s', download_subs) - # Enable Kodi to switch autonomously to downloadable subtitles - if download_subs: - listitem.setSubtitles(download_subs) - # Don't additionally burn in subtitles - if select_subs_index in downloadable_streams: - select_subs_index = '' - # Now prep the PMS for our choice - args = { - 'subtitleStreamID': select_subs_index, - 'allParts': 1 - } - DU().downloadUrl('{server}/library/parts/%s' % part_id, - action_type='PUT', - parameters=args) diff --git a/resources/lib/plex_api/__init__.py b/resources/lib/plex_api/__init__.py index 7a215018..444c2bfc 100644 --- a/resources/lib/plex_api/__init__.py +++ b/resources/lib/plex_api/__init__.py @@ -10,11 +10,12 @@ from .artwork import Artwork from .file import File from .media import Media from .user import User +from .playback import Playback from ..plex_db import PlexDB -class API(Base, Artwork, File, Media, User): +class API(Base, Artwork, File, Media, User, Playback): pass diff --git a/resources/lib/plex_api/media.py b/resources/lib/plex_api/media.py index 3bd23188..61342f80 100644 --- a/resources/lib/plex_api/media.py +++ b/resources/lib/plex_api/media.py @@ -6,6 +6,7 @@ from logging import getLogger from ..utils import cast from ..downloadutils import DownloadUtils as DU from .. import utils, variables as v, app, path_ops, clientinfo +from .. import plex_functions as PF LOG = getLogger('PLEX.api') @@ -209,7 +210,9 @@ class Media(object): Transcode Video support; returns the URL to get a media started Input: - action 'DirectStream' or 'Transcode' + action 'DirectPlay' + 'DirectStream' + 'Transcode' quality: { 'videoResolution': e.g. '1024x768', @@ -224,47 +227,23 @@ class Media(object): """ if self.mediastream is None and self.mediastream_number() is None: return - quality = {} if quality is None else quality - xargs = clientinfo.getXArgsDeviceInfo() - # For DirectPath, path/key of PART is needed - # trailers are 'clip' with PMS xmls - if action == "DirectStream": + headers = clientinfo.getXArgsDeviceInfo() + if action == v.PLAYBACK_METHOD_DIRECT_PLAY: path = self.xml[self.mediastream][self.part].get('key') - url = app.CONN.server + path # e.g. Trailers already feature an '?'! - return utils.extend_url(url, xargs) - - # For Transcoding - headers = { - 'X-Plex-Platform': 'Android', - 'X-Plex-Platform-Version': '7.0', - 'X-Plex-Product': 'Plex for Android', - 'X-Plex-Version': '5.8.0.475' - } + return utils.extend_url(app.CONN.server + path, headers) + # Direct Streaming and Transcoding + arguments = PF.transcoding_arguments(path=self.path_and_plex_id(), + media=self.mediastream, + part=self.part, + playmethod=action, + args=quality) + headers.update(arguments) # Path/key to VIDEO item of xml PMS response is needed, not part path = self.xml.get('key') transcode_path = app.CONN.server + \ '/video/:/transcode/universal/start.m3u8' - args = { - 'audioBoost': utils.settings('audioBoost'), - 'autoAdjustQuality': 0, - 'directPlay': 0, - 'directStream': 1, - 'protocol': 'hls', # seen in the wild: 'dash', 'http', 'hls' - 'session': v.PKC_MACHINE_IDENTIFIER, # TODO: create new unique id - 'fastSeek': 1, - 'path': path, - 'mediaIndex': self.mediastream, - 'partIndex': self.part, - 'hasMDE': 1, - 'location': 'lan', - 'subtitleSize': utils.settings('subtitleSize') - } - LOG.debug("Setting transcode quality to: %s", quality) - xargs.update(headers) - xargs.update(args) - xargs.update(quality) - return utils.extend_url(transcode_path, xargs) + return utils.extend_url(transcode_path, headers) def cache_external_subs(self): """ diff --git a/resources/lib/plex_api/playback.py b/resources/lib/plex_api/playback.py new file mode 100644 index 00000000..2b585845 --- /dev/null +++ b/resources/lib/plex_api/playback.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +from __future__ import absolute_import, division, unicode_literals + +from ..utils import cast + + +class Playback(object): + def decision_code(self): + """ + Returns the general_play_decision_code or mde_play_decision_code if + not available. Returns None if something went wrong + """ + return self.general_play_decision_code() or self.mde_play_decision_code() + + def general_play_decision_code(self): + """ + Returns the 'generalDecisionCode' as an int or None + Generally, the 1xxx codes constitute a a success decision, 2xxx a + general playback error, 3xxx a direct play error, and 4xxx a transcode + error. + + General decisions can include: + + 1000: Direct play OK. + 1001: Direct play not available; Conversion OK. + 2000: Neither direct play nor conversion is available. + 2001: Not enough bandwidth for any playback of this item. + 2002: Number of allowed streams has been reached. Stop a playback or ask + admin for more permissions. + 2003: File is unplayable. + 2004: Streaming Session doesn’t exist or timed out. + 2005: Client stopped playback. + 2006: Admin Terminated Playback. + """ + return cast(int, self.xml.get('generalDecisionCode')) + + def general_play_decision_text(self): + """ + Returns the text associated with the general_play_decision_code() as + text in unicode or None + """ + return self.xml.get('generalDecisionText') + + def mde_play_decision_code(self): + return cast(int, self.xml.get('mdeDecisionCode')) + + def mde_play_decision_text(self): + """ + Returns the text associated with the mde_play_decision_code() as + text in unicode or None + """ + return self.xml.get('mdeDecisionText') + + def direct_play_decision_code(self): + return cast(int, self.xml.get('directPlayDecisionCode')) + + def direct_play_decision_text(self): + """ + Returns the text associated with the mde_play_decision_code() as + text in unicode or None + """ + return self.xml.get('directPlayDecisionText') + + def transcode_decision_code(self): + return cast(int, self.xml.get('directPlayDecisionCode')) + + def transcode_decision_text(self): + """ + Returns the text associated with the mde_play_decision_code() as + text in unicode or None + """ + return self.xml.get('directPlayDecisionText') + + def video_decision(self): + """ + Returns "copy" if PMS streaming brain decided to DirectStream, so copy + an existing video stream into a new container. Returns "transcode" if + the video stream will be transcoded. + + Raises IndexError if something went wrong. Might also return None + """ + for stream in self.xml[0][0][0]: + if stream.get('streamType') == '1': + return stream.get('decision') + + def audio_decision(self): + """ + Returns "copy" if PMS streaming brain decided to DirectStream, so copy + an existing audio stream into a new container. Returns "transcode" if + the audio stream will be transcoded. + + Raises IndexError if something went wrong. Might also return None + """ + for stream in self.xml[0][0][0]: + if stream.get('streamType') == '2': + return stream.get('decision') + + def subtitle_decision(self): + """ + Returns the PMS' decision on the subtitle stream. + + Raises IndexError if something went wrong. Might also return None + """ + for stream in self.xml[0][0][0]: + if stream.get('streamType') == '3': + return stream.get('decision') diff --git a/resources/lib/plex_functions.py b/resources/lib/plex_functions.py index a89fbf16..a378d5d3 100644 --- a/resources/lib/plex_functions.py +++ b/resources/lib/plex_functions.py @@ -1040,3 +1040,74 @@ def show_episodes(plex_id): 'skipRefresh': 1, } return DownloadChunks(utils.extend_url(url, arguments)) + + +def transcoding_arguments(path, media, part, playmethod, args=None): + if playmethod == v.PLAYBACK_METHOD_DIRECT_PLAY: + direct_play = 1 + direct_stream = 1 + elif playmethod == v.PLAYBACK_METHOD_DIRECT_STREAM: + direct_play = 0 + direct_stream = 1 + elif playmethod == v.PLAYBACK_METHOD_TRANSCODE: + direct_play = 0 + direct_stream = 0 + arguments = { + # e.g. '/library/metadata/831399' + 'path': path, + # 1 if you want to directPlay, 0 if you want to transcode + 'directPlay': direct_play, + # 1 if you want to play a stream copy of data into a new container. This + # is unlikely to come up but it’s possible if you are playing something + # with a lot of tracks, a direct stream can result in lower bandwidth + # when a direct play would be over the limit. + # Assume Kodi can always handle any stream thrown at it! + 'directStream': direct_stream, + # Same for audio - assume Kodi can play any audio stream passed in! + 'directStreamAudio': direct_stream, + # This tells the server that you definitively know that the client can + # direct play (when you have directPlay=1) the content in spite of what + # client profiles may say about what the client can play. When this is + # set and directPlay=1, the server just checks bandwidth restrictions + # and gives you a reservation if bandwidth restrictions are met + 'hasMDE': direct_play, + # where # is an integer, 0 indexed. If you specify directPlay, this is + # required. -1 indicates let the server choose. + 'mediaIndex': media, + # Similar to mediaIndex but indicates which part you want to direct + # play. Really only comes into play with multi-part files which are + # uncommon. -1 here means concatenate the parts together but that + # requires the transcoder. + 'partIndex': part, + # all the rest + 'audioBoost': utils.settings('audioBoost'), + 'autoAdjustQuality': 1, + 'protocol': 'hls', # seen in the wild: 'http', 'dash', 'http', 'hls' + 'session': v.PKC_MACHINE_IDENTIFIER, # TODO: create new unique id + 'fastSeek': 1, + # none, embedded, sidecar + # Essentially indicating what you want to do with subtitles and state + # you aren’t want it to burn them into the video (requires transcoding) + 'subtitles': 'none', + # 'subtitleSize': utils.settings('subtitleSize') + 'copyts': 1 + } + if args: + arguments.update(args) + return arguments + + +def playback_decision(path, media, part, playmethod, video=True, args=None): + """ + Let's the PMS decide how we should playback this file + """ + arguments = transcoding_arguments(path, media, part, playmethod, args=args) + if video: + url = '{server}/video/:/transcode/universal/decision' + else: + url = '{server}/music/:/transcode/universal/decision' + LOG.debug('Asking the PMS if we can play this video with settings: %s', + arguments) + return DU().downloadUrl(utils.extend_url(url, arguments), + headerOptions=v.STREAMING_HEADERS, + reraise=True) diff --git a/resources/lib/service_entry.py b/resources/lib/service_entry.py index e85c5442..c3314758 100644 --- a/resources/lib/service_entry.py +++ b/resources/lib/service_entry.py @@ -51,7 +51,6 @@ class Service(object): return # Initial logging LOG.info("======== START %s ========", v.ADDON_NAME) - LOG.info("Platform: %s", v.PLATFORM) LOG.info("KODI Version: %s", v.KODILONGVERSION) LOG.info("%s Version: %s", v.ADDON_NAME, v.ADDON_VERSION) LOG.info("PKC Direct Paths: %s", diff --git a/resources/lib/variables.py b/resources/lib/variables.py index b21ac258..d848bf95 100644 --- a/resources/lib/variables.py +++ b/resources/lib/variables.py @@ -70,9 +70,6 @@ else: DEVICE = "Unknown" MODEL = platform.release() or 'Unknown' -# Plex' own platform for e.g. Plex Media Player -PLATFORM = 'Konvergo' -PLATFORM_VERSION = '2.26.0.947-1e21fa2b' DEVICENAME = try_decode(_ADDON.getSetting('deviceName')) if not DEVICENAME: @@ -138,6 +135,7 @@ EXTERNAL_SUBTITLE_TEMP_PATH = try_decode(xbmc.translatePath( # Multiply Plex time by this factor to receive Kodi time PLEX_TO_KODI_TIMEFACTOR = 1.0 / 1000.0 + # Playlist stuff PLAYLIST_PATH = os.path.join(KODI_PROFILE, 'playlists') PLAYLIST_PATH_MIXED = os.path.join(PLAYLIST_PATH, 'mixed') @@ -447,6 +445,58 @@ CONTENT_FROM_PLEX_TYPE = { None: CONTENT_TYPE_FILE } + +# Plex profile for transcoding and direct streaming +# Uses the empty Generic.xml at Plex Media Server/Resources/Profiles for any +# Playback decisions +PLATFORM = 'Generic' +# Version seems to be irrelevant for the generic platform +PLATFORM_VERSION = '1.0.0' +# Overrides (replace=true) any existing entries in generic.xml +STREAMING_HEADERS = { + 'X-Plex-Client-Profile-Extra': + # Would allow to DirectStream anything, but seems to be rather faulty + # 'add-transcode-target(' + # 'type=videoProfile&' + # 'context=streaming&' + # 'protocol=hls&' + # 'container=mpegts&' + # 'videoCodec=h264,*&' + # 'audioCodec=aac,*&' + # 'subtitleCodec=ass,pgs,vobsub,srt,*&' + # 'replace=true)' + ('add-transcode-target(' + 'type=videoProfile&' + 'context=streaming&' + 'protocol=hls&' + 'container=mpegts&' + 'videoCodec=h264,hevc,mpeg4,mpeg2video&' + 'audioCodec=aac,flac,vorbis,opus,ac3,eac3,mp3,mp2,pcm&' + 'subtitleCodec=ass,pgs,vobsub&' + 'replace=true)' + '+add-direct-play-profile(' + 'type=videoProfile&' + 'container=*&' + 'videoCodec=*&' + 'audioCodec=*&' + 'subtitleCodec=*&' + 'replace=true)') +} + +PLAYBACK_METHOD_DIRECT_PATH = 0 +PLAYBACK_METHOD_DIRECT_PLAY = 1 +PLAYBACK_METHOD_DIRECT_STREAM = 2 +PLAYBACK_METHOD_TRANSCODE = 3 + +EXPLICIT_PLAYBACK_METHOD = { + PLAYBACK_METHOD_DIRECT_PATH: 'DirectPath', + PLAYBACK_METHOD_DIRECT_PLAY: 'DirectPlay', + PLAYBACK_METHOD_DIRECT_STREAM: 'DirectStream', + PLAYBACK_METHOD_TRANSCODE: 'Transcode', + None: None +} + + KODI_TO_PLEX_ARTWORK = { 'poster': 'thumb', 'banner': 'banner',