Introduce the interactive menu, with the ability to adjust transcoding and bulk set subtitle/audio settings.

This commit is contained in:
Ian Walton 2020-01-03 18:17:13 -05:00
parent 15fcb36e7c
commit cf4c9db0a4
7 changed files with 647 additions and 24 deletions

View File

@ -64,6 +64,21 @@ 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.
## Interactive Menu
Plex-MPV-Shim 1.3 introduces the interactive menu. This allows for features that
would be impossible to support through the remote control interface normally, such as:
- Change subtitle or audio tracks. The full titles from the media file are shown.
- Automatically change the subtitle and audio streams for an entire series at once.
- Adjust video transcoding quality without restarting.
- Change transcoding preferences without editing the config file.
- Quit the player, marking the current video unwatched.
The interactive menu supports both the keyboard and the remote control. To open the menu
from a phone, press the ok or home button. Press back to go back or close the menu. The
keyboard shortcut to open the menu is `c`. Use the arrow keys, escape, and enter/space
to navigate the menu.
## 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.

View File

@ -0,0 +1,218 @@
from .media import XMLCollection
from .utils import get_plex_url
from collections import namedtuple
import urllib.parse
import requests
import time
Part = namedtuple("Part", ["id", "audio", "subtitle"])
Audio = namedtuple("Audio", ["id", "language_code", "name", "plex_name"])
Subtitle = namedtuple("Subtitle", ["id", "language_code", "name", "is_forced", "plex_name"])
messages = []
keep_messages = 6
def render_message(message, show_text):
messages.append(message)
text = "Selecting Tracks..."
for message in messages[-6:]:
text += "\n " + message
show_text(text,2**30,1)
def process_series(mode, url, player, m_raid=None, m_rsid=None):
messages.clear()
show_text = player._player.show_text
c_aid, c_sid = None, None
c_pid = player._video._part_node.get("id")
success_ct = 0
partial_ct = 0
count = 0
xml = XMLCollection(url)
for video in xml.tree.findall("./Video"):
name = "s{0}e{1:02}".format(int(video.get("parentIndex")), int(video.get("index")))
video = XMLCollection(xml.get_path(video.get("key"))).tree.find("./")
for partxml in video.findall("./Media/Part"):
count += 1
audio_list = [Audio(s.get("id"), s.get("languageCode"), s.get("title"),
s.get("displayTitle")) for s in partxml.findall("./Stream[@streamType='2']")]
subtitle_list = [Subtitle(s.get("id"), s.get("languageCode"), s.get("title"),
"Forced" in s.get("displayTitle"), s.get("displayTitle"))
for s in partxml.findall("./Stream[@streamType='3']")]
part = Part(partxml.get("id"), audio_list, subtitle_list)
aid = None
sid = "0"
if mode == "subbed":
audio, subtitle = get_subbed(part)
if audio and subtitle:
render_message("{0}: {1} ({2})".format(
name, subtitle.plex_name, subtitle.name), show_text)
aid, sid = audio.id, subtitle.id
success_ct += 1
elif mode == "dubbed":
audio, subtitle = get_dubbed(part)
if audio and subtitle:
render_message("{0}: {1} ({2})".format(
name, subtitle.plex_name, subtitle.name), show_text)
aid, sid = audio.id, subtitle.id
success_ct += 1
elif audio:
render_message("{0}: No Subtitles".format(name), show_text)
aid = audio.id
partial_ct += 1
elif mode == "manual":
if m_raid < len(part.audio) and m_rsid < len(part.subtitle):
audio = part.audio[m_raid]
aid = audio.id
render_message("{0} a: {1} ({2})".format(
name, audio.plex_name, audio.name), show_text)
if m_rsid != -1:
subtitle = part.subtitle[m_rsid]
sid = subtitle.id
render_message("{0} s: {1} ({2})".format(
name, subtitle.plex_name, subtitle.name), show_text)
success_ct += 1
if aid:
if c_pid == part.id:
c_aid, c_sid = aid, sid
args = {
"allParts": "1",
"audioStreamID": aid,
"subtitleStreamID": sid
}
url = "/library/parts/{0}".format(part.id)
requests.put(get_plex_url(urllib.parse.urljoin(xml.server_url, url), args), data=None)
else:
render_message("{0}: Fail".format(name), show_text)
if mode == "subbed":
render_message("Set Subbed: {0} ok, {1} fail".format(
success_ct, count-success_ct), show_text)
elif mode == "dubbed":
render_message("Set Dubbed: {0} ok, {1} audio only, {2} fail".format(
success_ct, partial_ct, count-success_ct-partial_ct), show_text)
elif mode == "manual":
render_message("Manual: {0} ok, {1} fail".format(
success_ct, count-success_ct), show_text)
time.sleep(3)
if c_aid:
render_message("Setting Current...", show_text)
if player._video.is_transcode:
player.put_task(player.set_streams, c_aid, c_sid)
player.timeline_handle()
else:
player.set_streams(c_aid, c_sid)
def get_subbed(part):
japanese_audio = None
english_subtitles = None
subtitle_weight = None
for audio in part.audio:
lower_title = audio.name.lower() if audio.name is not None else ""
if audio.language_code != "jpn" and not "japan" in lower_title:
continue
if "commentary" in lower_title:
continue
if japanese_audio is None:
japanese_audio = audio
break
for subtitle in part.subtitle:
lower_title = subtitle.name.lower() if subtitle.name is not None else ""
if subtitle.language_code != "eng" and not "english" in lower_title:
continue
if subtitle.is_forced:
continue
weight = dialogue_weight(lower_title)
if subtitle_weight is None or weight < subtitle_weight:
subtitle_weight = weight
english_subtitles = subtitle
if japanese_audio and english_subtitles:
return japanese_audio, english_subtitles
return None, None
def get_dubbed(part):
english_audio = None
sign_subtitles = None
subtitle_weight = None
for audio in part.audio:
lower_title = audio.name.lower() if audio.name is not None else ""
if audio.language_code != "eng" and not "english" in lower_title:
continue
if "commentary" in lower_title:
continue
if english_audio is None:
english_audio = audio
break
for subtitle in part.subtitle:
lower_title = subtitle.name.lower() if subtitle.name is not None else ""
if subtitle.language_code != "eng" and not "english" in lower_title:
continue
if subtitle.is_forced:
sign_subtitles = subtitle
break
weight = sign_weight(lower_title)
if weight == 0:
continue
if subtitle_weight is None or weight < subtitle_weight:
subtitle_weight = weight
sign_subtitles = subtitle
if english_audio:
return english_audio, sign_subtitles
return None, None
def dialogue_weight(text):
if not text:
return 900
lower_text = text.lower()
has_dialogue = "main" in lower_text or "full" in lower_text or "dialogue" in lower_text
has_songs = "op/ed" in lower_text or "song" in lower_text or "lyric" in lower_text
has_signs = "sign" in lower_text
vendor = "bd" in lower_text or "retail" in lower_text
weight = 900
if has_dialogue and has_songs:
weight -= 100
if has_songs:
weight += 200
if has_dialogue and has_signs:
weight -= 100
elif has_signs:
weight += 700
if vendor:
weight += 50
return weight
def sign_weight(text):
if not text:
return 0
lower_text = text.lower()
has_songs = "op/ed" in lower_text or "song" in lower_text or "lyric" in lower_text
has_signs = "sign" in lower_text
vendor = "bd" in lower_text or "retail" in lower_text
weight = 900
if not (has_songs or has_signs):
return 0
if has_songs:
weight -= 200
if has_signs:
weight -= 300
if vendor:
weight += 50
return weight

