PlexKodiConnect/resources/lib/plexnet/mediadecisionengine.py

475 lines
20 KiB
Python
Raw Normal View History

2018-09-30 21:16:17 +10:00
import mediachoice
import serverdecision
import plexapp
import util
class MediaDecisionEngine(object):
proxyTypes = util.AttributeDict({
'NORMAL': 0,
'LOCAL': 42,
'CLOUD': 43
})
def __init__(self):
self.softSubLanguages = None
# TODO(schuyler): Do we need to allow this to be async? We may have to request
# the media again to fetch details, and we may need to make multiple requests to
# resolve an indirect. We can do it all async, we can block, or we can allow
# both.
def chooseMedia(self, item, forceUpdate=False):
# If we've already evaluated this item, use our previous choice.
if not forceUpdate and item.mediaChoice is not None and item.mediaChoice.media is not None and not item.mediaChoice.media.isIndirect():
return item.mediaChoice
# See if we're missing media/stream details for this item.
if item.isLibraryItem() and item.isVideoItem() and len(item.media) > 0 and not item.media[0].hasStreams():
# TODO(schuyler): Fetch the details
util.WARN_LOG("Can't make media choice, missing details")
# Take a first pass through the media items to create an array of candidates
# that we'll evaluate more completely. If we find a forced item, we use it.
# If we find an indirect, we only keep a single candidate.
indirect = False
candidates = []
maxResolution = item.settings.getMaxResolution(item.getQualityType())
for mediaIndex in range(len(item.media)):
media = item.media[mediaIndex]
media.mediaIndex = mediaIndex
if media.isSelected():
candidates = []
candidates.append(media)
break
if media.isIndirect():
# Only add indirect media if the resolution fits. We cannot
# exit early as the user may have selected media.
indirect = True
if media.getVideoResolution() <= maxResolution:
candidates.append(media)
elif media.isAccessible():
# Only consider testing available media
candidates.append(media)
# Only use the first indirect media item
if indirect and candidates:
candidates = candidates[0]
# Make sure we have at least one valid item, regardless of availability
if len(candidates) == 0:
candidates.append(item.media[0])
# Now that we have an array of candidates, evaluate them completely.
choices = []
for media in candidates:
choice = None
if media is not None:
if item.isVideoItem():
choice = self.evaluateMediaVideo(item, media)
elif item.isMusicItem():
choice = self.evaluateMediaMusic(item, media)
else:
choice = mediachoice.MediaChoice(media)
choices.append(choice)
item.mediaChoice = self.sortChoices(choices)[-1]
util.LOG("MDE: MediaChoice: {0}".format(item.mediaChoice))
return item.mediaChoice
def sortChoices(self, choices):
if choices is None:
return []
if len(choices) > 1:
self.sort(choices, "bitrate")
self.sort(choices, "audioChannels")
self.sort(choices, "audioDS")
self.sort(choices, "resolution")
self.sort(choices, "videoDS")
self.sort(choices, "directPlay")
self.sort(choices, self.higherResIfCapable)
self.sort(choices, self.cloudIfRemote)
return choices
def evaluateMediaVideo(self, item, media, partIndex=0):
# Resolve indirects before doing anything else.
if media.isIndirect():
util.LOG("Resolve indirect media for {0}".format(item))
media = media.resolveIndirect()
choice = mediachoice.MediaChoice(media, partIndex)
server = item.getServer()
if not media:
return choice
choice.isSelected = media.isSelected()
choice.protocol = media.protocol("http")
maxResolution = item.settings.getMaxResolution(item.getQualityType(), self.isSupported4k(media, choice.videoStream))
maxBitrate = item.settings.getMaxBitrate(item.getQualityType())
choice.resolution = media.getVideoResolution()
if choice.resolution > maxResolution or media.bitrate.asInt() > maxBitrate:
choice.forceTranscode = True
if choice.subtitleStream:
choice.subtitleDecision = self.evaluateSubtitles(choice.subtitleStream)
choice.hasBurnedInSubtitles = (choice.subtitleDecision != choice.SUBTITLES_SOFT_DP and choice.subtitleDecision != choice.SUBTITLES_SOFT_ANY)
else:
choice.hasBurnedInSubtitles = False
# For evaluation purposes, we only care about the first part
part = media.parts[partIndex]
if not part:
return choice
# Although PMS has already told us which streams are selected, we can't
# necessarily tell the video player which streams we want. So we need to
# iterate over the streams and see if there are any red flags that would
# prevent direct play. If there are multiple video streams, we're hosed.
# For audio streams, we have a fighting chance if the selected stream can
# be selected by language, but we need to be careful about guessing which
# audio stream the Roku will pick for a given language.
numVideoStreams = 0
problematicAudioStream = False
if part.get('hasChapterVideoStream').asBool():
numVideoStreams = 1
for stream in part.streams:
streamType = stream.streamType.asInt()
if streamType == stream.TYPE_VIDEO:
numVideoStreams = numVideoStreams + 1
if stream.codec == "h264" or (
stream.codec == "hevc" and item.settings.getPreference("allow_hevc", False)
) or (
stream.codec == "vp9" and item.settings.getGlobal("vp9Support")
):
choice.sorts.videoDS = 1
# Special cases to force direct play
forceDirectPlay = False
if choice.protocol == "hls":
util.LOG("MDE: Assuming HLS is direct playable")
forceDirectPlay = True
elif not server.supportsVideoTranscoding:
# See if we can use another server to transcode, otherwise force direct play
transcodeServer = item.getTranscodeServer(True, "video")
if not transcodeServer or not transcodeServer.supportsVideoTranscoding:
util.LOG("MDE: force direct play because the server does not support video transcoding")
forceDirectPlay = True
# See if we found any red flags based on the streams. Otherwise, go ahead
# with our codec checks.
if forceDirectPlay:
# Consider the choice DP, but continue to allow the
# choice to have the sorts set properly.
choice.isDirectPlayable = True
elif choice.hasBurnedInSubtitles:
util.LOG("MDE: Need to burn in subtitles")
elif choice.protocol != "http":
util.LOG("MDE: " + choice.protocol + " not supported")
elif numVideoStreams > 1:
util.LOG("MDE: Multiple video streams, won't try to direct play")
elif problematicAudioStream:
util.LOG("MDE: Problematic AAC stream with more than 2 channels prevents direct play")
elif self.canDirectPlay(item, choice):
choice.isDirectPlayable = True
elif item.isMediaSynthesized:
util.LOG("MDE: assuming synthesized media can direct play")
choice.isDirectPlayable = True
# Check for a server decision. This is authority as it's the only playback type
# the server will allow. This will also support forcing direct play, overriding
# only our local MDE checks based on the user pref, and only if the server
# agrees.
decision = part.get("decision")
if decision:
if decision != serverdecision.ServerDecision.DECISION_DIRECT_PLAY:
util.LOG("MDE: Server has decided this cannot direct play")
choice.isDirectPlayable = False
else:
util.LOG("MDE: Server has allowed direct play")
choice.isDirectPlayable = True
# Setup sorts
if choice.videoStream is not None:
choice.sorts.bitrate = choice.videoStream.bitrate.asInt()
elif choice.media is not None:
choice.sorts.bitrate = choice.media.bitrate.asInt()
else:
choice.sorts.bitrate = 0
if choice.audioStream is not None:
choice.sorts.audioChannels = choice.audioStream.channels.asInt()
elif choice.media is not None:
choice.sorts.audioChannels = choice.media.audioChannels.asInt()
else:
choice.sorts.audioChannels = 0
choice.sorts.videoDS = not (choice.sorts.videoDS is None or choice.forceTranscode is True) and choice.sorts.videoDS or 0
choice.sorts.resolution = choice.resolution
# Server properties probably don't need to be associated with each choice
choice.sorts.canTranscode = server.supportsVideoTranscoding and 1 or 0
choice.sorts.canRemuxOnly = server.supportsVideoRemuxOnly and 1 or 0
choice.sorts.directPlay = (choice.isDirectPlayable is True and choice.forceTranscode is not True) and 1 or 0
choice.sorts.proxyType = choice.media.proxyType and choice.media.proxyType or self.proxyTypes.NORMAL
return choice
def canDirectPlay(self, item, choice):
maxResolution = item.settings.getMaxResolution(item.getQualityType(), self.isSupported4k(choice.media, choice.videoStream))
height = choice.media.getVideoResolution()
if height > maxResolution:
util.LOG("MDE: (DP) Video height is greater than max allowed: {0} > {1}".format(height, maxResolution))
if height > 1088 and item.settings.getPreference("allow_4k", True):
util.LOG("MDE: (DP) Unsupported 4k media")
return False
maxBitrate = item.settings.getMaxBitrate(item.getQualityType())
bitrate = choice.media.bitrate.asInt()
if bitrate > maxBitrate:
util.LOG("MDE: (DP) Video bitrate is greater than the allowed max: {0} > {1}".format(bitrate, maxBitrate))
return False
if choice.videoStream is None:
util.ERROR_LOG("MDE: (DP) No video stream")
return True
if not item.settings.getGlobal("supports1080p60"):
videoFrameRate = choice.videoStream.asInt()
if videoFrameRate > 30 and height >= 1080:
util.LOG("MDE: (DP) Frame rate is not supported for resolution: {0}@{1}".format(height, videoFrameRate))
return False
if choice.videoStream.codec == "hevc" and not item.settings.getPreference("allow_hevc", False):
util.LOG("MDE: (DP) Codec is HEVC, which is disabled")
return False
return True
# container = choice.media.get('container')
# videoCodec = choice.videoStream.codec
# if choice.audioStream is None:
# audioCodec = None
# numChannels = 0
# else:
# audioCodec = choice.audioStream.codec
# numChannels = choice.audioStream.channels.asInt()
# Formats: https://support.roku.com/hc/en-us/articles/208754908-Roku-Media-Player-Playing-your-personal-videos-music-photos
# All Models: H.264/AVC (MKV, MP4, MOV),
# Roku 4 only: H.265/HEVC (MKV, MP4, MOV); VP9 (.MKV)
# if True: # container in ("mp4", "mov", "m4v", "mkv"):
# util.LOG("MDE: {0} container looks OK, checking streams".format(container))
# isHEVC = videoCodec == "hevc" and item.settings.getPreference("allow_hevc", False)
# isVP9 = videoCodec == "vp9" and container == "mkv" and item.settings.getGlobal("vp9Support")
# if videoCodec != "h264" and videoCodec != "mpeg4" and not isHEVC and not isVP9:
# util.LOG("MDE: Unsupported video codec: {0}".format(videoCodec))
# return False
# # TODO(schuyler): Fix ref frames check. It's more nuanced than this.
# if choice.videoStream.refFrames.asInt() > 8:
# util.LOG("MDE: Too many ref frames: {0}".format(choice.videoStream.refFrames))
# return False
# # HEVC supports a bitDepth of 10, otherwise 8 is the limit
# if choice.videoStream.bitDepth.asInt() > (isHEVC and 10 or 8):
# util.LOG("MDE: Bit depth too high: {0}".format(choice.videoStream.bitDepth))
# return False
# # We shouldn't have to whitelist particular audio codecs, we can just
# # check to see if the Roku can decode this codec with the number of channels.
# if not item.settings.supportsAudioStream(audioCodec, numChannels):
# util.LOG("MDE: Unsupported audio track: {0} ({1} channels)".format(audioCodec, numChannels))
# return False
# # # TODO(schuyler): We've reported this to Roku, they may fix it. If/when
# # # they do, we should move this behind a firmware version check.
# # if container == "mkv" and choice.videoStream.headerStripping.asBool() and audioCodec == "ac3":
# # util.ERROR_LOG("MDE: Header stripping with AC3 audio")
# # return False
# # Those were our problems, everything else should be OK.
# return True
# else:
# util.LOG("MDE: Unsupported container: {0}".format(container))
# return False
def evaluateSubtitles(self, stream):
if plexapp.INTERFACE.getPreference("burn_subtitles") == "always":
# If the user prefers them burned, always burn
return mediachoice.MediaChoice.SUBTITLES_BURN
# elif stream.codec != "srt":
# # We only support soft subtitles for SRT. Anything else has to use the
# # transcoder, and we defer to it on whether the subs will have to be
# # burned or can be converted to SRT and muxed.
# return mediachoice.MediaChoice.SUBTITLES_DEFAULT
elif stream.key is None:
# Embedded subs don't have keys and can only be direct played
result = mediachoice.MediaChoice.SUBTITLES_SOFT_DP
else:
# Sidecar subs can be direct played or used alongside a transcode
result = mediachoice.MediaChoice.SUBTITLES_SOFT_ANY
# # TODO(schuyler) If Roku adds support for non-Latin characters, remove
# # this hackery. To the extent that we continue using this hackery, it
# # seems that the Roku requires UTF-8 subtitles but only supports characters
# # from Windows-1252. This should be the full set of languages that are
# # completely representable in Windows-1252. PMS should specifically be
# # returning ISO 639-2/B language codes.
# # Update: Roku has added support for additional characters, but still only
# # Latin characters. We can now basically support anything from the various
# # ISO-8859 character sets, but nothing non-Latin.
# if not self.softSubLanguages:
# self.softSubLanguages = frozenset((
# 'afr',
# 'alb',
# 'baq',
# 'bre',
# 'cat',
# 'cze',
# 'dan',
# 'dut',
# 'eng',
# 'epo',
# 'est',
# 'fao',
# 'fin',
# 'fre',
# 'ger',
# 'gla',
# 'gle',
# 'glg',
# 'hrv',
# 'hun',
# 'ice',
# 'ita',
# 'lat',
# 'lav',
# 'lit',
# 'ltz',
# 'may',
# 'mlt',
# 'nno',
# 'nob',
# 'nor',
# 'oci',
# 'pol',
# 'por',
# 'roh',
# 'rum',
# 'slo',
# 'slv',
# 'spa',
# 'srd',
# 'swa',
# 'swe',
# 'tur',
# 'vie',
# 'wel',
# 'wln'
# ))
# if not (stream.languageCode or 'eng') in self.softSubLanguages:
# # If the language is unsupported,: we need to force burning
# result = mediachoice.MediaChoice.SUBTITLES_BURN
return result
def evaluateMediaMusic(self, item, media):
# Resolve indirects before doing anything else.
if media.isIndirect():
util.LOG("Resolve indirect media for {0}".format(item))
media = media.resolveIndirect()
choice = mediachoice.MediaChoice(media)
if media is None:
return choice
# Verify the server supports audio transcoding, otherwise force direct play
if not item.getServer().supportsAudioTranscoding:
util.LOG("MDE: force direct play because the server does not support audio transcoding")
choice.isDirectPlayable = True
return choice
# See if this part has a server decision to transcode and obey it
if choice.part and choice.part.get(
"decision", serverdecision.ServerDecision.DECISION_DIRECT_PLAY
) != serverdecision.ServerDecision.DECISION_DIRECT_PLAY:
util.WARN_LOG("MDE: Server has decided this cannot direct play")
return choice
# Verify the codec and container are compatible
codec = media.audioCodec
container = media.get('container')
canPlayCodec = item.settings.supportsAudioStream(codec, media.audioChannels.asInt())
canPlayContainer = (codec == container) or True # (container in ("mp4", "mka", "mkv"))
choice.isDirectPlayable = (canPlayCodec and canPlayContainer)
if choice.isDirectPlayable:
# Inspect the audio stream attributes if the codec/container can direct
# play. For now we only need to verify the sample rate.
if choice.audioStream is not None and choice.audioStream.samplingRate.asInt() >= 192000:
util.LOG("MDE: sampling rate is not compatible")
choice.isDirectPlayable = False
else:
util.LOG("MDE: container or codec is incompatible")
return choice
# Simple Quick sort function modeled after roku sdk function
def sort(self, choices, key=None):
if not isinstance(choices, list):
return
if key is None:
choices.sort()
elif isinstance(key, basestring):
choices.sort(key=lambda x: getattr(x.media, key))
elif hasattr(key, '__call__'):
choices.sort(key=key)
def higherResIfCapable(self, choice):
if choice.media is not None:
server = choice.media.getServer()
if server.supportsVideoTranscoding and not server.supportsVideoRemuxOnly and (choice.sorts.directPlay == 1 or choice.sorts.videoDS == 1):
return util.validInt(choice.sorts.resolution)
return 0
def cloudIfRemote(self, choice):
if choice.media is not None and choice.media.getServer().isLocalConnection() and choice.media.proxyType != self.proxyTypes.CLOUD:
return 1
return 0
def isSupported4k(self, media, videoStream):
if videoStream is None or not plexapp.INTERFACE.getPreference("allow_4k", True):
return False
# # Roku 4 only: H.265/HEVC (MKV, MP4, MOV); VP9 (.MKV)
# if media.get('container') in ("mp4", "mov", "m4v", "mkv"):
# isHEVC = (videoStream.codec == "hevc" and plexapp.INTERFACE.getPreference("allow_hevc"))
# isVP9 = (videoStream.codec == "vp9" and media.get('container') == "mkv" and plexapp.INTERFACE.getGlobal("vp9Support"))
# return (isHEVC or isVP9)
# return False
return True