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:
commit
52c1a0e47d
14 changed files with 738 additions and 433 deletions
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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':
|
||||||
|
|
458
resources/lib/playback_decision.py
Normal file
458
resources/lib/playback_decision.py
Normal 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)
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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):
|
||||||
"""
|
"""
|
||||||
|
|
107
resources/lib/plex_api/playback.py
Normal file
107
resources/lib/plex_api/playback.py
Normal 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 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')
|
|
@ -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 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)
|
||||||
|
|
|
@ -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'],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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',
|
||||||
|
|
|
@ -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" />
|
||||||
|
|
Loading…
Reference in a new issue