diff --git a/README.md b/README.md index 0e566e7..e9c1ee2 100644 --- a/README.md +++ b/README.md @@ -24,15 +24,9 @@ The project supports the following: - Playing multiple videos in a queue. - The app doesn't require or save any Plex passwords or tokens. - Executing commands before playing, after media end, and when stopped. - - Configurable transcoding support based on remote server and bitrate. + - Configurable transcoding support. (Please see the section below.) - The application shows up in Plex dashboard and usage tracking. -Transcoding is supported, but needs work: - - Transcode bandwidth decisions are currently based on values in the config file. - - Playback of videos can fail on remote servers if the available bandwidth is lower. - - Changing subtitle/audio tracks cannot be done after starting transcode playback. - - The only way to control transcode video quality is using the config file. - You'll need [libmpv1](https://github.com/Kagami/mpv.js/blob/master/README.md#get-libmpv). To install `plex-mpv-shim`, run: ```bash sudo pip3 install --upgrade plex-mpv-shim @@ -60,20 +54,35 @@ Keyboard Shortcuts: - u to mark unwatched and quit You can execute shell commands on media state using the config file: - - media\_ended\_cmd - When all media has played. - - pre\_media\_cmd - Before the player displays. (Will wait for finish.) - - stop\_cmd - After stopping the player. - - idle\_cmd - After no activity for idle\_cmd\_delay seconds. + - `media_ended_cmd` - When all media has played. + - `pre_media_cmd` - Before the player displays. (Will wait for finish.) + - `stop_cmd` - After stopping the player. + - `idle_cmd` - After no activity for `idle_cmd_delay` seconds. This project is based on https://github.com/wnielson/omplex, which is available under the terms of the MIT License. The project was ported to python3, modified to use mpv as the player, and updated to allow all features of the remote control api for video playback. -UPDATE: It looks like we have a reversal on the Plex Media Player situation. -That being said, this project has proven to be interesting as a hackable -Plex client. **I plan to maintain this client, although I may not work on -adding new features unless someone requests them.** +## Transcoding Support + +Plex-MPV-Shim 1.2 introduces revamped transcoding support. It will automatically ask the server to see if transcoding is suggested, which enables Plex-MPV-Shim to play more of your library on the go. You can configure this or switch to the old local transcode decision system. + +- `always_transcode`: This will tell the client to always transcode, without asking. Default: `false` + - This may be useful if you are using limited hardware that cannot handle advanced codecs. + - You may have some luck changing `client_profile` in the configuration to a more restrictive one. +- `auto_transcode`: This will ask the server to determine if transcoding is suggested. Default: `true` + - `transcode_kbps`: Transcode bandwidth to request. Default: `2000` + - `transcode_res`: Transcode resolution to request. Default: `720p` +- `remote_transcode`: This will check for transcoding using locally available metadata for remote servers only. Default: `true` + - This will not take effect if `auto_transcode` is enabled. + - Configuration options from `auto_transcode` are also used. + - `remote_kbps_thresh`: The threshold to force transcoding. If this is lower than the configured server bandwidth, playback may fail. +- `adaptive_transcode`: Tell the server to adjust the quality while streaming. Default: `false` + +Caveats: + - Controlling Plex-MPV-Shim from the Plex web application only works on a LAN where a Plex Server resides. It does NOT have to be the one you are streaming from. An empty server will work. + - The only way to configure transcode quality is the config file. There is no native way to configure transcode quality from the Plex remote control interface. I may implement an on-screen menu to adjust this and other settings. ## Building on Windows diff --git a/plex_mpv_shim/conf.py b/plex_mpv_shim/conf.py index 19ffaf1..a64fec8 100644 --- a/plex_mpv_shim/conf.py +++ b/plex_mpv_shim/conf.py @@ -28,10 +28,13 @@ class Settings(object): "idle_cmd": None, "idle_cmd_delay": 60, "always_transcode": False, + "auto_transcode": True, + "adaptive_transcode": False, "remote_transcode": True, "remote_kbps_thresh": 5000, "transcode_kbps": 2000, "transcode_res": "720p", + "client_profile": "Plex Home Theater", } def __getattr__(self, name): diff --git a/plex_mpv_shim/media.py b/plex_mpv_shim/media.py index f051dd6..81bf717 100644 --- a/plex_mpv_shim/media.py +++ b/plex_mpv_shim/media.py @@ -1,6 +1,7 @@ import logging import urllib.request, urllib.parse, urllib.error import urllib.parse +import requests import uuid try: @@ -9,7 +10,7 @@ except: import xml.etree.ElementTree as et from .conf import settings -from .utils import get_plex_url, safe_urlopen, is_local_domain, get_resolution +from .utils import get_plex_url, safe_urlopen, is_local_domain, get_resolution, get_session, reset_session log = logging.getLogger('media') @@ -32,6 +33,8 @@ class Video(object): self.audio_seq = {} self.audio_uid = {} self.is_transcode = False + self.trs_aid = None + self.trs_sid = None if media: self.select_media(media, part) @@ -54,10 +57,13 @@ class Video(object): self.subtitle_seq[sub.attrib["id"]] = index+1 def get_transcode_streams(self): - audio_obj = self._part_node.find("./Stream[@streamType='2'][@selected='1']") - subtitle_obj = self._part_node.find("./Stream[@streamType='3'][@selected='1']") - return (audio_obj.get("id") if audio_obj else None, - subtitle_obj.get("id") if subtitle_obj else None) + if not self.trs_aid: + audio_obj = self._part_node.find("./Stream[@streamType='2'][@selected='1']") + self.trs_aid = audio_obj.get("id") if audio_obj is not None else None + if not self.trs_sid: + subtitle_obj = self._part_node.find("./Stream[@streamType='3'][@selected='1']") + self.trs_sid = subtitle_obj.get("id") if subtitle_obj is not None else None + return self.trs_aid, self.trs_sid def select_best_media(self, part=0): """ @@ -107,6 +113,22 @@ class Video(object): return False return len(self._media_node.findall("./Part")) > 1 + def set_streams(self, audio_uid, sub_uid): + args = {"allParts": "1"} + + if audio_uid is not None: + args["audioStreamID"] = audio_uid + self.trs_aid = audio_uid + + if sub_uid is not None: + args["subtitleStreamID"] = sub_uid + self.trs_sid = sub_uid + + if self._part_node != None: + partid = self._part_node.get("id") + url = "/library/parts/{0}".format(partid) + requests.put(get_plex_url(urllib.parse.urljoin(self.parent.server_url, url), args), data=None) + def get_proper_title(self): if not hasattr(self, "_title"): media_type = self.node.get('type') @@ -136,12 +158,80 @@ class Video(object): setattr(self, "_title", title) return getattr(self, "_title") - def is_transcode_suggested(self): + def get_formats(self): + audio_formats = [] + protocols = "protocols=http-video,http-live-streaming,http-mp4-streaming,http-mp4-video,http-mp4-video-720p,http-streaming-video,http-streaming-video-720p;videoDecoders=mpeg4,h264{profile:high&resolution:1080&level:51};audioDecoders=mp3,aac{channels:8}" + if settings.audio_ac3passthrough: + audio_formats.append("add-transcode-target-audio-codec(type=videoProfile&context=streaming&protocol=hls&audioCodec=ac3)") + audio_formats.append("add-transcode-target-audio-codec(type=videoProfile&context=streaming&protocol=hls&audioCodec=eac3)") + protocols += ",ac3{bitrate:800000&channels:8}" + if settings.audio_dtspassthrough: + audio_formats.append("add-transcode-target-audio-codec(type=videoProfile&context=streaming&protocol=hls&audioCodec=dca)") + protocols += ",dts{bitrate:800000&channels:8}" + + return audio_formats, protocols + + def is_transcode_suggested(self, video_height=None, video_width=None, + video_bitrate=None, video_quality=100): + request_direct_play = "1" + request_subtitle_mode = "none" + is_local = is_local_domain(self.parent.path.hostname) + + # User would like us to always transcode. if settings.always_transcode: - return True - elif (settings.remote_transcode and not is_local_domain(self.parent.path.hostname) + request_direct_play = "0" + request_subtitle_mode = "auto" + # Check locally if we should transcode or direct play. (Legacy) + elif (settings.remote_transcode and not settings.auto_transcode and not is_local and int(self.node.find("./Media").get("bitrate")) > settings.remote_kbps_thresh): + request_direct_play = "0" + request_subtitle_mode = "auto" + + # Regardless of if we need the data from the decision, Plex will sometimes deny access + # if there is no decision for the current session. + audio_formats, protocols = self.get_formats() + + url = "/video/:/transcode/universal/decision" + args = { + "hasMDE": "1", + "path": self.node.get("key"), + "session": get_session(self.parent.path.hostname), + "protocol": "hls", + "directPlay": request_direct_play, + "directStream": "1", + "fastSeek": "1", + "maxVideoBitrate": str(video_bitrate), + "videoQuality": str(video_quality), + "videoResolution": "%sx%s" % (video_width, video_height), + "mediaIndex": self._media or 0, + "partIndex": self._part or 0, + "location": "lan" if is_local else "wan", + "autoAdjustQuality": str(int(settings.adaptive_transcode)), + "directStreamAudio": "1", + "subtitles": request_subtitle_mode, # Setting this to auto or burn breaks direct play. + "copyts": "1", + } + + if audio_formats: + args["X-Plex-Client-Profile-Extra"] = "+".join(audio_formats) + args["X-Plex-Client-Capabilities"] = protocols + + tree = et.parse(urllib.request.urlopen(get_plex_url(urllib.parse.urljoin(self.parent.server_url, url), args))) + treeRoot = tree.getroot() + decisionText = treeRoot.get("generalDecisionText") or treeRoot.get("mdeDecisionText") + decision = treeRoot.get("generalDecisionCode") or treeRoot.get("mdeDecisionCode") + log.debug("Decision: {0}: {1}".format(decision, decisionText)) + + if request_direct_play == "0": return True + # Use the decision from the Plex server. + elif settings.auto_transcode: + if decision == "1000": + return False + elif decision == "1001": + return True + else: + log.error("Server reports that file cannot be streamed.") return False def get_playback_url(self, direct_play=None, offset=0, @@ -150,9 +240,18 @@ class Video(object): """ Returns the URL to use for the trancoded file. """ + reset_session(self.parent.path.hostname) + + if video_height is None or video_width is None: + video_width, video_height = get_resolution(settings.transcode_res) + + if video_bitrate is None: + video_bitrate = settings.transcode_kbps + if direct_play is None: # See if transcoding is suggested - direct_play = not self.is_transcode_suggested() + direct_play = not self.is_transcode_suggested(video_height, video_width, + video_bitrate, video_quality) if direct_play: if not self._part_node: @@ -162,39 +261,31 @@ class Video(object): return get_plex_url(url) self.is_transcode = True - - if video_height is None or video_width is None: - video_width, video_height = get_resolution(settings.transcode_res) - - if video_bitrate is None: - video_bitrate = settings.transcode_kbps + is_local = is_local_domain(self.parent.path.hostname) url = "/video/:/transcode/universal/start.m3u8" args = { - "path": self.node.get("key"), - "session": settings.client_uuid, - "protocol": "hls", - "directPlay": "0", - "directStream": "1", - "fastSeek": "1", - "maxVideoBitrate": str(video_bitrate), - "videoQuality": str(video_quality), - "videoResolution": "%sx%s" % (video_width,video_height), - "mediaIndex": self._media or 0, - "partIndex": self._part or 0, - "offset": offset, + "path": self.node.get("key"), + "session": get_session(self.parent.path.hostname), + "protocol": "hls", + "directPlay": "0", + "directStream": "1", + "fastSeek": "1", + "maxVideoBitrate": str(video_bitrate), + "videoQuality": str(video_quality), + "videoResolution": "%sx%s" % (video_width,video_height), + "mediaIndex": self._media or 0, + "partIndex": self._part or 0, + "location": "lan" if is_local else "wan", + "offset": offset, + "autoAdjustQuality": str(int(settings.adaptive_transcode)), + "directStreamAudio": "1", + "subtitles": "auto", + "copyts": "1", #"skipSubtitles": "1", } - audio_formats = [] - protocols = "protocols=http-live-streaming,http-mp4-streaming,http-mp4-video,http-mp4-video-720p,http-streaming-video,http-streaming-video-720p;videoDecoders=mpeg4,h264{profile:high&resolution:1080&level:51};audioDecoders=mp3,aac{channels:8}" - if settings.audio_ac3passthrough: - audio_formats.append("add-transcode-target-audio-codec(type=videoProfile&context=streaming&protocol=hls&audioCodec=ac3)") - audio_formats.append("add-transcode-target-audio-codec(type=videoProfile&context=streaming&protocol=hls&audioCodec=eac3)") - protocols += ",ac3{bitrate:800000&channels:8}" - if settings.audio_dtspassthrough: - audio_formats.append("add-transcode-target-audio-codec(type=videoProfile&context=streaming&protocol=hls&audioCodec=dca)") - protocols += ",dts{bitrate:800000&channels:8}" + audio_formats, protocols = self.get_formats() if audio_formats: args["X-Plex-Client-Profile-Extra"] = "+".join(audio_formats) @@ -300,7 +391,7 @@ class XMLCollection(object): return self.path.path class Media(XMLCollection): - def __init__(self, url, series=None, seq=None, play_queue=None, play_queue_xml=None, session=None): + def __init__(self, url, series=None, seq=None, play_queue=None, play_queue_xml=None): XMLCollection.__init__(self, url) self.video = self.tree.find('./Video') self.is_tv = self.video.get("type") == "episode" @@ -310,11 +401,6 @@ class Media(XMLCollection): self.play_queue = play_queue self.play_queue_xml = play_queue_xml - if session: - self.session = session - else: - self.session = str(uuid.uuid4()) - if self.play_queue: if not series: self.upd_play_queue() @@ -380,21 +466,21 @@ class Media(XMLCollection): if self.play_queue and self.seq+1 == len(self.series): self.upd_play_queue() next_video = self.series[self.seq+1] - return Media(self.get_path(next_video.get('key')), self.series, self.seq+1, self.play_queue, self.play_queue_xml, session=self.session) + return Media(self.get_path(next_video.get('key')), self.series, self.seq+1, self.play_queue, self.play_queue_xml) def get_prev(self): if self.has_prev: if self.play_queue and self.seq-1 == 0: self.upd_play_queue() prev_video = self.series[self.seq-1] - return Media(self.get_path(prev_video.get('key')), self.series, self.seq-1, self.play_queue, self.play_queue_xml, session=self.session) + return Media(self.get_path(prev_video.get('key')), self.series, self.seq-1, self.play_queue, self.play_queue_xml) def get_from_key(self, key): if self.play_queue: self.upd_play_queue() for i, video in enumerate(self.series): if video.get("key") == key: - return Media(self.get_path(key), self.series, i, self.play_queue, self.play_queue_xml, session=self.session) + return Media(self.get_path(key), self.series, i, self.play_queue, self.play_queue_xml) return None else: return Media(self.get_path(key)) diff --git a/plex_mpv_shim/player.py b/plex_mpv_shim/player.py index ef05e1f..eae7a2c 100644 --- a/plex_mpv_shim/player.py +++ b/plex_mpv_shim/player.py @@ -1,12 +1,14 @@ import logging import mpv import os +import requests +import urllib.parse from threading import RLock from queue import Queue from . import conffile -from .utils import synchronous, Timer +from .utils import synchronous, Timer, get_plex_url from .conf import settings APP_NAME = 'plex-mpv-shim' @@ -248,6 +250,12 @@ class PlayerManager(object): return True return False + @synchronous('_lock') + def restart_playback(self): + current_time = self._player.playback_time + self.play(self._video, current_time) + return True + @synchronous('_lock') def get_video_attr(self, attr, default=None): if self._video: @@ -256,16 +264,22 @@ class PlayerManager(object): @synchronous('_lock') def set_streams(self, audio_uid, sub_uid): - if audio_uid is not None: - log.debug("PlayerManager::play selecting audio stream index=%s" % audio_uid) - self._player.audio = self._video.audio_seq[audio_uid] + if not self._video.is_transcode: + if audio_uid is not None: + log.debug("PlayerManager::play selecting audio stream index=%s" % audio_uid) + self._player.audio = self._video.audio_seq[audio_uid] - if sub_uid == '0': - log.debug("PlayerManager::play selecting subtitle stream (none)") - self._player.sub = 'no' - elif sub_uid is not None: - log.debug("PlayerManager::play selecting subtitle stream index=%s" % sub_uid) - self._player.sub = self._video.subtitle_seq[sub_uid] + if sub_uid == '0': + log.debug("PlayerManager::play selecting subtitle stream (none)") + self._player.sub = 'no' + elif sub_uid is not None: + log.debug("PlayerManager::play selecting subtitle stream index=%s" % sub_uid) + self._player.sub = self._video.subtitle_seq[sub_uid] + + self._video.set_streams(audio_uid, sub_uid) + + if self._video.is_transcode: + self.restart_playback() playerManager = PlayerManager() diff --git a/plex_mpv_shim/timeline.py b/plex_mpv_shim/timeline.py index 5d55395..3cafa3b 100644 --- a/plex_mpv_shim/timeline.py +++ b/plex_mpv_shim/timeline.py @@ -66,18 +66,13 @@ class TimelineManager(threading.Thread): # Also send timeline to plex server. video = playerManager._video options = self.GetCurrentTimeline() - session = None server_url = None if video: server_url = video.parent.server_url self.last_server_url = video.parent.server_url - session = video.parent.session - self.last_session = video.parent.session - elif self.last_server_url and self.last_session: + elif self.last_server_url: server_url = self.last_server_url - session = self.last_session - if server_url and session: - options["X-Plex-Session-Identifier"] = session + if server_url: url = safe_urlopen("%s/:/timeline" % server_url, options, quiet=True) def SendTimelineToSubscriber(self, subscriber): @@ -193,9 +188,8 @@ class TimelineManager(threading.Thread): controllable.append("skipTo") controllable.append("autoPlay") - if not video.is_transcode: - controllable.append("subtitleStream") - controllable.append("audioStream") + controllable.append("subtitleStream") + controllable.append("audioStream") if video.parent.has_next: controllable.append("skipNext") @@ -225,7 +219,10 @@ class TimelineManager(threading.Thread): options["containerKey"] = video.get_video_attr("key") if video.parent.play_queue: options.update(video.parent.get_queue_info()) - options["state"] = "stopped" + if player.playback_abort: + options["state"] = "stopped" + else: + options["state"] = "buffering" return options diff --git a/plex_mpv_shim/utils.py b/plex_mpv_shim/utils.py index 6a183ab..06d8681 100644 --- a/plex_mpv_shim/utils.py +++ b/plex_mpv_shim/utils.py @@ -3,6 +3,7 @@ import os import urllib.request, urllib.parse, urllib.error import socket import ipaddress +import uuid from .conf import settings from datetime import datetime @@ -10,6 +11,7 @@ from functools import wraps log = logging.getLogger("utils") plex_eph_tokens = {} +plex_sessions = {} class Timer(object): def __init__(self): @@ -45,6 +47,17 @@ def synchronous(tlockname): def upd_token(domain, token): plex_eph_tokens[domain] = token +def get_session(domain): + if domain not in plex_sessions: + session = str(uuid.uuid4()) + plex_sessions[domain] = session + return plex_sessions[domain] + +def reset_session(domain): + session = str(uuid.uuid4()) + plex_sessions[domain] = session + return session + def get_plex_url(url, data=None, quiet=False): if not data: data = {} @@ -62,17 +75,23 @@ def get_plex_url(url, data=None, quiet=False): else: log.error("get_plex_url No token for: %s" % domain) + if domain in plex_sessions: + data.update({ + "X-Plex-Session-Identifier": plex_sessions[domain] + }) + data.update({ - "X-Plex-Version": "2.0", - "X-Plex-Client-Identifier": settings.client_uuid, - "X-Plex-Provides": "player", - "X-Plex-Device-Name": settings.player_name, - "X-Plex-Model": "RaspberryPI", - "X-Plex-Device": "RaspberryPI", + "X-Plex-Version": "2.0", + "X-Plex-Client-Identifier": settings.client_uuid, + "X-Plex-Provides": "player", + "X-Plex-Device-Name": settings.player_name, + "X-Plex-Model": "RaspberryPI", + "X-Plex-Device": "RaspberryPI", # Lies - "X-Plex-Product": "Plex Home Theater", - "X-Plex-Platform": "Plex Home Theater" + "X-Plex-Product": "Plex MPV Shim", + "X-Plex-Platform": "Plex Home Theater", + "X-Plex-Client-Profile-Name": settings.client_profile, }) # Kinda ghetto... diff --git a/setup.py b/setup.py index 69ddf30..865ec7a 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ with open("README.md", "r") as fh: setup( name='plex-mpv-shim', - version='1.1.2', + version='1.2.0', author="Ian Walton", author_email="iwalton3@gmail.com", description="Cast media from Plex Mobile and Web apps to MPV. (Unofficial)",