Merge pull request #975 from croneter/fix-play-decision

Rework logic for using direct paths, direct play, direct streaming and transcoding, using the PMS StreamingBrain
This commit is contained in:
croneter 2019-09-08 15:28:29 +02:00 committed by GitHub
commit 52c1a0e47d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 738 additions and 433 deletions

View file

@ -668,6 +668,16 @@ msgctxt "#33003"
msgid "Server is online" msgid "Server is online"
msgstr "" 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 # Error notification
msgctxt "#33009" msgctxt "#33009"
msgid "Invalid username or password" msgid "Invalid username or password"
@ -963,7 +973,7 @@ msgstr ""
# PKC Settings - Customize paths # PKC Settings - Customize paths
msgctxt "#39056" msgctxt "#39056"
msgid "Used by Sync and when attempting to Direct Play" msgid "Used by Sync and when attempting to use Direct Paths"
msgstr "" msgstr ""
# PKC Settings, category name # PKC Settings, category name

View file

@ -62,4 +62,13 @@ def check_migration():
# Re-sync all playlists to Kodi # Re-sync all playlists to Kodi
utils.wipe_synched_playlists() utils.wipe_synched_playlists()
if not utils.compare_version(last_migration, '2.9.7'):
LOG.info('Migrating to version 2.9.6')
# Allow for a new "Direct Stream" setting (number 2), so shift the
# last setting for "force transcoding"
current_playback_type = utils.cast(int, utils.settings('playType')) or 0
if current_playback_type == 2:
current_playback_type = 3
utils.settings('playType', value=str(current_playback_type))
utils.settings('last_migrated_PKC_version', value=v.ADDON_VERSION) utils.settings('last_migrated_PKC_version', value=v.ADDON_VERSION)

View file

@ -19,7 +19,7 @@ from . import playlist_func as PL
from . import playqueue as PQ from . import playqueue as PQ
from . import json_rpc as js from . import json_rpc as js
from . import transfer from . import transfer
from .playutils import PlayUtils from .playback_decision import set_playurl
from . import variables as v from . import variables as v
from . import app from . import app
@ -460,17 +460,15 @@ def _conclude_playback(playqueue, pos):
api = API(item.xml) api = API(item.xml)
api.part = item.part or 0 api.part = item.part or 0
listitem = api.listitem(listitem=transfer.PKCListItem) listitem = api.listitem(listitem=transfer.PKCListItem)
playutils = PlayUtils(api, item) set_playurl(api, item)
playurl = playutils.getPlayUrl()
else: else:
listitem = transfer.PKCListItem() listitem = transfer.PKCListItem()
api = None api = None
playurl = item.file if not item.file:
if not playurl:
LOG.info('Did not get a playurl, aborting playback silently') LOG.info('Did not get a playurl, aborting playback silently')
_ensure_resolve(abort=True) _ensure_resolve()
return return
listitem.setPath(playurl.encode('utf-8')) listitem.setPath(item.file.encode('utf-8'))
if item.playmethod == 'DirectStream': if item.playmethod == 'DirectStream':
listitem.setSubtitles(api.cache_external_subs()) listitem.setSubtitles(api.cache_external_subs())
elif item.playmethod == 'Transcode': elif item.playmethod == 'Transcode':

View file

