475 lines
20 KiB
Python
475 lines
20 KiB
Python
|
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
|