diff --git a/.gitignore b/.gitignore index 7babfb7..ee62767 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ __pycache__ dist build plex_mpv_shim.egg-info +plex_mpv_shim/default_shader_pack/* diff --git a/README.md b/README.md index 7d63ce1..f46aea4 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ The menu enables you to: - Change subtitles or audio, while knowing the track names. - Change subtitles or audio for an entire series at once. - Mark the media as unwatched and quit. + - Configure shader packs and SVP profiles. On your computer, use the arrow keys, enter, and escape to navigate. On your phone, use the arrow buttons, ok, back, and home to navigate. (The option for remote controls is @@ -40,6 +41,26 @@ Please also note that the on-screen controller for MPV (if available) cannot cha audio and subtitle track configurations for transcoded media. It also cannot load external subtitles. You must either use the menu or the application you casted from. +### Shader Packs + +Shader packs are a recent feature addition that allows you to easily use advanced video +shaders and video quality settings. These usually require a lot of configuration to use, +but MPV Shim's default shader pack comes with [FSRCNNX](https://github.com/igv/FSRCNN-TensorFlow) +and [Anime4K](https://github.com/bloc97/Anime4K) preconfigured. Try experimenting with video +profiles! It may greatly improve your experience. + +Shader Packs are ready to use as of the most recent MPV Shim version. To use, simply +navigate to the **Video Playback Profiles** option and select a profile. + +For details on the shader settings, please see [default-shader-pack](https://github.com/iwalton3/default-shader-pack). +If you would like to customize the shader pack, there are details in the configuration section. + +### SVP Integration + +SVP integration allows you to easily configure SVP support, change profiles, and enable/disable +SVP without having to exit the player. It is not enabled by default, please see the configuration +instructions for instructions on how to enable it. + ### Keyboard Shortcuts This program supports most of the [keyboard shortcuts from MPV](https://mpv.io/manual/stable/#interactive-control). The custom keyboard shortcuts are: @@ -151,6 +172,36 @@ You can reconfigure the custom keyboard shortcuts. You can also set them to `nul - `seek_right` - Time to seek for "right" key. (Default: `5`) - `seek_left` - Time to seek for "left" key. (Default: `-5`) +### Shader Packs + +Shader packs allow you to import MPV config and shader presets into MPV Shim and easily switch +between them at runtime through the built-in menu. This enables easy usage and switching of +advanced MPV video playback options, such as video upscaling, while being easy to use. + +If you select one of the presets from the shader pack, it will override some MPV configurations +and any shaders manually specified in `mpv.conf`. If you would like to customize the shader pack, +use `shader_pack_custom`. + + - `shader_pack_enable` - Enable shader pack. (Default: `true`) + - `shader_pack_custom` - Enable to use a custom shader pack. (Default: `false`) + - If you enable this, it will copy the default shader pack to the `shader_pack` config folder. + - This initial copy will only happen if the `shader_pack` folder didn't exist. + - This shader pack will then be used instead of the built-in one from then on. + - `shader_pack_remember` - Automatically remember the last used shader profile. (Default: `true`) + - `shader_pack_profile` - The default profile to use. (Default: `null`) + - If you use `shader_pack_remember`, this will be updated when you set a profile through the UI. + +### SVP Integration + +To enable SVP integration, set `svp_enable` to `true` and enable "External control via HTTP" within SVP +under Settings > Control options. Adjust the `svp_url` and `svp_socket` settings if needed. + + - `svp_enable` - Enable SVP integration. (Default: `false`) + - `svp_url` - URL for SVP web API. (Default: `http://127.0.0.1:9901/`) + - `svp_socket` - Custom MPV socket to use for SVP. + - Default on Windows: `mpvpipe` + - Default on other platforms: `/tmp/mpvsocket` + ### Other Configuration Options - `player_name` - The name of the player that appears in the cast menu. Initially set from your hostname. @@ -323,7 +374,9 @@ discovery protocol](https://support.plex.tv/articles/201543147-what-network-port 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. +features of the remote control api for video playback. The shaders included +in the shader pack are also available under verious open source licenses, +[which you can read about here](https://github.com/iwalton3/default-shader-pack/blob/master/LICENSE.md). ## Linux Installation @@ -379,4 +432,5 @@ and libmpv libraries are either 64 or 32 bit. (Don't mismatch them.) 3. Download [libmpv](https://sourceforge.net/projects/mpv-player-windows/files/libmpv/). 4. Extract the `mpv-1.dll` from the file and move it to the `plex-mpv-shim` folder. 5. Open a regular `cmd` prompt. Navigate to the `plex-mpv-shim` folder. -6. Run `pyinstaller -wF --add-binary "mpv-1.dll;." --add-binary "plex_mpv_shim\systray.png;." --icon media.ico run.py`. +6. If you would like the shader pack included, [download it](https://github.com/iwalton3/default-shader-pack) and put the contents into `plex_mpv_shim\default_shader_pack`. +7. Run `pyinstaller -wF --add-binary "mpv-1.dll;." --add-binary "plex_mpv_shim\systray.png;." --icon media.ico run.py`. diff --git a/plex_mpv_shim/conf.py b/plex_mpv_shim/conf.py index c5419d3..bdf8d98 100644 --- a/plex_mpv_shim/conf.py +++ b/plex_mpv_shim/conf.py @@ -69,6 +69,13 @@ class Settings(object): "seek_left": -5, "skip_intro_always": False, "skip_intro_prompt": True, + "shader_pack_enable": True, + "shader_pack_custom": False, + "shader_pack_remember": True, + "shader_pack_profile": None, + "svp_enable": False, + "svp_url": "http://127.0.0.1:9901/", + "svp_socket": None, } def __getattr__(self, name): diff --git a/plex_mpv_shim/menu.py b/plex_mpv_shim/menu.py index d60904b..df8a064 100644 --- a/plex_mpv_shim/menu.py +++ b/plex_mpv_shim/menu.py @@ -2,7 +2,12 @@ from .bulk_subtitle import process_series from .conf import settings from .utils import mpv_color_to_plex +from .video_profile import VideoProfileManager +from .svp_integration import SVPManager import time +import logging + +log = logging.getLogger('menu') TRANSCODE_LEVELS = ( ("1080p 20 Mbps", 20000), @@ -52,6 +57,20 @@ def __init__(self, playerManager): self.original_osd_color = playerManager._player.osd_back_color self.original_osd_size = playerManager._player.osd_font_size + self.profile_menu = None + if settings.shader_pack_enable: + try: + profile_manager = VideoProfileManager(self, playerManager) + self.profile_menu = profile_manager.menu_action + except Exception: + log.error("Could not load profile manager.", exc_info=True) + + self.svp_menu = None + try: + self.svp_menu = SVPManager(self, playerManager) + except Exception: + log.error("Could not load SVP integration.", exc_info=True) + # The menu is a bit of a hack... # It works using multiline OSD. # We also have to force the window to open. @@ -90,11 +109,18 @@ def show_menu(self): ("Change Subtitles", self.change_subtitle_menu), ("Change Video Quality", self.change_transcode_quality), ] + if self.profile_menu is not None: + self.menu_list.append(("Change Video Playback Profile", self.profile_menu)) if self.playerManager._video.parent.is_tv: self.menu_list.append(("Auto Set Audio/Subtitles (Entire Series)", self.change_tracks_menu)) self.menu_list.append(("Quit and Mark Unwatched", self.unwatched_menu_handle)) else: self.menu_list = [] + if self.profile_menu is not None: + self.menu_list.append(("Video Playback Profiles", self.profile_menu)) + + if self.svp_menu is not None and self.svp_menu.is_available(): + self.menu_list.append(("SVP Settings", self.svp_menu.menu_action)) self.menu_list.extend([ ("Preferences", self.preferences_menu), diff --git a/plex_mpv_shim/svp_integration.py b/plex_mpv_shim/svp_integration.py new file mode 100644 index 0000000..a3437de --- /dev/null +++ b/plex_mpv_shim/svp_integration.py @@ -0,0 +1,145 @@ +from .conf import settings +import urllib.request +import urllib.error +import logging +import sys + +log = logging.getLogger('svp_integration') + +def list_request(path): + try: + response = urllib.request.urlopen(settings.svp_url + "?" + path) + return response.read().decode('utf-8').replace('\r\n', '\n').split('\n') + except urllib.error.URLError as ex: + log.error("Could not reach SVP API server.", exc_info=1) + return None + +def simple_request(path): + response_list = list_request(path) + if response_list is None: + return None + if len(response_list) != 1 or " = " not in response_list[0]: + return None + return response_list[0].split(" = ")[1] + +def get_profiles(): + profile_ids = list_request("list=profiles") + profiles = {} + for profile_id in profile_ids: + profile_id = profile_id.replace("profiles.", "") + if profile_id == "P10000001_1001_1001_1001_100000000001": + profile_name = "Automatic" + else: + profile_name = simple_request("profiles.{0}.title".format(profile_id)) + profile_guid = "{" + profile_id[1:].replace("_", "-") + "}" + profiles[profile_guid] = profile_name + return profiles + +def get_name_from_guid(profile_id): + profile_id = "P" + profile_id[1:-1].replace("-", "_") + if profile_id == "P10000001_1001_1001_1001_100000000001": + return "Automatic" + else: + return simple_request("profiles.{0}.title".format(profile_id)) + +def get_last_profile(): + return simple_request("rt.playback.last_profile") + +def is_svp_alive(): + try: + response = list_request("") + return response is not None + except Exception: + log.error("Could not reach SVP API server.", exc_info=1) + return False + +def is_svp_enabled(): + return simple_request("rt.disabled") == "false" + +def is_svp_active(): + response = simple_request("rt.playback.active") + if response is None: + return False + return response != "" + +def set_active_profile(profile_id): + # As far as I know, there is no way to directly set the profile. + if not is_svp_active(): + return False + if profile_id == get_last_profile(): + return True + for i in range(len(list_request("list=profiles"))): + list_request("!profile_next") + if get_last_profile() == profile_id: + return True + return False + +def set_disabled(disabled): + return simple_request("rt.disabled={0}".format("true" if disabled else "false")) == "true" + +class SVPManager: + def __init__(self, menu, playerManager): + self.menu = menu + + if settings.svp_enable: + socket = settings.svp_socket + if socket is None: + if sys.platform.startswith("win32") or sys.platform.startswith("cygwin"): + socket = "mpvpipe" + else: + socket = "/tmp/mpvsocket" + + # This actually *adds* another ipc server. + playerManager._player.input_ipc_server = socket + + if not is_svp_alive(): + log.error("SVP is not reachable. Please make sure you have the API enabled.") + + def is_available(self): + if not settings.svp_enable: + return False + if not is_svp_alive(): + return False + return True + + def menu_set_profile(self): + profile_id = self.menu.menu_list[self.menu.menu_selection][2] + if profile_id is None: + set_disabled(True) + else: + set_active_profile(profile_id) + # Need to re-render menu. + self.menu.menu_action("back") + self.menu_action() + + def menu_set_enabled(self): + set_disabled(False) + + # Need to re-render menu. + self.menu.menu_action("back") + self.menu_action() + + def menu_action(self): + if is_svp_active(): + selected = 0 + active_profile = get_last_profile() + profile_option_list = [ + ("Disabled", self.menu_set_profile, None) + ] + for i, (profile_id, profile_name) in enumerate(get_profiles().items()): + profile_option_list.append( + (profile_name, self.menu_set_profile, profile_id) + ) + if profile_id == active_profile: + selected = i+1 + self.menu.put_menu("Select SVP Profile", profile_option_list, selected) + else: + if is_svp_enabled(): + self.menu.put_menu("SVP is Not Active", [ + ("Disable", self.menu_set_profile, None), + ("Retry", self.menu_set_enabled) + ], selected=1) + else: + self.menu.put_menu("SVP is Disabled", [ + ("Enable SVP", self.menu_set_enabled) + ]) diff --git a/plex_mpv_shim/utils.py b/plex_mpv_shim/utils.py index 0d4fa26..4a15a35 100644 --- a/plex_mpv_shim/utils.py +++ b/plex_mpv_shim/utils.py @@ -5,6 +5,7 @@ import ipaddress import uuid import re +import sys from .conf import settings from datetime import datetime @@ -148,3 +149,13 @@ def mpv_color_to_plex(color): def plex_color_to_mpv(color): return '#FF'+color.upper()[1:] + +def get_resource(*path): + # Detect if bundled via pyinstaller. + # From: https://stackoverflow.com/questions/404744/ + if getattr(sys, '_MEIPASS', False): + application_path = os.path.join(sys._MEIPASS, "plex_mpv_shim") + else: + application_path = os.path.dirname(os.path.abspath(__file__)) + + return os.path.join(application_path, *path) diff --git a/plex_mpv_shim/video_profile.py b/plex_mpv_shim/video_profile.py new file mode 100644 index 0000000..f8ebb8a --- /dev/null +++ b/plex_mpv_shim/video_profile.py @@ -0,0 +1,141 @@ +from .conf import settings +from . import conffile +from .utils import get_resource +import logging +import os.path +import shutil +import json +import time + +APP_NAME = 'plex-mpv-shim' +log = logging.getLogger('video_profile') + +class MPVSettingError(Exception): + """Raised when MPV does not support a required setting.""" + pass + +class VideoProfileManager: + def __init__(self, menu, playerManager): + self.menu = menu + self.playerManager = playerManager + self.used_settings = set() + self.current_profile = None + + self.load_shader_pack() + + def load_shader_pack(self): + shader_pack_builtin = get_resource("default_shader_pack") + + self.shader_pack = shader_pack_builtin + if settings.shader_pack_custom: + self.shader_pack = conffile.get(APP_NAME, "shader_pack") + if not os.path.exists(self.shader_pack): + shutil.copytree(shader_pack_builtin, self.shader_pack) + + if not os.path.exists(os.path.join(self.shader_pack, "pack.json")): + raise FileNotFoundError("Could not find default shader pack.") + + with open(os.path.join(self.shader_pack, "pack.json")) as fh: + pack = json.load(fh) + self.default_groups = pack.get("default-setting-groups") or [] + self.profiles = pack.get("profiles") or {} + self.groups = pack.get("setting-groups") or {} + self.revert_ignore = set(pack.get("setting-revert-ignore") or []) + + self.defaults = {} + for group in self.groups.values(): + setting_group = group.get("settings") + if setting_group is None: + continue + + for key, value in setting_group: + if key in self.defaults or key in self.revert_ignore: + continue + try: + self.defaults[key] = getattr(self.playerManager._player, key) + except Exception: + log.warning("Your MPV does not support setting {0} used in shader pack.".format(key), exc_info=1) + + if settings.shader_pack_profile is not None: + self.load_profile(settings.shader_pack_profile, reset=False) + + def process_setting_group(self, group_name, settings_to_apply, shaders_to_apply): + group = self.groups[group_name] + for key, value in group.get("settings", []): + if key in self.revert_ignore: + continue + if key not in self.defaults: + raise MPVSettingError("Cannot use setting group {0} due to MPV not supporting {1}".format(group_name, key)) + self.used_settings.add(key) + settings_to_apply.append((key, value)) + for shader in group.get("shaders", []): + shaders_to_apply.append(os.path.join(self.shader_pack, "shaders", shader)) + + def load_profile(self, profile_name, reset=True): + if reset: + self.unload_profile() + log.info("Loading shader profile {0}.".format(profile_name)) + profile = self.profiles[profile_name] + settings_to_apply = [] + shaders_to_apply = [] + try: + # Read Settings & Shaders + for group in self.default_groups: + self.process_setting_group(group, settings_to_apply, shaders_to_apply) + for group in profile.get("setting-groups", []): + self.process_setting_group(group, settings_to_apply, shaders_to_apply) + for shader in profile.get("shaders", []): + shaders_to_apply.append(os.path.join(self.shader_pack, "shaders", shader)) + + # Apply Settings + already_set = set() + for key, value in settings_to_apply: + if (key, value) in already_set: + continue + log.debug("Set MPV setting {0} to {1}".format(key, value)) + setattr(self.playerManager._player, key, value) + already_set.add((key, value)) + + # Apply Shaders + log.debug("Set shaders: {0}".format(shaders_to_apply)) + self.playerManager._player.glsl_shaders = shaders_to_apply + self.current_profile = profile_name + return True + except MPVSettingError as ex: + log.error("Could not apply shader profile.", exc_info=1) + return False + + def unload_profile(self): + log.info("Unloading shader profile.") + self.playerManager._player.glsl_shaders = [] + for setting in self.used_settings: + setattr(self.playerManager._player, setting, self.defaults[setting]) + self.current_profile = None + + def menu_handle(self): + profile_name = self.menu.menu_list[self.menu.menu_selection][2] + settings_were_successful = True + if profile_name is None: + self.unload_profile() + else: + settings_were_successful = self.load_profile(profile_name) + if settings.shader_pack_remember and settings_were_successful: + settings.shader_pack_profile = profile_name + settings.save() + + # Need to re-render menu. + self.menu.menu_action("back") + self.menu_action() + + def menu_action(self): + selected = 0 + profile_option_list = [ + ("None (Disabled)", self.menu_handle, None) + ] + for i, (profile_name, profile) in enumerate(self.profiles.items()): + profile_option_list.append( + (profile["displayname"], self.menu_handle, profile_name) + ) + if profile_name == self.current_profile: + selected = i+1 + self.menu.put_menu("Select Shader Profile", profile_option_list, selected)