import re import util import captions import http import plexrequest import mediadecisionengine import serverdecision DecisionFailure = serverdecision.DecisionFailure class PlexPlayer(object): DECISION_ENDPOINT = "/video/:/transcode/universal/decision" def __init__(self, item, seekValue=0, forceUpdate=False): self.decision = None self.seekValue = seekValue self.metadata = None self.init(item, forceUpdate) def init(self, item, forceUpdate=False): self.item = item self.choice = mediadecisionengine.MediaDecisionEngine().chooseMedia(item, forceUpdate=forceUpdate) if self.choice: self.media = self.choice.media def terminate(self, code, reason): util.LOG('TERMINATE PLAYER: ({0}, {1})'.format(code, reason)) # TODO: Handle this? ---------------------------------------------------------------------------------------------------------- TODO def rebuild(self, item, decision=None): # item.settings = self.item.settings oldChoice = self.choice self.init(item, True) util.LOG("Replacing '{0}' with '{1}' and rebuilding.".format(oldChoice, self.choice)) self.build() self.decision = decision def build(self, forceTranscode=False): if self.item.settings.getPreference("playback_directplay", False): directPlayPref = self.item.settings.getPreference("playback_directplay_force", False) and 'forced' or 'allow' else: directPlayPref = 'disabled' if forceTranscode or directPlayPref == "disabled" or self.choice.hasBurnedInSubtitles is True: directPlay = False else: directPlay = directPlayPref == "forced" and True or None return self._build(directPlay, self.item.settings.getPreference("playback_remux", False)) def _build(self, directPlay=None, directStream=True, currentPartIndex=None): isForced = directPlay is not None if isForced: util.LOG(directPlay and "Forced Direct Play" or "Forced Transcode; allowDirectStream={0}".format(directStream)) directPlay = directPlay or self.choice.isDirectPlayable server = self.item.getServer() # A lot of our content metadata is independent of the direct play decision. # Add that first. obj = util.AttributeDict() obj.duration = self.media.duration.asInt() videoRes = self.media.getVideoResolution() obj.fullHD = videoRes >= 1080 obj.streamQualities = (videoRes >= 480 and self.item.settings.getGlobal("IsHD")) and ["HD"] or ["SD"] frameRate = self.media.videoFrameRate or "24p" if frameRate == "24p": obj.frameRate = 24 elif frameRate == "NTSC": obj.frameRate = 30 # Add soft subtitle info if self.choice.subtitleDecision == self.choice.SUBTITLES_SOFT_ANY: obj.subtitleUrl = server.buildUrl(self.choice.subtitleStream.getSubtitlePath(), True) elif self.choice.subtitleDecision == self.choice.SUBTITLES_SOFT_DP: obj.subtitleConfig = {'TrackName': "mkv/" + str(self.choice.subtitleStream.index.asInt() + 1)} # Create one content metadata object for each part and store them as a # linked list. We probably want a doubly linked list, except that it # becomes a circular reference nuisance, so we make the current item the # base object and singly link in each direction from there. baseObj = obj prevObj = None startOffset = 0 startPartIndex = currentPartIndex or 0 for partIndex in range(startPartIndex, len(self.media.parts)): isCurrentPart = (currentPartIndex is not None and partIndex == currentPartIndex) partObj = util.AttributeDict() partObj.update(baseObj) partObj.live = False partObj.partIndex = partIndex partObj.startOffset = startOffset part = self.media.parts[partIndex] partObj.partDuration = part.duration.asInt() if part.isIndexed(): partObj.sdBifPath = part.getIndexPath("sd") partObj.hdBifPath = part.getIndexPath("hd") # We have to evaluate every part before playback. Normally we'd expect # all parts to be identical, but in reality they can be different. if partIndex > 0 and (not isForced and directPlay or not isCurrentPart): choice = mediadecisionengine.MediaDecisionEngine().evaluateMediaVideo(self.item, self.media, partIndex) canDirectPlay = (choice.isDirectPlayable is True) else: canDirectPlay = directPlay if canDirectPlay: partObj = self.buildDirectPlay(partObj, partIndex) else: transcodeServer = self.item.getTranscodeServer(True, "video") if transcodeServer is None: return None partObj = self.buildTranscode(transcodeServer, partObj, partIndex, directStream, isCurrentPart) # Set up our linked list references. If we couldn't build an actual # object: fail fast. Otherwise, see if we're at our start offset # yet in order to decide if we need to link forwards or backwards. # We also need to account for parts missing a duration, by verifying # the prevObj is None or if the startOffset has incremented. if partObj is None: obj = None break elif prevObj is None or (startOffset > 0 and int(self.seekValue / 1000) >= startOffset): obj = partObj partObj.prevObj = prevObj elif prevObj is not None: prevObj.nextPart = partObj startOffset = startOffset + int(part.duration.asInt() / 1000) prevObj = partObj # Only set PlayStart for the initial part, and adjust for the part's offset if obj is not None: if obj.live: # Start the stream at the end. Per Roku, this can be achieved using # a number higher than the duration. Using the current time should # ensure it's definitely high enough. obj.playStart = util.now() + 1800 else: obj.playStart = int(self.seekValue / 1000) - obj.startOffset self.metadata = obj util.LOG("Constructed video item for playback: {0}".format(dict(obj))) return self.metadata @property def startOffset(self): return self.metadata and self.metadata.startOffset or 0 def offsetIsValid(self, offset_seconds): return self.metadata.startOffset <= offset_seconds < self.metadata.startOffset + (self.metadata.partDuration / 1000) def isLiveHls(url=None, headers=None): # Check to see if this is a live HLS playlist to fix two issues. One is a # Roku workaround since it doesn't obey the absence of EXT-X-ENDLIST to # start playback at the END of the playlist. The second is for us to know # if it's live to modify the functionality and player UI. # if IsString(url): # request = createHttpRequest(url, "GET", true) # AddRequestHeaders(request.request, headers) # response = request.GetToStringWithTimeout(10) # ' Inspect one of the media playlist streams if this is a master playlist. # if response.instr("EXT-X-STREAM-INF") > -1 then # Info("Identify live HLS: inspecting the master playlist") # mediaUrl = CreateObject("roRegex", "(^https?://.*$)", "m").Match(response)[1] # if mediaUrl <> invalid then # request = createHttpRequest(mediaUrl, "GET", true) # AddRequestHeaders(request.request, headers) # response = request.GetToStringWithTimeout(10) # end if # end if # isLiveHls = (response.Trim().Len() > 0 and response.instr("EXT-X-ENDLIST") = -1 and response.instr("EXT-X-STREAM-INF") = -1) # Info("Identify live HLS: live=" + isLiveHls.toStr()) # return isLiveHls return False def getServerDecision(self): directPlay = not (self.metadata and self.metadata.isTranscoded) decisionPath = self.getDecisionPath(directPlay) newDecision = None if decisionPath: server = self.metadata.transcodeServer or self.item.getServer() request = plexrequest.PlexRequest(server, decisionPath) response = request.getWithTimeout(10) if response.isSuccess() and response.container: decision = serverdecision.ServerDecision(self, response, self) if decision.isSuccess(): util.LOG("MDE: Server was happy with client's original decision. {0}".format(decision)) elif decision.isDecision(True): util.WARN_LOG("MDE: Server was unhappy with client's original decision. {0}".format(decision)) return decision.getDecision() else: util.LOG("MDE: Server was unbiased about the decision. {0}".format(decision)) # Check if the server has provided a new media item to use it. If # there is no item, then we'll continue along as if there was no # decision made. newDecision = decision.getDecision(False) else: util.WARN_LOG("MDE: Server failed to provide a decision") else: util.WARN_LOG("MDE: Server or item does not support decisions") return newDecision or self def getDecisionPath(self, directPlay=False): if not self.item or not self.metadata: return None decisionPath = self.metadata.decisionPath if not decisionPath: server = self.metadata.transcodeServer or self.item.getServer() decisionPath = self.buildTranscode(server, util.AttributeDict(), self.metadata.partIndex, True, False).decisionPath util.TEST(decisionPath) # Modify the decision params based on the transcode url if decisionPath: if directPlay: decisionPath = decisionPath.replace("directPlay=0", "directPlay=1") # Clear all subtitle parameters and add the a valid subtitle type based # on the video player. This will let the server decide if it can supply # sidecar subs, burn or embed w/ an optional transcode. for key in ("subtitles", "advancedSubtitles"): decisionPath = re.sub('([?&]{0}=)\w+'.format(key), '', decisionPath) subType = 'sidecar' # AppSettings().getBoolPreference("custom_video_player"), "embedded", "sidecar") decisionPath = http.addUrlParam(decisionPath, "subtitles=" + subType) # Global variables for all decisions decisionPath = http.addUrlParam(decisionPath, "mediaBufferSize=20971") # Kodi default is 20971520 (20MB) decisionPath = http.addUrlParam(decisionPath, "hasMDE=1") decisionPath = http.addUrlParam(decisionPath, 'X-Plex-Platform=Chrome') return decisionPath def getTranscodeReason(self): # Combine the server and local MDE decisions obj = [] if self.decision: obj.append(self.decision.getDecisionText()) if self.item: obj.append(self.item.transcodeReason) reason = ' '.join(obj) if not reason: return None return reason def buildTranscodeHls(self, obj): util.DEBUG_LOG('buildTranscodeHls()') obj.streamFormat = "hls" obj.streamBitrates = [0] obj.switchingStrategy = "no-adaptation" obj.transcodeEndpoint = "/video/:/transcode/universal/start.m3u8" builder = http.HttpRequest(obj.transcodeServer.buildUrl(obj.transcodeEndpoint, True)) builder.extras = [] builder.addParam("protocol", "hls") if self.choice.subtitleDecision == self.choice.SUBTITLES_SOFT_ANY: builder.addParam("skipSubtitles", "1") else: # elif self.choice.hasBurnedInSubtitles is True: # Must burn transcoded because we can't set offset captionSize = captions.CAPTIONS.getBurnedSize() if captionSize is not None: builder.addParam("subtitleSize", captionSize) # Augment the server's profile for things that depend on the Roku's configuration. if self.item.settings.supportsAudioStream("ac3", 6): builder.extras.append("append-transcode-target-audio-codec(type=videoProfile&context=streaming&protocol=hls&audioCodec=ac3)") builder.extras.append("add-direct-play-profile(type=videoProfile&container=matroska&videoCodec=*&audioCodec=ac3)") return builder def buildTranscodeMkv(self, obj): util.DEBUG_LOG('buildTranscodeMkv()') obj.streamFormat = "mkv" obj.streamBitrates = [0] obj.transcodeEndpoint = "/video/:/transcode/universal/start.mkv" builder = http.HttpRequest(obj.transcodeServer.buildUrl(obj.transcodeEndpoint, True)) builder.extras = [] # builder.addParam("protocol", "http") builder.addParam("copyts", "1") obj.subtitleUrl = None if True: # if self.choice.subtitleDecision == self.choice.SUBTITLES_BURN: # Must burn transcoded because we can't set offset builder.addParam("subtitles", "burn") captionSize = captions.CAPTIONS.getBurnedSize() if captionSize is not None: builder.addParam("subtitleSize", captionSize) else: # TODO(rob): can we safely assume the id will also be 3 (one based index). # If not, we will have to get tricky and select the subtitle stream after # video playback starts via roCaptionRenderer: GetSubtitleTracks() and # ChangeSubtitleTrack() obj.subtitleConfig = {'TrackName': "mkv/3"} # Allow text conversion of subtitles if we only burn image formats if self.item.settings.getPreference("burn_subtitles") == "image": builder.addParam("advancedSubtitles", "text") builder.addParam("subtitles", "auto") # Augment the server's profile for things that depend on the Roku's configuration. if self.item.settings.supportsSurroundSound(): if self.choice.audioStream is not None: numChannels = self.choice.audioStream.channels.asInt(6) else: numChannels = 6 for codec in ("ac3", "eac3", "dca"): if self.item.settings.supportsAudioStream(codec, numChannels): builder.extras.append("append-transcode-target-audio-codec(type=videoProfile&context=streaming&audioCodec=" + codec + ")") builder.extras.append("add-direct-play-profile(type=videoProfile&container=matroska&videoCodec=*&audioCodec=" + codec + ")") if codec == "dca": builder.extras.append( "add-limitation(scope=videoAudioCodec&scopeName=dca&type=upperBound&name=audio.channels&value=6&isRequired=false)" ) # AAC sample rate cannot be less than 22050hz (HLS is capable). if self.choice.audioStream is not None and self.choice.audioStream.samplingRate.asInt(22050) < 22050: builder.extras.append("add-limitation(scope=videoAudioCodec&scopeName=aac&type=lowerBound&name=audio.samplingRate&value=22050&isRequired=false)") # HEVC and VP9 support! if self.item.settings.getGlobal("hevcSupport"): builder.extras.append("append-transcode-target-codec(type=videoProfile&context=streaming&videoCodec=hevc)") if self.item.settings.getGlobal("vp9Support"): builder.extras.append("append-transcode-target-codec(type=videoProfile&context=streaming&videoCodec=vp9)") return builder def buildDirectPlay(self, obj, partIndex): util.DEBUG_LOG('buildDirectPlay()') part = self.media.parts[partIndex] server = self.item.getServer() # Check if we should include our token or not for this request obj.isRequestToServer = server.isRequestToServer(server.buildUrl(part.getAbsolutePath("key"))) obj.streamUrls = [server.buildUrl(part.getAbsolutePath("key"), obj.isRequestToServer)] obj.token = obj.isRequestToServer and server.getToken() or None if self.media.protocol == "hls": obj.streamFormat = "hls" obj.switchingStrategy = "full-adaptation" obj.live = self.isLiveHLS(obj.streamUrls[0], self.media.indirectHeaders) else: obj.streamFormat = self.media.get('container', 'mp4') if obj.streamFormat == "mov" or obj.streamFormat == "m4v": obj.streamFormat = "mp4" obj.streamBitrates = [self.media.bitrate.asInt()] obj.isTranscoded = False if self.choice.audioStream is not None: obj.audioLanguageSelected = self.choice.audioStream.languageCode return obj def hasMoreParts(self): return (self.metadata is not None and self.metadata.nextPart is not None) def getNextPartOffset(self): return self.metadata.nextPart.startOffset * 1000 def goToNextPart(self): oldPart = self.metadata if oldPart is None: return newPart = oldPart.nextPart if newPart is None: return newPart.prevPart = oldPart oldPart.nextPart = None self.metadata = newPart util.LOG("Next part set for playback: {0}".format(self.metadata)) def getBifUrl(self, offset=0): server = self.item.getServer() startOffset = 0 for part in self.media.parts: duration = part.duration.asInt() if startOffset <= offset < startOffset + duration: bifUrl = part.getIndexPath("hd") or part.getIndexPath("sd") if bifUrl is not None: url = server.buildUrl('{0}/{1}'.format(bifUrl, offset - startOffset), True) return url startOffset += duration return None def buildTranscode(self, server, obj, partIndex, directStream, isCurrentPart): util.DEBUG_LOG('buildTranscode()') obj.transcodeServer = server obj.isTranscoded = True # if server.supportsFeature("mkvTranscode") and self.item.settings.getPreference("transcode_format", 'mkv') != "hls": if server.supportsFeature("mkvTranscode"): builder = self.buildTranscodeMkv(obj) else: builder = self.buildTranscodeHls(obj) if self.item.getServer().TYPE == 'MYPLEXSERVER': path = server.swizzleUrl(self.item.getAbsolutePath("key")) else: path = self.item.getAbsolutePath("key") builder.addParam("path", path) part = self.media.parts[partIndex] seekOffset = int(self.seekValue / 1000) # Disabled for HLS due to a Roku bug plexinc/roku-client-issues#776 if True: # obj.streamFormat == "mkv": # Trust our seekOffset for this part if it's the current part (now playing) or # the seekOffset is within the time frame. We have to trust the current part # as we may have to rebuild the transcode when seeking, and not all parts # have a valid duration. if isCurrentPart or len(self.media.parts) <= 1 or ( seekOffset >= obj.startOffset and seekOffset <= obj.get('startOffset', 0) + int(part.duration.asInt() / 1000) ): startOffset = seekOffset - (obj.startOffset or 0) # Avoid a perfect storm of PMS and Roku quirks. If we pass an offset to # the transcoder,: it'll start transcoding from that point. But if # we try to start a few seconds into the video, the Roku seems to want # to grab the first segment. The first segment doesn't exist, so PMS # returns a 404 (but only if the offset is <= 12s, otherwise it returns # a blank segment). If the Roku gets a 404 for the first segment,: # it'll fail. So, if we're going to start playing from less than 12 # seconds, don't bother telling the transcoder. It's not worth the # potential failure, let it transcode from the start so that the first # segment will always exist. # TODO: Probably can remove this (Rick) if startOffset <= 12: startOffset = 0 else: startOffset = 0 builder.addParam("offset", str(startOffset)) builder.addParam("session", self.item.settings.getGlobal("clientIdentifier")) builder.addParam("directStream", directStream and "1" or "0") builder.addParam("directPlay", "0") qualityIndex = self.item.settings.getQualityIndex(self.item.getQualityType(server)) builder.addParam("videoQuality", self.item.settings.getGlobal("transcodeVideoQualities")[qualityIndex]) builder.addParam("videoResolution", str(self.item.settings.getGlobal("transcodeVideoResolutions")[qualityIndex])) builder.addParam("maxVideoBitrate", self.item.settings.getGlobal("transcodeVideoBitrates")[qualityIndex]) if self.media.mediaIndex is not None: builder.addParam("mediaIndex", str(self.media.mediaIndex)) builder.addParam("partIndex", str(partIndex)) # Augment the server's profile for things that depend on the Roku's configuration. if self.item.settings.getPreference("h264_level", "auto") != "auto": builder.extras.append( "add-limitation(scope=videoCodec&scopeName=h264&type=upperBound&name=video.level&value={0}&isRequired=true)".format( self.item.settings.getPreference("h264_level") ) ) if not self.item.settings.getGlobal("supports1080p60") and self.item.settings.getGlobal("transcodeVideoResolutions")[qualityIndex][0] >= 1920: builder.extras.append("add-limitation(scope=videoCodec&scopeName=h264&type=upperBound&name=video.frameRate&value=30&isRequired=false)") if builder.extras: builder.addParam("X-Plex-Client-Profile-Extra", '+'.join(builder.extras)) if server.isLocalConnection(): builder.addParam("location", "lan") obj.streamUrls = [builder.getUrl()] # Build the decision path now that we have build our stream url, and only if the server supports it. if server.supportsFeature("streamingBrain"): util.TEST("TEST==========================") decisionPath = builder.getRelativeUrl().replace(obj.transcodeEndpoint, self.DECISION_ENDPOINT) if decisionPath.startswith(self.DECISION_ENDPOINT): obj.decisionPath = decisionPath return obj class PlexAudioPlayer(object): def __init__(self, item): self.containerFormats = { 'aac': "es.aac-adts" } self.item = item self.choice = mediadecisionengine.MediaDecisionEngine().chooseMedia(item) if self.choice: self.media = self.choice.media self.lyrics = None # createLyrics(item, self.media) def build(self, directPlay=None): directPlay = directPlay or self.choice.isDirectPlayable obj = util.AttributeDict() # TODO(schuyler): Do we want/need to add anything generic here? Title? Duration? if directPlay: obj = self.buildDirectPlay(obj) else: obj = self.buildTranscode(obj) self.metadata = obj util.LOG("Constructed audio item for playback: {0}".format(dict(obj))) return self.metadata def buildTranscode(self, obj): transcodeServer = self.item.getTranscodeServer(True, "audio") if not transcodeServer: return None obj.streamFormat = "mp3" obj.isTranscoded = True obj.transcodeServer = transcodeServer obj.transcodeEndpoint = "/music/:/transcode/universal/start.m3u8" builder = http.HttpRequest(transcodeServer.buildUrl(obj.transcodeEndpoint, True)) # builder.addParam("protocol", "http") builder.addParam("path", self.item.getAbsolutePath("key")) builder.addParam("session", self.item.getGlobal("clientIdentifier")) builder.addParam("directPlay", "0") builder.addParam("directStream", "0") obj.url = builder.getUrl() return obj def buildDirectPlay(self, obj): if self.choice.part: obj.url = self.item.getServer().buildUrl(self.choice.part.getAbsolutePath("key"), True) # Set and override the stream format if applicable obj.streamFormat = self.choice.media.get('container', 'mp3') if self.containerFormats.get(obj.streamFormat): obj.streamFormat = self.containerFormats[obj.streamFormat] # If we're direct playing a FLAC, bitrate can be required, and supposedly # this is the only way to do it. plexinc/roku-client#48 # bitrate = self.choice.media.bitrate.asInt() if bitrate > 0: obj.streams = [{'url': obj.url, 'bitrate': bitrate}] return obj # We may as well fallback to transcoding if we could not direct play return self.buildTranscode(obj) def getLyrics(self): return self.lyrics def hasLyrics(self): return False return self.lyrics.isAvailable() class PlexPhotoPlayer(object): def __init__(self, item): self.item = item self.choice = item self.media = item.media()[0] self.metadata = None def build(self): if self.media.parts and self.media.parts[0]: obj = util.AttributeDict() part = self.media.parts[0] path = part.key or part.thumb server = self.item.getServer() obj.url = server.buildUrl(path, True) obj.enableBlur = server.supportsPhotoTranscoding util.DEBUG_LOG("Constructed photo item for playback: {0}".format(dict(obj))) self.metadata = obj return self.metadata