@ -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
"""
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,
'11': 35000,
'12': 50000
}
# 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',
'11': '3840x2160',
'12': '3840x2160'
}
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,
'4': 2160
}
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)

View file

@ -158,7 +158,7 @@ class PlaylistItem(object):
uri = None [str] PMS path to item; will be auto-set with plex_id uri = None [str] PMS path to item; will be auto-set with plex_id
guid = None [str] Weird Plex guid guid = None [str] Weird Plex guid
xml = None [etree] XML from PMS, 1 lvl below <MediaContainer> xml = None [etree] XML from PMS, 1 lvl below <MediaContainer>
playmethod = None [str] either 'DirectPlay', 'DirectStream', 'Transcode' playmethod = None [str] either 'DirectPath', 'DirectStream', 'Transcode'
playcount = None [int] how many times the item has already been played playcount = None [int] how many times the item has already been played
offset = None [int] the item's view offset UPON START in Plex time offset = None [int] the item's view offset UPON START in Plex time
part = 0 [int] part number if Plex video consists of mult. parts part = 0 [int] part number if Plex video consists of mult. parts
@ -177,6 +177,8 @@ class PlaylistItem(object):
self.playmethod = None self.playmethod = None
self.playcount = None self.playcount = None
self.offset = None self.offset = None
# Transcoding quality, if needed
self.quality = None
# If Plex video consists of several parts; part number # If Plex video consists of several parts; part number
self.part = 0 self.part = 0
self.force_transcode = False self.force_transcode = False

View file

@ -1,372 +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.isDirectPlay()
if playurl is not None:
LOG.info("File is direct playing.")
self.item.playmethod = 'DirectPlay'
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 isDirectPlay(self):
"""
Returns the path/playurl if we can direct play, 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')
playurl = self.api.validate_playurl(path,
self.api.plex_type,
force_check=True)
return playurl
# set to either 'Direct Stream=1' or 'Transcode=2'
# and NOT to 'Direct Play=0'
if utils.settings('playType') != "0":
# User forcing to play via HTTP
LOG.info("User chose to not direct play")
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
# set to 'Transcode=2'
if utils.settings('playType') == "2":
# 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)

View file

@ -10,11 +10,12 @@ from .artwork import Artwork
from .file import File from .file import File
from .media import Media from .media import Media
from .user import User from .user import User
from .playback import Playback
from ..plex_db import PlexDB from ..plex_db import PlexDB
class API(Base, Artwork, File, Media, User): class API(Base, Artwork, File, Media, User, Playback):
pass pass

View file

@ -6,12 +6,13 @@ from logging import getLogger
from ..utils import cast from ..utils import cast
from ..downloadutils import DownloadUtils as DU from ..downloadutils import DownloadUtils as DU
from .. import utils, variables as v, app, path_ops, clientinfo from .. import utils, variables as v, app, path_ops, clientinfo
from .. import plex_functions as PF
LOG = getLogger('PLEX.api') LOG = getLogger('PLEX.api')
class Media(object): class Media(object):
def should_stream(self): def optimized_for_streaming(self):
""" """
Returns True if the item's 'optimizedForStreaming' is set, False other- Returns True if the item's 'optimizedForStreaming' is set, False other-
wise wise
@ -209,7 +210,9 @@ class Media(object):
Transcode Video support; returns the URL to get a media started Transcode Video support; returns the URL to get a media started
Input: Input:
action 'DirectStream' or 'Transcode' action 'DirectPlay'
'DirectStream'
'Transcode'
quality: { quality: {
'videoResolution': e.g. '1024x768', 'videoResolution': e.g. '1024x768',
@ -224,47 +227,23 @@ class Media(object):
""" """
if self.mediastream is None and self.mediastream_number() is None: if self.mediastream is None and self.mediastream_number() is None:
return return
quality = {} if quality is None else quality headers = clientinfo.getXArgsDeviceInfo()
xargs = clientinfo.getXArgsDeviceInfo() if action == v.PLAYBACK_METHOD_DIRECT_PLAY:
# For DirectPlay, path/key of PART is needed
# trailers are 'clip' with PMS xmls
if action == "DirectStream":
path = self.xml[self.mediastream][self.part].get('key') path = self.xml[self.mediastream][self.part].get('key')
url = app.CONN.server + path
# e.g. Trailers already feature an '?'! # e.g. Trailers already feature an '?'!
return utils.extend_url(url, xargs) return utils.extend_url(app.CONN.server + path, headers)
# Direct Streaming and Transcoding
# For Transcoding arguments = PF.transcoding_arguments(path=self.path_and_plex_id(),
headers = { media=self.mediastream,
'X-Plex-Platform': 'Android', part=self.part,
'X-Plex-Platform-Version': '7.0', playmethod=action,
'X-Plex-Product': 'Plex for Android', args=quality)
'X-Plex-Version': '5.8.0.475' headers.update(arguments)
}
# Path/key to VIDEO item of xml PMS response is needed, not part # Path/key to VIDEO item of xml PMS response is needed, not part
path = self.xml.get('key') path = self.xml.get('key')
transcode_path = app.CONN.server + \ transcode_path = app.CONN.server + \
'/video/:/transcode/universal/start.m3u8' '/video/:/transcode/universal/start.m3u8'
args = { return utils.extend_url(transcode_path, headers)
'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)
def cache_external_subs(self): def cache_external_subs(self):
""" """

View file

@ -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 doesnt 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')

View file

@ -1040,3 +1040,74 @@ def show_episodes(plex_id):
'skipRefresh': 1, 'skipRefresh': 1,
} }
return DownloadChunks(utils.extend_url(url, arguments)) 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 its 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 arent 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)

