From 1e104a8c864204869876f551008bfe42b778f42a Mon Sep 17 00:00:00 2001 From: Ian Walton Date: Sat, 22 Aug 2020 13:05:38 -0400 Subject: [PATCH] Config data validation and warnings with pydantic. --- README.md | 6 +- jellyfin_mpv_shim/conf.py | 274 +++++++++++++++++--------------------- setup.py | 1 + 3 files changed, 125 insertions(+), 156 deletions(-) diff --git a/README.md b/README.md index cec3a86..ea6559a 100644 --- a/README.md +++ b/README.md @@ -527,7 +527,7 @@ If you'd like to run the application without installing it, run `./run.py`. The project is written entirely in Python 3. There are no closed-source components in this project. It is fully hackable. -The project is dependent on `python-mpv`, `python-mpv-jsonipc`, and `jellyfin-apiclient-python`. If you are +The project is dependent on `python-mpv`, `python-mpv-jsonipc`, `pydantic`, and `jellyfin-apiclient-python`. If you are using Windows and would like mpv to be maximize properly, `pywin32` is also needed. The GUI component uses `pystray` and `tkinter`, but there is a fallback cli mode. The mirroring dependencies are `Jinja2` and `pywebview`, along with platform-specific dependencies. (See the installation and building @@ -550,7 +550,7 @@ The shaders included in the shader pack are also available under verious open so If you are on Windows there are additional dependencies. Please see the Windows Build Instructions. -1. Install the dependencies: `sudo pip3 install --upgrade python-mpv jellyfin-apiclient-python pystray Jinja2 pywebview python-mpv-jsonipc Flask Werkzeug pypresence`. +1. Install the dependencies: `sudo pip3 install --upgrade python-mpv jellyfin-apiclient-python pystray Jinja2 pywebview python-mpv-jsonipc Flask Werkzeug pypresence pydantic`. - If you run `./gen_pkg.sh --install`, it will also fetch these for you. 2. Clone this repository: `git clone https://github.com/iwalton3/jellyfin-mpv-shim` - You can also download a zip build. @@ -661,7 +661,7 @@ You may also need to edit the batch file for 32 bit builds to point to the right 1. Install Git for Windows. Open Git Bash and run `git clone https://github.com/iwalton3/jellyfin-mpv-shim; cd jellyfin-mpv-shim`. - You can update the project later with `git pull`. 2. Install [Python3](https://www.python.org/downloads/) with PATH enabled. Install [7zip](https://ninite.com/7zip/). -3. After installing python3, open `cmd` as admin and run `pip install --upgrade pyinstaller python-mpv jellyfin-apiclient-python pywin32 pystray Jinja2 pywebview[cef] python-mpv-jsonipc Flask Werkzeug pypresence`. +3. After installing python3, open `cmd` as admin and run `pip install --upgrade pyinstaller python-mpv jellyfin-apiclient-python pywin32 pystray Jinja2 pywebview[cef] python-mpv-jsonipc Flask Werkzeug pypresence pydantic`. 4. Download [libmpv](https://sourceforge.net/projects/mpv-player-windows/files/libmpv/). 5. Extract the `mpv-1.dll` from the file and move it to the `jellyfin-mpv-shim` folder. 6. Open a regular `cmd` prompt. Navigate to the `jellyfin-mpv-shim` folder. diff --git a/jellyfin_mpv_shim/conf.py b/jellyfin_mpv_shim/conf.py index 1486a20..1c0b6aa 100644 --- a/jellyfin_mpv_shim/conf.py +++ b/jellyfin_mpv_shim/conf.py @@ -1,180 +1,160 @@ import logging import os import uuid -import pickle as pickle import socket import json import os.path import sys +from pydantic import BaseModel +from typing import Optional log = logging.getLogger("conf") +config_path = None -class Settings(object): - _listeners = [] - - _path = None - _data = { - "player_name": socket.gethostname(), - "audio_output": "hdmi", - "client_uuid": str(uuid.uuid4()), - "media_ended_cmd": None, - "pre_media_cmd": None, - "stop_cmd": None, - "auto_play": True, - "idle_cmd": None, - "idle_cmd_delay": 60, - "direct_paths": False, - "remote_direct_paths": False, - "always_transcode": False, - "transcode_h265": False, - "transcode_hi10p": False, - "remote_kbps": 10000, - "local_kbps": 2147483, - "subtitle_size": 100, - "subtitle_color": "#FFFFFFFF", - "subtitle_position": "bottom", - "fullscreen": True, - "enable_gui": True, - "media_key_seek": False, - "mpv_ext": sys.platform.startswith("darwin"), - "mpv_ext_path": None, - "mpv_ext_ipc": None, - "mpv_ext_start": True, - "mpv_ext_no_ovr": False, - "enable_osc": True, - "use_web_seek": False, - "display_mirroring": False, - "log_decisions": False, - "mpv_log_level": "info", - "enable_desktop": False, - "desktop_fullscreen": False, - "desktop_keep_pos": False, - "desktop_keep_size": True, - "idle_when_paused": False, - "stop_idle": False, - "transcode_to_h265": False, - "kb_stop": "q", - "kb_prev": "<", - "kb_next": ">", - "kb_watched": "w", - "kb_unwatched": "u", - "kb_menu": "c", - "kb_menu_esc": "esc", - "kb_menu_ok": "enter", - "kb_menu_left": "left", - "kb_menu_right": "right", - "kb_menu_up": "up", - "kb_menu_down": "down", - "kb_pause": "space", - "kb_fullscreen": "f", - "kb_debug": "~", - "kb_kill_shader": "k", - "seek_up": 60, - "seek_down": -60, - "seek_right": 5, - "seek_left": -5, - "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, - "sanitize_output": True, - "write_logs": False, - "playback_timeout": 30, - "sync_max_delay_speed": 50, - "sync_max_delay_skip": 300, - "sync_method_thresh": 2000, - "sync_speed_time": 1000, - "sync_speed_attempts": 3, - "sync_attempts": 5, - "sync_revert_seek": True, - "sync_osd_message": True, - "screenshot_menu": True, - "check_updates": True, - "notify_updates": True, - "lang": None, - "desktop_scale": 1.0, - "discord_presence": False, - "ignore_ssl_cert": False, - "menu_mouse": True, - "media_keys": True, - "connect_retry_mins": 0, - "transcode_warning": True, - "lang_filter": "und,eng,jpn,mis,mul,zxx", - "lang_filter_sub": False, - "lang_filter_audio": False, - } - - def __getattr__(self, name): - return self._data[name] - - def __setattr__(self, name, value): - if name in self._data: - self._data[name] = value - self.save() - - for callback in self._listeners: - try: - callback(name, value) - except: - pass - else: - super(Settings, self).__setattr__(name, value) +class Settings(BaseModel): + player_name: str = socket.gethostname() + audio_output: str = "hdmi" + client_uuid: str = str(uuid.uuid4()) + media_ended_cmd: Optional[str] = None + pre_media_cmd: Optional[str] = None + stop_cmd: Optional[str] = None + auto_play: bool = True + idle_cmd: Optional[str] = None + idle_cmd_delay: int = 60 + direct_paths: bool = False + remote_direct_paths: bool = False + always_transcode: bool = False + transcode_h265: bool = False + transcode_hi10p: bool = False + remote_kbps: int = 10000 + local_kbps: int = 2147483 + subtitle_size: int = 100 + subtitle_color: str = "#FFFFFFFF" + subtitle_position: str = "bottom" + fullscreen: bool = True + enable_gui: bool = True + media_key_seek: bool = False + mpv_ext: bool = sys.platform.startswith("darwin") + mpv_ext_path: Optional[str] = None + mpv_ext_ipc: Optional[str] = None + mpv_ext_start: bool = True + mpv_ext_no_ovr: bool = False + enable_osc: bool = True + use_web_seek: bool = False + display_mirroring: bool = False + log_decisions: bool = False + mpv_log_level: str = "info" + enable_desktop: bool = False + desktop_fullscreen: bool = False + desktop_keep_pos: bool = False + desktop_keep_size: bool = True + idle_when_paused: bool = False + stop_idle: bool = False + transcode_to_h265: bool = False + kb_stop: str = "q" + kb_prev: str = "<" + kb_next: str = ">" + kb_watched: str = "w" + kb_unwatched: str = "u" + kb_menu: str = "c" + kb_menu_esc: str = "esc" + kb_menu_ok: str = "enter" + kb_menu_left: str = "left" + kb_menu_right: str = "right" + kb_menu_up: str = "up" + kb_menu_down: str = "down" + kb_pause: str = "space" + kb_fullscreen: str = "f" + kb_debug: str = "~" + kb_kill_shader: str = "k" + seek_up: int = 60 + seek_down: int = -60 + seek_right: int = 5 + seek_left: int = -5 + shader_pack_enable: bool = True + shader_pack_custom: bool = False + shader_pack_remember: bool = True + shader_pack_profile: Optional[str] = None + svp_enable: bool = False + svp_url: str = "http://127.0.0.1:9901/" + svp_socket: Optional[str] = None + sanitize_output: bool = True + write_logs: bool = False + playback_timeout: int = 30 + sync_max_delay_speed: int = 50 + sync_max_delay_skip: int = 300 + sync_method_thresh: int = 2000 + sync_speed_time: int = 1000 + sync_speed_attempts: int = 3 + sync_attempts: int = 5 + sync_revert_seek: bool = True + sync_osd_message: bool = True + screenshot_menu: bool = True + check_updates: bool = True + notify_updates: bool = True + lang: Optional[str] = None + desktop_scale: float = 1.0 + discord_presence: bool = False + ignore_ssl_cert: bool = False + menu_mouse: bool = True + media_keys: bool = True + connect_retry_mins: int = 0 + transcode_warning: bool = True + lang_filter: str = "und,eng,jpn,mis,mul,zxx" + lang_filter_sub: bool = False + lang_filter_audio: bool = False def __get_file(self, path, mode="r", create=True): created = False if not os.path.exists(path): try: - fh = open(path, mode) + _fh = open(path, mode) except IOError as e: if e.errno == 2 and create: fh = open(path, "w") - json.dump(self._data, fh, indent=4, sort_keys=True) + json.dump(self.dict(), fh, indent=4, sort_keys=True) fh.close() created = True else: raise e - except Exception as e: + except Exception: log.error("Error opening settings from path: %s" % path) return None # This should work now return open(path, mode), created - def migrate_config(self, old_path, new_path): - fh, created = self.__get_file(old_path, "rb+", False) - if not created: - try: - data = pickle.load(fh) - self._data.update(data) - except Exception as e: - log.error("Error loading settings from pickle: %s" % e) - fh.close() - return False - - os.remove(old_path) - self._path = new_path - fh.close() - self.save() - return True - def load(self, path, create=True): + global config_path # Don't want in model. fh, created = self.__get_file(path, "r", create) - self._path = path + config_path = path if not created: try: data = json.load(fh) + safe_data = self.parse_obj(data) + + # Copy and count items input_params = 0 + for key in safe_data.__fields_set__: + setattr(self, key, getattr(safe_data, key)) + input_params += 1 + + # Print warnings for key, value in data.items(): - if key in self._data: - input_params += 1 - self._data[key] = value + if key not in safe_data.__fields_set__: + log.warning("Config item {0} was ignored.".format(key)) + elif value != getattr(safe_data, key): + log.warning( + "Config item {0} was was coerced from {1} to {2}.".format( + key, repr(value), repr(getattr(safe_data, key)) + ) + ) + log.info("Loaded settings from json: %s" % path) - if input_params < len(self._data): + if input_params < len(self.__fields__): + log.info("Saving back due to schema change.") self.save() except Exception as e: log.error("Error loading settings from json: %s" % e) @@ -185,10 +165,10 @@ class Settings(object): return True def save(self): - fh, created = self.__get_file(self._path, "w", True) + fh, created = self.__get_file(config_path, "w", True) try: - json.dump(self._data, fh, indent=4, sort_keys=True) + json.dump(self.dict(), fh, indent=4, sort_keys=True) fh.flush() fh.close() except Exception as e: @@ -197,17 +177,5 @@ class Settings(object): return True - def add_listener(self, callback): - """ - Register a callback to be called anytime a setting value changes. - An example callback function: - - def my_callback(key, value): - # Do something with the new setting ``value``... - - """ - if callback not in self._listeners: - self._listeners.append(callback) - settings = Settings() diff --git a/setup.py b/setup.py index 3347a5b..ddbac80 100644 --- a/setup.py +++ b/setup.py @@ -53,6 +53,7 @@ setup( "jellyfin-apiclient-python>=1.6.1", "python-mpv-jsonipc>=1.1.9", "requests", + "pydantic", ], include_package_data=True, )