View File

@ -31,6 +31,16 @@ log = logging.getLogger("client")
STATIC_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'static')
NAVIGATION_DICT = {
"/player/navigation/moveDown": "down",
"/player/navigation/moveUp": "up",
"/player/navigation/select": "ok",
"/player/navigation/moveLeft": "left",
"/player/navigation/moveRight": "right",
"/player/navigation/home": "home",
"/player/navigation/back": "back"
}
class HttpHandler(SimpleHTTPRequestHandler):
xmlOutput = None
completed = False
@ -349,8 +359,10 @@ class HttpHandler(SimpleHTTPRequestHandler):
def mirror(self, path, arguments):
timelineManager.delay_idle()
def navigation(self, path, query):
pass
def navigation(self, path, arguments):
path = path.path
if path in NAVIGATION_DICT:
playerManager.menu_action(NAVIGATION_DICT[path])
class HttpSocketServer(ThreadingMixIn, HTTPServer):
allow_reuse_address = True

View File

@ -35,6 +35,7 @@ class Video(object):
self.is_transcode = False
self.trs_aid = None
self.trs_sid = None
self.trs_ovr = None
if media:
self.select_media(media, part)
@ -158,6 +159,26 @@ class Video(object):
setattr(self, "_title", title)
return getattr(self, "_title")
def set_trs_override(self, video_bitrate, force_transcode, force_bitrate):
if force_transcode:
self.trs_ovr = (video_bitrate, force_transcode, force_bitrate)
else:
self.trs_ovr = None
def get_transcode_bitrate(self):
if not self.is_transcode:
return "none"
elif self.trs_ovr is not None:
if self.trs_ovr[0] is not None:
return self.trs_ovr[0]
elif self.trs_ovr[1]:
return "max"
elif is_local_domain(self.parent.path.hostname):
return "max"
else:
return settings.transcode_kbps
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}"
@ -208,8 +229,8 @@ class Video(object):
"copyts": "1",
}
if not is_local or force_bitrate:
args[maxVideoBitrate] = str(video_bitrate)
if video_bitrate is not None and (not is_local or force_bitrate):
args["maxVideoBitrate"] = str(video_bitrate)
if audio_formats:
args["X-Plex-Client-Profile-Extra"] = "+".join(audio_formats)
@ -238,8 +259,10 @@ class Video(object):
Returns the URL to use for the trancoded file.
"""
reset_session(self.parent.path.hostname)
if video_bitrate is None:
if self.trs_ovr:
video_bitrate, force_transcode, force_bitrate = self.trs_ovr
elif video_bitrate is None:
video_bitrate = settings.transcode_kbps
if direct_play is None:
@ -275,8 +298,8 @@ class Video(object):
#"skipSubtitles": "1",
}
if not is_local or force_bitrate:
args[maxVideoBitrate] = str(video_bitrate)
if video_bitrate is not None and (not is_local or force_bitrate):
args["maxVideoBitrate"] = str(video_bitrate)
audio_formats, protocols = self.get_formats()

View File

@ -5,16 +5,35 @@ import requests
import urllib.parse
from threading import RLock
from queue import Queue
from queue import Queue, LifoQueue
from . import conffile
from .utils import synchronous, Timer, get_plex_url
from .conf import settings
from .bulk_subtitle import process_series
APP_NAME = 'plex-mpv-shim'
TRANSCODE_LEVELS = (
("1080p 20 Mbps", 20000),
("1080p 12 Mbps", 12000),
("1080p 10 Mbps", 10000),
("720p 4 Mbps", 4000),
("720p 3 Mbps", 3000),
("720p 2 Mbps", 2000),
("480p 1.5 Mbps", 1500),
("328p 0.7 Mbps", 720),
("240p 0.3 Mbps", 320),
("160p 0.2 Mbps", 208),
)
log = logging.getLogger('player')
# Q: What is with the put_task and timeline_handle?
# A: If something that modifies the url is called from a keybind
# directly, it crashes the input handling. If you know why,
# please tell me. I'd love to get rid of it.
class PlayerManager(object):
"""
Manages the relationship between a ``Player`` instance and a ``Media``
@ -28,6 +47,13 @@ class PlayerManager(object):
mpv_config = conffile.get(APP_NAME,"mpv.conf", True)
self._player = mpv.MPV(input_default_bindings=True, input_vo_keyboard=True, include=mpv_config)
self.timeline_trigger = None
self.is_menu_shown = False
self.menu_title = ""
self.menu_stack = LifoQueue()
self.menu_list = []
self.menu_selection = 0
self.menu_tmp = None
if hasattr(self._player, 'osc'):
self._player.osc = True
else:
@ -61,10 +87,62 @@ class PlayerManager(object):
self.put_task(self.unwatched_quit)
self.timeline_handle()
@self._player.on_key_press('c')
def menu_open():
if not self.is_menu_shown:
self.show_menu()
else:
self.hide_menu()
@self._player.on_key_press('esc')
def menu_back():
self.menu_action('back')
@self._player.on_key_press('enter')
def menu_ok():
self.menu_action('ok')
@self._player.on_key_press('left')
def menu_left():
if self.is_menu_shown:
self.menu_action('left')
else:
self._player.command("seek", -5)
@self._player.on_key_press('right')
def menu_right():
if self.is_menu_shown:
self.menu_action('right')
else:
self._player.command("seek", 5)
@self._player.on_key_press('up')
def menu_up():
if self.is_menu_shown:
self.menu_action('up')
else:
self._player.command("seek", 60)
@self._player.on_key_press('down')
def menu_down():
if self.is_menu_shown:
self.menu_action('down')
else:
self._player.command("seek", -60)
@self._player.on_key_press('space')
def handle_unwatched():
self.toggle_pause()
self.timeline_handle()
def handle_pause():
if self.is_menu_shown:
self.menu_action('ok')
else:
self.toggle_pause()
self.timeline_handle()
# This gives you an interactive python debugger prompt.
@self._player.on_key_press('~')
def handle_debug():
import pdb
pdb.set_trace()
@self._player.event_callback('idle')
def handle_end(event):
@ -78,6 +156,267 @@ class PlayerManager(object):
self.__part = 1
# The menu is a bit of a hack...
# It works using multiline OSD.
# We also have to force the window to open.
def refresh_menu(self):
if not self.is_menu_shown:
return
items = self.menu_list
selected_item = self.menu_selection
menu_text = "{0}".format(self.menu_title)
for i, item in enumerate(items):
fmt = "\n {0}"
if i == selected_item:
fmt = "\n **{0}**"
menu_text += fmt.format(item[0])
self._player.show_text(menu_text,2**30,1)
def show_menu(self):
self.is_menu_shown = True
self._player.osd_back_color = '#CC333333'
self._player.osd_font_size = 40
if hasattr(self._player, 'osc'):
self._player.osc = False
if self._player.playback_abort:
self._player.force_window = True
self._player.keep_open = True
self._player.play("")
self._player.fs = True
else:
self._player.pause = True
self.menu_title = "Main Menu"
self.menu_selection = 0
if self._video and not self._player.playback_abort:
self.menu_list = [
("Change Audio", self.change_audio_menu),
("Change Subtitles", self.change_subtitle_menu),
("Change Video Quality", self.change_transcode_quality),
("Auto Set Audio/Subtitles (Entire Series)", self.change_tracks_menu),
("Quit and Mark Unwatched", self.unwatched_menu_handle),
]
else:
self.menu_list = []
self.menu_list.extend([
("Preferences", self.preferences_menu),
("Close Menu", self.hide_menu)
])
self.put_task(self.unhide_menu)
self.refresh_menu()
def hide_menu(self):
if self.is_menu_shown:
self._player.osd_back_color = '#00000000'
self._player.osd_font_size = 55
self._player.show_text("",0,0)
self._player.force_window = False
self._player.keep_open = False
if hasattr(self._player, 'osc'):
self._player.osc = True
if self._player.playback_abort:
self._player.play("")
else:
self._player.pause = False
self.is_menu_shown = False
def menu_action(self, action):
if not self.is_menu_shown and action in ("home", "ok"):
self.show_menu()
else:
if action == "up":
self.menu_selection = (self.menu_selection - 1) % len(self.menu_list)
elif action == "down":
self.menu_selection = (self.menu_selection + 1) % len(self.menu_list)
elif action == "back":
if self.menu_stack.empty():
self.hide_menu()
else:
self.menu_title, self.menu_list, self.menu_selection = self.menu_stack.get_nowait()
elif action == "ok":
self.menu_list[self.menu_selection][1]()
elif action == "home":
self.show_menu()
self.refresh_menu()
def change_audio_menu_handle(self):
if self._video.is_transcode:
self.put_task(self.set_streams, self.menu_list[self.menu_selection][2], None)
self.timeline_handle()
else:
self.set_streams(self.menu_list[self.menu_selection][2], None)
self.menu_action("back")
def change_audio_menu(self):
self.menu_stack.put((self.menu_title, self.menu_list, self.menu_selection))
self.menu_title = "Select Audio Track"
self.menu_list = []
self.menu_selection = 0
selected_aid, _ = self.get_track_ids()
audio_streams = playerManager._video._part_node.findall("./Stream[@streamType='2']")
for i, audio_track in enumerate(audio_streams):
aid = audio_track.get("id")
self.menu_list.append([
"{0} ({1})".format(audio_track.get("displayTitle"), audio_track.get("title")),
self.change_audio_menu_handle,
aid
])
if aid == selected_aid:
self.menu_selection = i
def change_subtitle_menu_handle(self):
if self._video.is_transcode:
self.put_task(self.set_streams, None, self.menu_list[self.menu_selection][2])
self.timeline_handle()
else:
self.set_streams(None, self.menu_list[self.menu_selection][2])
self.menu_action("back")
def change_subtitle_menu(self):
self.menu_stack.put((self.menu_title, self.menu_list, self.menu_selection))
self.menu_title = "Select Subtitle Track"
self.menu_list = []
self.menu_selection = 0
_, selected_sid = self.get_track_ids()
subtitle_streams = playerManager._video._part_node.findall("./Stream[@streamType='3']")
self.menu_list.append(["None", self.change_subtitle_menu_handle, "0"])
for i, subtitle_track in enumerate(subtitle_streams):
sid = subtitle_track.get("id")
self.menu_list.append([
"{0} ({1})".format(subtitle_track.get("displayTitle"), subtitle_track.get("title")),
self.change_subtitle_menu_handle,
sid
])
if sid == selected_sid:
self.menu_selection = i+1
def change_transcode_quality_handle(self):
bitrate = self.menu_list[self.menu_selection][2]
if bitrate == "none":
self._video.set_trs_override(None, False, False)
elif bitrate == "max":
self._video.set_trs_override(None, True, False)
else:
self._video.set_trs_override(bitrate, True, True)
self.menu_action("back")
self.put_task(self.restart_playback)
self.timeline_handle()
def change_transcode_quality(self):
self.menu_stack.put((self.menu_title, self.menu_list, self.menu_selection))
self.menu_title = "Select Transcode Quality"
handle = self.change_transcode_quality_handle
self.menu_list = [
("No Transcode", handle, "none"),
("Maximum", handle, "max")
]
for item in TRANSCODE_LEVELS:
self.menu_list.append((item[0], handle, item[1]))
self.menu_selection = 7
cur_bitrate = self._video.get_transcode_bitrate()
for i, option in enumerate(self.menu_list):
if cur_bitrate == option[2]:
self.menu_selection = i
def change_tracks_handle(self):
mode = self.menu_list[self.menu_selection][2]
parentSeriesKey = self._video.parent.tree.find("./").get("parentKey") + "/children"
url = self._video.parent.get_path(parentSeriesKey)
process_series(mode, url, self)
def change_tracks_manual_s1(self):
self.change_audio_menu()
for item in self.menu_list:
item[1] = self.change_tracks_manual_s2
def change_tracks_manual_s2(self):
self.menu_tmp = self.menu_selection
self.change_subtitle_menu()
for item in self.menu_list:
item[1] = self.change_tracks_manual_s3
def change_tracks_manual_s3(self):
aid, sid = self.menu_tmp, self.menu_selection - 1
# Pop 3 menu items.
for i in range(3):
self.menu_action("back")
parentSeriesKey = self._video.parent.tree.find("./").get("parentKey") + "/children"
url = self._video.parent.get_path(parentSeriesKey)
process_series("manual", url, self, aid, sid)
def change_tracks_menu(self):
self.menu_stack.put((self.menu_title, self.menu_list, self.menu_selection))
self.menu_title = "Select Audio/Subtitle for Series"
self.menu_selection = 0
self.menu_list = [
("English Audio", self.change_tracks_handle, "dubbed"),
("Japanese Audio w/ English Subtitles", self.change_tracks_handle, "subbed"),
("Manual by Track Index (Less Reliable)", self.change_tracks_manual_s1),
]
def settings_toggle_bool(self):
_, _, key, name = self.menu_list[self.menu_selection]
setattr(settings, key, not getattr(settings, key))
settings.save()
self.menu_list[self.menu_selection] = self.get_settings_toggle(name, key)
def get_settings_toggle(self, name, setting):
return (
"{0}: {1}".format(name, getattr(settings, setting)),
self.settings_toggle_bool,
setting,
name
)
def transcode_settings_handle(self):
settings.transcode_kbps = self.menu_list[self.menu_selection][2]
settings.save()
# Need to re-render preferences menu.
for i in range(2):
self.menu_action("back")
self.preferences_menu()
def transcode_settings_menu(self):
self.menu_stack.put((self.menu_title, self.menu_list, self.menu_selection))
self.menu_title = "Select Default Transcode Profile"
self.menu_selection = 0
self.menu_list = []
handle = self.transcode_settings_handle
for i, item in enumerate(TRANSCODE_LEVELS):
self.menu_list.append((item[0], handle, item[1]))
if settings.transcode_kbps == item[1]:
self.menu_selection = i
def preferences_menu(self):
self.menu_stack.put((self.menu_title, self.menu_list, self.menu_selection))
self.menu_title = "Preferences"
self.menu_selection = 0
self.menu_list = [
self.get_settings_toggle("Adaptive Transcode", "adaptive_transcode"),
self.get_settings_toggle("Always Transcode", "always_transcode"),
self.get_settings_toggle("Auto Play", "auto_play"),
("Transcode Quality: {0:0.1f} Mbps".format(settings.transcode_kbps/1000), self.transcode_settings_menu)
]
def put_task(self, func, *args):
self.evt_queue.put([func, args])
@ -85,6 +424,15 @@ class PlayerManager(object):
if self.timeline_trigger:
self.timeline_trigger.set()
def unwatched_menu_handle(self):
self.put_task(self.unwatched_quit)
self.timeline_handle()
def unhide_menu(self):
# Sometimes, mpv completely ignores the OSD text.
# Setting this value usually causes it to appear...
self._player.osd_align_x = 'left'
@synchronous('_lock')
def update(self):
while not self.evt_queue.empty():
@ -105,6 +453,7 @@ class PlayerManager(object):
@synchronous('_lock')
def _play_media(self, video, url, offset=0):
self.url = url
self.hide_menu()
self._player.play(self.url)
self._player.wait_for_property("duration")
@ -280,6 +629,18 @@ class PlayerManager(object):
if self._video.is_transcode:
self.restart_playback()
def get_track_ids(self):
if self._video.is_transcode:
return self._video.get_transcode_streams()
else:
aid, sid = None, None
if self._player.sub != 'no':
sid = self._video.subtitle_uid.get(self._player.sub, '')
if self._player.audio != 'no':
aid = self._video.audio_uid.get(self._player.audio, '')
return aid, sid
playerManager = PlayerManager()

View File

@ -153,18 +153,12 @@ class TimelineManager(threading.Thread):
options["time"] = int(player.playback_time * 1e3)
options["autoPlay"] = '1' if settings.auto_play else '0'
if video.is_transcode:
trs_audio, trs_subtitle = video.get_transcode_streams()
if trs_subtitle:
options["subtitleStreamID"] = trs_subtitle
if trs_audio:
options["audioStreamID"] = trs_audio
else:
if player.sub != 'no':
options["subtitleStreamID"] = video.subtitle_uid.get(player.sub, '')
aid, sid = playerManager.get_track_ids()
if player.audio != 'no':
options["audioStreamID"] = video.audio_uid.get(player.audio, '')
if aid:
options["audioStreamID"] = aid
if sid:
options["subtitleStreamID"] = sid
options["ratingKey"] = video.get_video_attr("ratingKey")
options["key"] = video.get_video_attr("key")

View File

@ -5,7 +5,7 @@ with open("README.md", "r") as fh:
setup(
name='plex-mpv-shim',
version='1.2.0',
version='1.3.0',
author="Ian Walton",
author_email="iwalton3@gmail.com",
description="Cast media from Plex Mobile and Web apps to MPV. (Unofficial)",