View file

@ -59,21 +59,14 @@ def params_pms():
Returns the url parameters for communicating with the PMS Returns the url parameters for communicating with the PMS
""" """
return { return {
# 'X-Plex-Client-Capabilities': 'protocols=shoutcast,http-video;'
# 'videoDecoders=h264{profile:high&resolution:2160&level:52};'
# 'audioDecoders=mp3,aac,dts{bitrate:800000&channels:2},'
# 'ac3{bitrate:800000&channels:2}',
'X-Plex-Client-Identifier': v.PKC_MACHINE_IDENTIFIER, 'X-Plex-Client-Identifier': v.PKC_MACHINE_IDENTIFIER,
'X-Plex-Device': v.DEVICE, 'X-Plex-Device': v.DEVICE,
'X-Plex-Device-Name': v.DEVICENAME, 'X-Plex-Device-Name': v.DEVICENAME,
# 'X-Plex-Device-Screen-Resolution': '1916x1018,1920x1080',
'X-Plex-Model': v.MODEL, 'X-Plex-Model': v.MODEL,
'X-Plex-Platform': v.PLATFORM, 'X-Plex-Platform': v.PLATFORM,
'X-Plex-Platform-Version': v.PLATFORM_VERSION, 'X-Plex-Platform-Version': v.PLATFORM_VERSION,
'X-Plex-Product': v.ADDON_NAME, 'X-Plex-Product': v.ADDON_NAME,
'X-Plex-Version': v.ADDON_VERSION, 'X-Plex-Version': v.ADDON_VERSION,
'hasMDE': '1',
# 'X-Plex-Session-Identifier': ['vinuvirm6m20iuw9c4cx1dcx'],
} }

View file

