Implement Plex transcode decision processing.

This commit is contained in:
Ian Walton 2020-01-02 18:21:28 -05:00
parent e33f9001a2
commit 4ec642e2bf
7 changed files with 218 additions and 90 deletions

View File

@ -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

View File

@ -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):

View File

@ -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))

View File

@ -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()

View File

@ -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

View File

@ -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...

View File

@ -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)",