@ -51,7 +51,6 @@ class Service(object):
return return
# Initial logging # Initial logging
LOG.info("======== START %s ========", v.ADDON_NAME) LOG.info("======== START %s ========", v.ADDON_NAME)
LOG.info("Platform: %s", v.PLATFORM)
LOG.info("KODI Version: %s", v.KODILONGVERSION) LOG.info("KODI Version: %s", v.KODILONGVERSION)
LOG.info("%s Version: %s", v.ADDON_NAME, v.ADDON_VERSION) LOG.info("%s Version: %s", v.ADDON_NAME, v.ADDON_VERSION)
LOG.info("PKC Direct Paths: %s", LOG.info("PKC Direct Paths: %s",

View file

@ -70,9 +70,6 @@ else:
DEVICE = "Unknown" DEVICE = "Unknown"
MODEL = platform.release() or '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')) DEVICENAME = try_decode(_ADDON.getSetting('deviceName'))
if not 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 # Multiply Plex time by this factor to receive Kodi time
PLEX_TO_KODI_TIMEFACTOR = 1.0 / 1000.0 PLEX_TO_KODI_TIMEFACTOR = 1.0 / 1000.0
# Playlist stuff # Playlist stuff
PLAYLIST_PATH = os.path.join(KODI_PROFILE, 'playlists') PLAYLIST_PATH = os.path.join(KODI_PROFILE, 'playlists')
PLAYLIST_PATH_MIXED = os.path.join(PLAYLIST_PATH, 'mixed') PLAYLIST_PATH_MIXED = os.path.join(PLAYLIST_PATH, 'mixed')
@ -447,6 +445,58 @@ CONTENT_FROM_PLEX_TYPE = {
None: CONTENT_TYPE_FILE 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 = { KODI_TO_PLEX_ARTWORK = {
'poster': 'thumb', 'poster': 'thumb',
'banner': 'banner', 'banner': 'banner',

View file

@ -111,10 +111,10 @@
<setting id="ignoreSpecialsNextEpisodes" type="bool" label="30527" default="false" /> <setting id="ignoreSpecialsNextEpisodes" type="bool" label="30527" default="false" />
<setting id="resumeJumpBack" type="slider" label="30521" default="10" range="0,1,120" option="int" visible="false"/> <setting id="resumeJumpBack" type="slider" label="30521" default="10" range="0,1,120" option="int" visible="false"/>
<setting type="sep" /> <setting type="sep" />
<setting id="playType" type="enum" label="30002" values="Direct Play (default)|Direct Stream|Force Transcode" default="0" /> <setting id="playType" type="enum" label="30002" values="Try Direct Path|Direct Play|Direct Stream|Force Transcode" default="0" />
<setting id="transcoderVideoQualities" type="enum" label="30160" values="420x420, 320kbps|576x320, 720kbps|720x480, 1.5Mbps|1024x768, 2Mbps|1280x720, 3Mbps|1280x720, 4Mbps|1920x1080, 8Mbps|1920x1080, 10Mbps|1920x1080, 12Mbps|1920x1080, 20Mbps|1920x1080, 40Mbps" default="10" /><!-- Video Quality if Transcoding necessary --> <setting id="transcoderVideoQualities" type="enum" label="30160" values="420x420, 320kbps|576x320, 720kbps|720x480, 1.5Mbps|1024x768, 2Mbps|720p, 3Mbps|720p, 4Mbps|1080p, 8Mbps|1080p, 10Mbps|1080p, 12Mbps|1080p, 20Mbps|1080p, 40Mbps|4K, 35Mbps|4K, 50Mbps" default="10" /><!-- Video Quality if Transcoding necessary -->
<setting id="maxVideoQualities" type="enum" label="30143" values="320kbps|720kbps|1.5Mbps|2Mbps|3Mbps|4Mbps|8Mbps|10Mbps|12Mbps|20Mbps|40Mbps|deactivated" default="11" /><!-- Always transcode if video bitrate is above --> <setting id="maxVideoQualities" type="enum" label="30143" values="320kbps|720kbps|1.5Mbps|2Mbps|3Mbps|4Mbps|8Mbps|10Mbps|12Mbps|20Mbps|40Mbps|deactivated" default="11" /><!-- Always transcode if video bitrate is above -->
<setting id="transcodeH265" type="enum" label="30522" default="0" values="Disabled (default)|480p (and higher)|720p (and higher)|1080p" /><!-- Force transcode h265/HEVC --> <setting id="transcodeH265" type="enum" label="30522" default="0" values="Disabled (default)|480p (and higher)|720p (and higher)|1080p (and higher)|4K" /><!-- Force transcode h265/HEVC -->
<setting id="transcodeHi10P" type="bool" label="39063" default="false"/> <setting id="transcodeHi10P" type="bool" label="39063" default="false"/>
<setting id="audioBoost" type="slider" label="39001" default="0" range="0,10,100" option="int"/> <setting id="audioBoost" type="slider" label="39001" default="0" range="0,10,100" option="int"/>
<setting id="subtitleSize" label="39002" type="slider" option="int" range="0,30,300" default="100" /> <setting id="subtitleSize" label="39002" type="slider" option="int" range="0,30,300" default="100" />