├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ └── main.yml ├── .gitignore ├── .idea ├── .gitignore ├── inspectionProfiles │ └── profiles_settings.xml ├── misc.xml ├── modules.xml ├── plex-mpv-shim.iml └── vcs.xml ├── LICENSE.md ├── MANIFEST.in ├── Plex MPV Shim.iss ├── README.md ├── build-debug.bat ├── build.bat ├── gen_pkg.sh ├── hidpi.manifest ├── media.ico ├── plex_mpv_shim ├── __init__.py ├── action_thread.py ├── bulk_subtitle.py ├── cli_mgr.py ├── client.py ├── conf.py ├── conffile.py ├── gdm.py ├── gui_mgr.py ├── media.py ├── menu.py ├── mouse.lua ├── mpv_shim.py ├── player.py ├── subscribers.py ├── svp_integration.py ├── systray.png ├── timeline.py ├── utils.py ├── video_profile.py └── win_utils.py ├── run.py └── setup.py /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | custom: ['https://paypal.me/iwalton3'] 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # https://docs.github.com/en/code-security/supply-chain-security/keeping-your-dependencies-updated-automatically/configuration-options-for-dependency-updates#package-ecosystem 2 | 3 | version: 2 4 | updates: 5 | 6 | # Enable updates for Github Actions 7 | - package-ecosystem: "github-actions" 8 | target-branch: "master" 9 | directory: "/" 10 | schedule: 11 | # Check for updates to GitHub Actions every month 12 | interval: "monthly" -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | jobs: 8 | build-win64: 9 | runs-on: windows-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - name: Setup Python 3.9 13 | uses: actions/setup-python@v4 14 | with: 15 | python-version: 3.9 16 | - name: Install dependencies 17 | run: | 18 | curl -L https://sourceforge.net/projects/mpv-player-windows/files/libmpv/mpv-dev-x86_64-20230205-git-e439ddc.7z/download > mpv.7z 19 | 7z x mpv.7z 20 | mv libmpv-2.dll mpv-2.dll 21 | pip install . pywin32 pyinstaller pystray 22 | ./gen_pkg.sh --skip-build 23 | shell: bash 24 | - name: Build 25 | run: | 26 | .\build.bat 27 | shell: cmd 28 | - name: Archive production artifacts 29 | uses: actions/upload-artifact@v3 30 | with: 31 | name: windows 32 | path: ${{ github.workspace }}/dist/*.exe 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | dist 3 | build 4 | plex_mpv_shim.egg-info 5 | plex_mpv_shim/default_shader_pack/* 6 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Datasource local storage ignored files 5 | /dataSources/ 6 | /dataSources.local.xml 7 | # Editor-based HTTP Client requests 8 | /httpRequests/ 9 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/profiles_settings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/plex-mpv-shim.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 12 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2019 Ian Walton 2 | Copyright (c) 2014 Weston Nielson 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | SOFTWARE. 21 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include plex_mpv_shim/systray.png 2 | recursive-include plex_mpv_shim/default_shader_pack * 3 | include plex_mpv_shim/mouse.lua 4 | -------------------------------------------------------------------------------- /Plex MPV Shim.iss: -------------------------------------------------------------------------------- 1 | ; Script generated by the Inno Setup Script Wizard. 2 | ; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES! 3 | 4 | #define MyAppName "Plex MPV Shim" 5 | #define MyAppVersion "1.11.1" 6 | #define MyAppPublisher "Ian Walton" 7 | #define MyAppURL "https://github.com/iwalton3/plex-mpv-shim" 8 | #define MyAppExeName "run.exe" 9 | 10 | [Setup] 11 | ; NOTE: The value of AppId uniquely identifies this application. Do not use the same AppId value in installers for other applications. 12 | ; (To generate a new GUID, click Tools | Generate GUID inside the IDE.) 13 | AppId={{5D19E922-38AC-4ED4-A59A-560D145936BF} 14 | AppName={#MyAppName} 15 | AppVersion={#MyAppVersion} 16 | ;AppVerName={#MyAppName} {#MyAppVersion} 17 | AppPublisher={#MyAppPublisher} 18 | AppPublisherURL={#MyAppURL} 19 | AppSupportURL={#MyAppURL} 20 | AppUpdatesURL={#MyAppURL} 21 | DefaultDirName={autopf}\{#MyAppName} 22 | DisableProgramGroupPage=yes 23 | LicenseFile=LICENSE.md 24 | ; Uncomment the following line to run in non administrative install mode (install for current user only.) 25 | ;PrivilegesRequired=lowest 26 | PrivilegesRequiredOverridesAllowed=dialog 27 | OutputDir=dist 28 | OutputBaseFilename=plex-mpv-shim_version_installer 29 | SetupIconFile=media.ico 30 | Compression=lzma 31 | SolidCompression=yes 32 | WizardStyle=modern 33 | 34 | [Languages] 35 | Name: "english"; MessagesFile: "compiler:Default.isl" 36 | 37 | [Tasks] 38 | Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked 39 | 40 | [Files] 41 | Source: "dist\run\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs 42 | ; NOTE: Don't use "Flags: ignoreversion" on any shared system files 43 | 44 | [Icons] 45 | Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}" 46 | Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon 47 | 48 | [Run] 49 | Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent 50 | 51 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MPV Shim for Plex 2 | 3 | MPV Shim is a simple and lightweight Plex client, with support for Windows 4 | and Linux. Think of it as an open source Chromecast for Plex. You can cast almost 5 | anything from Plex and it will Direct Play. Subtitles are fully supported, and 6 | there are tools to manage them like no other Plex client. 7 | 8 | ## Getting Started 9 | 10 | If you are on Windows, simply [download the binary](https://github.com/iwalton3/plex-mpv-shim/releases). 11 | If you are using Linux or OSX, please see the [Linux Installation](https://github.com/iwalton3/plex-mpv-shim/blob/master/README.md#linux-installation) or [OSX Installation](https://github.com/iwalton3/plex-mpv-shim/blob/master/README.md#osx-installation) sections below. 12 | 13 | To use the client, simply launch it and cast your media from another Plex application. 14 | The mobile and web applications are supported. You do not have to log in to the client 15 | or set it up in any other way. 16 | 17 | If you want to use the web application to cast, you must either be on the same network as 18 | a Plex server (an empty one will work) or install the [MPV Shim Local Connection](https://greasyfork.org/en/scripts/398049-mpv-shim-local-connection) user script. 19 | 20 | The application runs with a notification icon by default. You can use this to view the application log, open the config folder, and open the application menu. 21 | 22 | ## Advanced Features 23 | 24 | ### Menu 25 | 26 | To open the menu, press **c** on your computer or **home** within the Plex mobile apps. 27 | 28 | The menu enables you to: 29 | - Adjust video transcoding quality. 30 | - Change the default transcoder settings. 31 | - Change subtitles or audio, while knowing the track names. 32 | - Change subtitles or audio for an entire series at once. 33 | - Mark the media as unwatched and quit. 34 | - Configure shader packs and SVP profiles. 35 | 36 | On your computer, use the arrow keys, enter, and escape to navigate. On your phone, use 37 | the arrow buttons, ok, back, and home to navigate. (The option for remote controls is 38 | shown next to the name of the client when you select it from the cast menu.) 39 | 40 | Please also note that the on-screen controller for MPV (if available) cannot change the 41 | audio and subtitle track configurations for transcoded media. It also cannot load external 42 | subtitles. You must either use the menu or the application you casted from. 43 | 44 | ### Shader Packs 45 | 46 | Shader packs are a recent feature addition that allows you to easily use advanced video 47 | shaders and video quality settings. These usually require a lot of configuration to use, 48 | but MPV Shim's default shader pack comes with [FSRCNNX](https://github.com/igv/FSRCNN-TensorFlow) 49 | and [Anime4K](https://github.com/bloc97/Anime4K) preconfigured. Try experimenting with video 50 | profiles! It may greatly improve your experience. 51 | 52 | Shader Packs are ready to use as of the most recent MPV Shim version. To use, simply 53 | navigate to the **Video Playback Profiles** option and select a profile. 54 | 55 | For details on the shader settings, please see [default-shader-pack](https://github.com/iwalton3/default-shader-pack). 56 | If you would like to customize the shader pack, there are details in the configuration section. 57 | 58 | ### SVP Integration 59 | 60 | SVP integration allows you to easily configure SVP support, change profiles, and enable/disable 61 | SVP without having to exit the player. It is not enabled by default, please see the configuration 62 | instructions for instructions on how to enable it. 63 | 64 | ### Keyboard Shortcuts 65 | 66 | This program supports most of the [keyboard shortcuts from MPV](https://mpv.io/manual/stable/#interactive-control). The custom keyboard shortcuts are: 67 | 68 | - < > to skip episodes 69 | - q to close player 70 | - w to mark watched and skip 71 | - u to mark unwatched and quit 72 | - c to open the menu 73 | 74 | Here are the notable MPV keyboard shortcuts: 75 | 76 | - space - Pause/Play 77 | - left/right - Seek by 5 seconds 78 | - up/down - Seek by 1 minute 79 | - s - Take a screenshot 80 | - S - Take a screenshot without subtitles 81 | - f - Toggle fullscreen 82 | - ,/. - Seek by individual frames 83 | - \[/\] - Change video speed by 10% 84 | - {/} - Change video speed by 50% 85 | - backspace - Reset speed 86 | - m - Mute 87 | - d - Enable/disable deinterlace 88 | - Ctrl+Shift+Left/Right - Adjust subtitle delay. 89 | 90 | ## Configuration 91 | 92 | The configuration file is located in different places depending on your platform. You can open the 93 | configuration folder using the systray icon. When you launch the program on Linux or OSX from the terminal, 94 | the location of the config file will be printed. The locations are: 95 | - Windows - `%appdata%\plex-mpv-shim\conf.json` 96 | - Linux - `~/.config/plex-mpv-shim/conf.json` 97 | - Mac OSX - `Library/Application Support/plex-mpv-shim/conf.json` 98 | - CygWin - `~/.config/plex-mpv-shim/conf.json` 99 | 100 | You can specify a custom configuration folder with the `--config` option. 101 | 102 | ### Transcoding 103 | 104 | You can adjust the basic transcoder settings via the menu. 105 | 106 | - `always_transcode` - This will tell the client to always transcode, without asking. Default: `false` 107 | - This may be useful if you are using limited hardware that cannot handle advanced codecs. 108 | - You may have some luck changing `client_profile` in the configuration to a more restrictive one. 109 | - `auto_transcode` - This will ask the server to determine if transcoding is suggested. Default: `true` 110 | - `transcode_kbps` - Transcode bandwidth to request. Default: `2000` 111 | - `direct_limit` - Also limit direct play to `transcode_kbps`. Default: `false` 112 | - Note that `direct_limit` cannot be overriden without changing `transcode_kbps`. 113 | - If `direct_limit` is not set, the player assumes the server will set the limit. 114 | - `adaptive_transcode` - Tell the server to adjust the quality while streaming. Default: `false` 115 | 116 | ### Shell Command Triggers 117 | 118 | You can execute shell commands on media state using the config file: 119 | 120 | - `media_ended_cmd` - When all media has played. 121 | - `pre_media_cmd` - Before the player displays. (Will wait for finish.) 122 | - `stop_cmd` - After stopping the player. 123 | - `idle_cmd` - After no activity for `idle_cmd_delay` seconds. 124 | 125 | ### Subtitle Visual Settings 126 | 127 | All of these settings apply to direct play and are adjustable through the controlling app. Note that some may not work depending on the subtitle codec. Subtitle position and color are not available for transcodes. 128 | 129 | - `subtitle_size` - The size of the subtitles, in percent. Default: `100` 130 | - `subtitle_color` - The color of the subtitles, in hex. Default: `#FFFFFFFF` 131 | - `subtitle_position` - The position (top, bottom, middle). Default: `bottom` 132 | 133 | ### External MPV 134 | 135 | The client now supports using an external copy of MPV, including one that is running prior to starting 136 | the client. This may be useful if your distribution only provides MPV as a binary executable (instead 137 | of as a shared library), or to connect to MPV-based GUI players. Please note that SMPlayer exhibits 138 | strange behaviour when controlled in this manner. External MPV is currently the only working backend 139 | for media playback on OSX. 140 | 141 | - `mpv_ext` - Enable usage of the external player by default. Default: `false` 142 | - The external player may still be used by default if `libmpv1` is not available. 143 | - `mpv_ext_path` - The path to the `mpv` binary to use. By default it uses the one in the PATH. Default: `null` 144 | - If you are using Windows, make sure to use two backslashes. Example: `C:\\path\\to\\mpv.exe` 145 | - `mpv_ext_ipc` - The path to the socket to control MPV. Default: `null` 146 | - If unset, the socket is a randomly selected temp file. 147 | - On Windows, this is just a name for the socket, not a path like on Linux. 148 | - `mpv_ext_start` - Start a managed copy of MPV with the client. Default: `true` 149 | - If not specified, the user must start MPV prior to launching the client. 150 | - MPV must be launched with `--input-ipc-server=[value of mpv_ext_ipc]`. 151 | - `mpv_ext_no_ovr` - Disable built-in mpv configuration files and use user defaults. 152 | - Please note that some scripts and settings, such as ones to keep MPV open, may break 153 | functionality in MPV Shim. 154 | 155 | ### Keyboard Shortcuts 156 | 157 | You can reconfigure the custom keyboard shortcuts. You can also set them to `null` to disable the shortcut. Please note that disabling keyboard shortcuts may make some features unusable. Additionally, if you remap `q`, using the default shortcut will crash the player. 158 | 159 | - `kb_stop` - Stop playback and close MPV. (Default: `q`) 160 | - `kb_prev` - Go to the previous video. (Default: `<`) 161 | - `kb_next` - Go to the next video. (Default: `>`) 162 | - `kb_watched` - Mark the video as watched and skip. (Default: `w`) 163 | - `kb_unwatched` - Mark the video as unwatched and quit. (Default: `u`) 164 | - `kb_menu` - Open the configuration menu. (Default: `c`) 165 | - `kb_menu_esc` - Leave the menu. Exits fullscreen otherwise. (Default: `esc`) 166 | - `kb_menu_ok` - "ok" for menu. (Default: `enter`) 167 | - `kb_menu_left` - "left" for menu. Seeks otherwise. (Default: `left`) 168 | - `kb_menu_right` - "right" for menu. Seeks otherwise. (Default: `right`) 169 | - `kb_menu_up` - "up" for menu. Seeks otherwise. (Default: `up`) 170 | - `kb_menu_down` - "down" for menu. Seeks otherwise. (Default: `down`) 171 | - `kb_pause` - Pause. Also "ok" for menu. (Default: `space`) 172 | - `kb_debug` - Trigger `pdb` debugger. (Default: `~`) 173 | - `seek_up` - Time to seek for "up" key. (Default: `60`) 174 | - `seek_down` - Time to seek for "down" key. (Default: `-60`) 175 | - `seek_right` - Time to seek for "right" key. (Default: `5`) 176 | - `seek_left` - Time to seek for "left" key. (Default: `-5`) 177 | 178 | ### Shader Packs 179 | 180 | Shader packs allow you to import MPV config and shader presets into MPV Shim and easily switch 181 | between them at runtime through the built-in menu. This enables easy usage and switching of 182 | advanced MPV video playback options, such as video upscaling, while being easy to use. 183 | 184 | If you select one of the presets from the shader pack, it will override some MPV configurations 185 | and any shaders manually specified in `mpv.conf`. If you would like to customize the shader pack, 186 | use `shader_pack_custom`. 187 | 188 | - `shader_pack_enable` - Enable shader pack. (Default: `true`) 189 | - `shader_pack_custom` - Enable to use a custom shader pack. (Default: `false`) 190 | - If you enable this, it will copy the default shader pack to the `shader_pack` config folder. 191 | - This initial copy will only happen if the `shader_pack` folder didn't exist. 192 | - This shader pack will then be used instead of the built-in one from then on. 193 | - `shader_pack_remember` - Automatically remember the last used shader profile. (Default: `true`) 194 | - `shader_pack_profile` - The default profile to use. (Default: `null`) 195 | - If you use `shader_pack_remember`, this will be updated when you set a profile through the UI. 196 | - `shader_pack_subtype` - The profile group to use. The default pack contains `lq` and `hq` groups. Use `hq` if you have a fancy graphics card. 197 | 198 | ### SVP Integration 199 | 200 | To enable SVP integration, set `svp_enable` to `true` and enable "External control via HTTP" within SVP 201 | under Settings > Control options. Adjust the `svp_url` and `svp_socket` settings if needed. 202 | 203 | - `svp_enable` - Enable SVP integration. (Default: `false`) 204 | - `svp_url` - URL for SVP web API. (Default: `http://127.0.0.1:9901/`) 205 | - `svp_socket` - Custom MPV socket to use for SVP. 206 | - Default on Windows: `mpvpipe` 207 | - Default on other platforms: `/tmp/mpvsocket` 208 | 209 | Currently on Windows the built-in MPV does not work with SVP. You must download MPV yourself. 210 | 211 | - Download the latest MPV build [from here](https://sourceforge.net/projects/mpv-player-windows/files/64bit/). 212 | - Follow the [vapoursynth instructions](https://github.com/shinchiro/mpv-winbuild-cmake/wiki/Setup-vapoursynth-for-mpv). 213 | - Make sure to use the latest Python, not Python 3.7. 214 | - In the config file, set `mpv_ext` to `true` and `mpv_ext_path` to the path to `mpv.exe`. 215 | - Make sure to use two backslashes per each backslash in the path. 216 | 217 | ### Other Configuration Options 218 | 219 | - `player_name` - The name of the player that appears in the cast menu. Initially set from your hostname. 220 | - `http_port` - The TCP port to listen on for Plex to control the player. Default: `3000` 221 | - `enable_play_queue` - Enable play queue support. Default: `true` 222 | - If you disable this, the application will queue media based on the series. 223 | - This is a legacy feature. It is not regularly tested. 224 | - `client_uuid` - The identifier for the client. Set to a random value on first run. 225 | - `audio_ac3passthrough` - Does not work. Currently only changes transcoder settings. Default: `false` 226 | - `audio_dtspassthrough` - Does not work. Currently only changes transcoder settings. Default: `false` 227 | - `allow_http` - Allow insecure Plex server connections. Default: `false` 228 | - This may be useful if you are using a Plex server offline or not signed in. 229 | - `client_profile` - The client profile for transcoding. Default: `Plex Home Theater` 230 | - It may be useful to change this on limited hardware. 231 | - If you change this, it should be changed to a profile that supports `hls` streaming. 232 | - `sanitize_output` - Prevent Plex tokens from being printed to the console. Default: `true` 233 | - `fullscreen` - Fullscreen the player when starting playback. Default: `true` 234 | - `enable_gui` - Enable the system tray icon and GUI features. Default: `true` 235 | - `media_key_seek` - Use the media next/prev keys to seek instead of skip episodes. Default: `false` 236 | - `enable_osc` - Enable the MPV on-screen controller. Default: `true` 237 | - It may be useful to disable this if you are using an external player that already provides a user interface. 238 | - `log_decisions` - Log the full playback URLs. Default: `false` 239 | - `mpv_log_level` - Log level to use for mpv. Default: `info` 240 | - Options: fatal, error, warn, info, v, debug, trace 241 | - `idle_when_paused` - Consider the player idle when paused. Default: `false` 242 | - `stop_idle` - Stop the player when idle. (Requires `idle_when_paused`.) Default: `false` 243 | - `skip_intro_always` - Always skip intros, without asking. Default: `false` 244 | - `skip_intro_prompt` - Prompt to skip intro via seeking. Default: `true` 245 | - `skip_credits_always` - Always skip credits, without asking. Default: `false` 246 | - `skip_credits_prompt` - Prompt to skip credits via seeking. Default: `true` 247 | - `menu_mouse` - Enable mouse support in the menu. Default: `true` 248 | - This requires MPV to be compiled with lua support. 249 | 250 | ### MPV Configuration 251 | 252 | You can configure mpv directly using the `mpv.conf` and `input.conf` files. (It is in the same folder as `conf.json`.) 253 | This may be useful for customizing video upscaling, keyboard shortcuts, or controlling the application 254 | via the mpv IPC server. 255 | 256 | ## Tips and Tricks 257 | 258 | Various tips have been found that allow the media player to support special 259 | functionality, albeit with more configuration required. 260 | 261 | ### Open on Specific Monitor 262 | 263 | Please note: Edits to the `mpv.conf` will not take effect until you restart the application. You can open the config directory by using the menu option in the system tray icon. 264 | 265 | **Option 1**: Select fullscreen output screen through MPV. 266 | Determine which screen you would like MPV to show up on. 267 | - If you are on Windows, right click the desktop and select "Display Settings". Take the monitor number and subtract one. 268 | - If you are on Linux, run `xrandr`. The screen number is the number you want. If there is only one proceed to **Option 2**. 269 | 270 | Add the following to your `mpv.conf` in the [config directory](https://github.com/iwalton3/jellyfin-mpv-shim#mpv-configuration), replacing `0` with the number from the previous step: 271 | ``` 272 | fs=yes 273 | fs-screen=0 274 | ``` 275 | 276 | **Option 2**: (Linux Only) If option 1 does not work, both of your monitors are likely configured as a single "screen". 277 | 278 | Run `xrandr`. It should look something like this: 279 | 280 | ``` 281 | Screen 0: minimum 8 x 8, current 3520 x 1080, maximum 16384 x 16384 282 | VGA-0 connected 1920x1080+0+0 (normal left inverted right x axis y axis) 521mm x 293mm 283 | 1920x1080 60.00*+ 284 | 1680x1050 59.95 285 | 1440x900 59.89 286 | 1280x1024 75.02 60.02 287 | 1280x960 60.00 288 | 1280x800 59.81 289 | 1280x720 60.00 290 | 1152x864 75.00 291 | 1024x768 75.03 70.07 60.00 292 | 800x600 75.00 72.19 60.32 56.25 293 | 640x480 75.00 59.94 294 | LVDS-0 connected 1600x900+1920+180 (normal left inverted right x axis y axis) 309mm x 174mm 295 | 1600x900 59.98*+ 296 | ``` 297 | 298 | If you want MPV to open on VGA-0 for instance, add the following to your `mpv.conf` in the [config directory](https://github.com/iwalton3/jellyfin-mpv-shim#mpv-configuration): 299 | ``` 300 | fs=yes 301 | geometry=1920x1080+0+0 302 | ``` 303 | **Option 3**: (Linux Only) If your window manager supports it, you can tell the window manager to always open on a specific screen. 304 | 305 | - For OpenBox: https://forums.bunsenlabs.org/viewtopic.php?id=1199 306 | - For i3: https://unix.stackexchange.com/questions/96798/i3wm-start-applications-on-specific-workspaces-when-i3-starts/363848#363848 307 | 308 | ### Control Volume with Mouse Wheel 309 | 310 | Add the following to `input.conf`: 311 | ``` 312 | WHEEL_UP add volume 5 313 | WHEEL_DOWN add volume -5 314 | ``` 315 | 316 | ### MPRIS Plugin 317 | 318 | Set `mpv_ext` to `true` in the config. Add `script=/path/to/mpris.so` to `mpv.conf`. 319 | 320 | ### Run Multiple Instances 321 | 322 | You can pass `--config /path/to/folder` to run another copy of the player. Make sure to change the port that the player uses. 323 | 324 | ### Audio Passthrough 325 | 326 | You can edit `mpv.conf` to support audio passthrough. A [user on Reddit](https://reddit.com/r/jellyfin/comments/fru6xo/new_cross_platform_desktop_client_jellyfin_mpv/fns7vyp) had luck with this config: 327 | ``` 328 | audio-spdif=ac3,dts,eac3 # (to use the passthrough to receiver over hdmi) 329 | audio-channels=2 # (not sure this is necessary, but i keep it in because it works) 330 | af=scaletempo,lavcac3enc=yes:640:3 # (for aac 5.1 tracks to the receiver) 331 | ``` 332 | 333 | ### MPV Crashes with "The sub-scale option must be a floating point number or a ratio" 334 | 335 | Run the plex-mpv-shim program with LC_NUMERIC=C. 336 | 337 | ### Use with gnome-mpv/celluloid 338 | 339 | You can use `gnome-mpv` with MPV Shim, but you must launch `gnome-mpv` separately before MPV Shim. (`gnome-mpv` doesn't support the MPV command options directly.) 340 | 341 | Configure MPV Shim with the following options (leave the other ones): 342 | ```json 343 | { 344 | "mpv_ext": true, 345 | "mpv_ext_ipc": "/tmp/gmpv-socket", 346 | "mpv_ext_path": null, 347 | "mpv_ext_start": false, 348 | "enable_osc": false 349 | } 350 | ``` 351 | Then within `gnome-mpv`, click the application icon (top left) > Preferences. Configure the following Extra MPV Options: 352 | ``` 353 | --idle --input-ipc-server=/tmp/gmpv-socket 354 | ``` 355 | 356 | ### Heavy Memory Usage 357 | 358 | A problem has been identified where MPV can use a ton of RAM after media has been played, 359 | and this RAM is not always freed when the player goes into idle mode. Some users have 360 | found that using external MPV lessens the memory leak. To enable external MPV on Windows: 361 | 362 | - [Download a copy of MPV](https://sourceforge.net/projects/mpv-player-windows/files/64bit/) 363 | - Unzip it with 7zip. 364 | - Configure `mpv_ext` to `true`. (See the config section.) 365 | - Configure `mpv_ext_path` to `C:\\replace\\with\\path\\to\\mpv.exe`. (Note usage of two `\\`.) 366 | - Run the program and wait. (You'll probably have to use it for a while.) 367 | - Let me know if the high memory usage is with `mpv.exe` or the shim itself. 368 | 369 | On Linux, the process is similar, except that you don't need to set the `mpv_ext_path` variable. 370 | On OSX, external MPV is already the default and is the only supported player mode. 371 | 372 | In the long term, I may look into a method of terminating MPV when not in use. This will require 373 | a lot of changes to the software. 374 | 375 | ## Development 376 | 377 | If you'd like to run the application without installing it, run `./run.py`. 378 | The project is written entirely in Python 3. There are no closed-source 379 | components in this project. It is fully hackable. 380 | 381 | The project is dependent on `python-mpv`, `python-mpv-jsonipc`, and `requests`. If you are using Windows 382 | and would like mpv to be maximize properly, `pywin32` is also needed. The GUI component 383 | uses `pystray` and `tkinter`, but there is a fallback cli mode. 384 | 385 | If you are using a local firewall, you'll want to allow inbound connections on 386 | TCP 3000 and UDP 32410, 32412, 32413, and 32414. The TCP port is for the web 387 | server the client uses to recieve commands. The UDP ports are for the [GDM 388 | discovery protocol](https://support.plex.tv/articles/201543147-what-network-ports-do-i-need-to-allow-through-my-firewall/). 389 | 390 | This project is based on https://github.com/wnielson/omplex, which 391 | is available under the terms of the MIT License. The project was ported 392 | to python3, modified to use mpv as the player, and updated to allow all 393 | features of the remote control api for video playback. The shaders included 394 | in the shader pack are also available under verious open source licenses, 395 | [which you can read about here](https://github.com/iwalton3/default-shader-pack/blob/master/LICENSE.md). 396 | 397 | ## Linux Installation 398 | 399 | If you are on Linux, you can install via pip. You'll need [libmpv1](https://github.com/Kagami/mpv.js/blob/master/README.md#get-libmpv) or `mpv` installed. 400 | ```bash 401 | pip3 install --upgrade plex-mpv-shim 402 | ``` 403 | Note: Recent distributions make pip unusable by default. Consider using conda or add a virtualenv to your user's path. 404 | 405 | If you would like the GUI and systray features, also install `pystray` and `tkinter`: 406 | ```bash 407 | pip3 install pystray 408 | sudo apt install python3-tk 409 | ``` 410 | 411 | You can build mpv from source to get better codec support. Execute the following: 412 | ```bash 413 | sudo pip3 install --upgrade python-mpv 414 | sudo apt install autoconf automake libtool libharfbuzz-dev libfreetype6-dev libfontconfig1-dev libx11-dev libxrandr-dev libvdpau-dev libva-dev mesa-common-dev libegl1-mesa-dev yasm libasound2-dev libpulse-dev libuchardet-dev zlib1g-dev libfribidi-dev git libgnutls28-dev libgl1-mesa-dev libsdl2-dev cmake wget python g++ libluajit-5.1-dev 415 | git clone https://github.com/mpv-player/mpv-build.git 416 | cd mpv-build 417 | echo --enable-libmpv-shared > mpv_options 418 | ./rebuild -j4 419 | sudo ./install 420 | sudo ldconfig 421 | ``` 422 | 423 | ## OSX Installation 424 | Currently on OSX only the external MPV backend seems to be working. I cannot test on OSX, so please report any issues you find. 425 | 426 | To install the CLI version: 427 | 428 | 1. Install brew. ([Instructions](https://brew.sh/)) 429 | 2. Install python3 and mpv. `brew install python mpv` 430 | 3. Install plex-mpv-shim. `pip3 install --upgrade plex-mpv-shim` 431 | 4. Run `plex-mpv-shim`. 432 | 433 | If you'd like to install the GUI version, you need a working copy of tkinter. 434 | 435 | 1. Install pyenv. ([Instructions](https://medium.com/python-every-day/python-development-on-macos-with-pyenv-2509c694a808)) 436 | 2. Install TK and mpv. `brew install tcl-tk mpv` 437 | 3. Install python3 with TK support. `FLAGS="-I$(brew --prefix tcl-tk)/include" pyenv install 3.8.1` 438 | 4. Set this python3 as the default. `pyenv global 3.8.1` 439 | 5. Install plex-mpv-shim and pystray. `pip3 install --upgrade plex-mpv-shim pystray` 440 | 6. Run `plex-mpv-shim`. 441 | 442 | ## Building on Windows 443 | 444 | There is a prebuilt version for Windows in the releases section. When 445 | following these directions, please take care to ensure both the python 446 | and libmpv libraries are either 64 or 32 bit. (Don't mismatch them.) 447 | 448 | 1. Install [Python3](https://www.python.org/downloads/) with PATH enabled. Install [7zip](https://ninite.com/7zip/). 449 | 2. After installing python3, open `cmd` as admin and run `pip install --upgrade pyinstaller python-mpv requests pywin32 pystray python-mpv-jsonipc`. 450 | 3. Download [libmpv](https://sourceforge.net/projects/mpv-player-windows/files/libmpv/). 451 | 4. Extract the `mpv-1.dll` from the file and move it to the `plex-mpv-shim` folder. 452 | 5. Open a regular `cmd` prompt. Navigate to the `plex-mpv-shim` folder. 453 | 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`. 454 | 7. Run `pyinstaller -wF --add-binary "mpv-1.dll;." --add-data "plex_mpv_shim\default_shader_pack;plex_mpv_shim\default_shader_pack" --add-binary "plex_mpv_shim\systray.png;." --icon media.ico run.py --hidden-import pystray._win32`. 455 | -------------------------------------------------------------------------------- /build-debug.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | git pull 3 | rd /s /q __pycache__ dist build 4 | pyinstaller -cF --add-binary "mpv-2.dll;." --add-binary "plex_mpv_shim\systray.png;." --add-data "plex_mpv_shim\mouse.lua;plex_mpv_shim" --icon media.ico run.py --hidden-import pystray._win32 5 | -------------------------------------------------------------------------------- /build.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | git pull 3 | rd /s /q __pycache__ dist build 4 | pyinstaller -w --add-binary "mpv-2.dll;." --add-binary "plex_mpv_shim\systray.png;." --add-data "plex_mpv_shim\mouse.lua;plex_mpv_shim" --add-data "plex_mpv_shim\default_shader_pack;plex_mpv_shim\default_shader_pack" --hidden-import pystray._win32 --icon media.ico run.py 5 | if %errorlevel% neq 0 exit /b %errorlevel% 6 | del dist\run\run.exe.manifest 7 | copy hidpi.manifest dist\run\run.exe.manifest 8 | "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" "Plex MPV Shim.iss" 9 | if %errorlevel% neq 0 exit /b %errorlevel% -------------------------------------------------------------------------------- /gen_pkg.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # This script: 3 | # - Download/updates default-shader-pack 4 | 5 | cd "$(dirname "$0")" 6 | 7 | function download_compat { 8 | if [[ "$AZ_CACHE" != "" ]] 9 | then 10 | download_id=$(echo "$2" | md5sum | sed 's/ .*//g') 11 | if [[ -e "$AZ_CACHE/$3/$download_id" ]] 12 | then 13 | echo "Cache hit: $AZ_CACHE/$3/$download_id" 14 | cp "$AZ_CACHE/$3/$download_id" "$1" 15 | return 16 | elif [[ "$3" != "" ]] 17 | then 18 | rm -r "$AZ_CACHE/$3" 2> /dev/null 19 | fi 20 | fi 21 | if [[ "$(which wget 2>/dev/null)" != "" ]] 22 | then 23 | wget -qO "$1" "$2" 24 | else [[ "$(which curl)" != "" ]] 25 | curl -sL "$2" > "$1" 26 | fi 27 | if [[ "$AZ_CACHE" != "" ]] 28 | then 29 | echo "Saving to: $AZ_CACHE/$3/$download_id" 30 | mkdir -p "$AZ_CACHE/$3/" 31 | cp "$1" "$AZ_CACHE/$3/$download_id" 32 | fi 33 | } 34 | 35 | function get_resource_version { 36 | curl -s --head https://github.com/"$1"/releases/latest | \ 37 | grep -i '^location: ' | sed 's/.*tag\///g' | tr -d '\r' 38 | } 39 | 40 | if [[ "$1" == "--get-pyinstaller" ]] 41 | then 42 | echo "Downloading pyinstaller..." 43 | pi_version=$(get_resource_version pyinstaller/pyinstaller) 44 | download_compat release.zip "https://github.com/pyinstaller/pyinstaller/archive/$pi_version.zip" "pi" 45 | ( 46 | mkdir pyinstaller 47 | cd pyinstaller 48 | unzip ../release.zip > /dev/null && rm ../release.zip 49 | mv pyinstaller-*/* ./ 50 | rm -r pyinstaller-* 51 | ) 52 | exit 0 53 | elif [[ "$1" == "--gen-fingerprint" ]] 54 | then 55 | ( 56 | get_resource_version pyinstaller/pyinstaller 57 | get_resource_version iwalton3/default-shader-pack 58 | ) | tee az-cache-fingerprint.list 59 | exit 0 60 | fi 61 | 62 | # Download default-shader-pack 63 | update_shader_pack="no" 64 | if [[ ! -e "plex_mpv_shim/default_shader_pack" ]] 65 | then 66 | update_shader_pack="yes" 67 | elif [[ -e ".last_sp_version" ]] 68 | then 69 | if [[ "$(get_resource_version iwalton3/default-shader-pack)" != "$(cat .last_sp_version)" ]] 70 | then 71 | update_shader_pack="yes" 72 | fi 73 | fi 74 | 75 | if [[ "$update_shader_pack" == "yes" ]] 76 | then 77 | echo "Downloading shaders..." 78 | sp_version=$(get_resource_version iwalton3/default-shader-pack) 79 | download_compat release.zip "https://github.com/iwalton3/default-shader-pack/archive/$sp_version.zip" "sp" 80 | rm -r plex_mpv_shim/default_shader_pack 2> /dev/null 81 | ( 82 | mkdir default_shader_pack 83 | cd default_shader_pack 84 | unzip ../release.zip > /dev/null && rm ../release.zip 85 | mv default-shader-pack-*/* ./ 86 | rm -r default-shader-pack-* 87 | ) 88 | mv default_shader_pack plex_mpv_shim/ 89 | echo "$sp_version" > .last_sp_version 90 | fi 91 | 92 | # Generate package 93 | if [[ "$1" == "--install" ]] 94 | then 95 | if [[ "$(which sudo 2> /dev/null)" != "" && ! "$*" =~ "--local" ]] 96 | then 97 | sudo pip3 install .[all] 98 | else 99 | pip3 install .[all] 100 | fi 101 | 102 | elif [[ "$1" != "--skip-build" ]] 103 | then 104 | rm -r build/ dist/ .eggs 2> /dev/null 105 | mkdir build/ dist/ 106 | echo "Building release package." 107 | python3 setup.py sdist bdist_wheel > /dev/null 108 | fi 109 | 110 | -------------------------------------------------------------------------------- /hidpi.manifest: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | PerMonitorV2 21 | 22 | 23 | -------------------------------------------------------------------------------- /media.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iwalton3/plex-mpv-shim/fb1f1f3325285e33f9ce3425e9361f5f99277d9a/media.ico -------------------------------------------------------------------------------- /plex_mpv_shim/__init__.py: -------------------------------------------------------------------------------- 1 | # empty file 2 | -------------------------------------------------------------------------------- /plex_mpv_shim/action_thread.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import threading 3 | 4 | from .player import playerManager 5 | 6 | class ActionThread(threading.Thread): 7 | def __init__(self): 8 | self.trigger = threading.Event() 9 | self.halt = False 10 | 11 | threading.Thread.__init__(self) 12 | 13 | def stop(self): 14 | self.halt = True 15 | self.join() 16 | 17 | def run(self): 18 | force_next = False 19 | while not self.halt: 20 | if (playerManager._player and playerManager._media_item) or force_next: 21 | playerManager.update() 22 | 23 | force_next = False 24 | if self.trigger.wait(1): 25 | force_next = True 26 | self.trigger.clear() 27 | 28 | actionThread = ActionThread() 29 | 30 | -------------------------------------------------------------------------------- /plex_mpv_shim/bulk_subtitle.py: -------------------------------------------------------------------------------- 1 | from .media import XMLCollection 2 | from .utils import get_plex_url 3 | 4 | from collections import namedtuple 5 | import urllib.parse 6 | import requests 7 | import time 8 | import logging 9 | 10 | Part = namedtuple("Part", ["id", "audio", "subtitle"]) 11 | Audio = namedtuple("Audio", ["id", "language_code", "name", "plex_name"]) 12 | Subtitle = namedtuple("Subtitle", ["id", "language_code", "name", "is_forced", "plex_name"]) 13 | 14 | log = logging.getLogger('bulk_subtitle') 15 | messages = [] 16 | keep_messages = 6 17 | 18 | def render_message(message, show_text): 19 | log.info(message) 20 | messages.append(message) 21 | text = "Selecting Tracks..." 22 | for message in messages[-6:]: 23 | text += "\n " + message 24 | show_text(text,2**30,1) 25 | 26 | def process_series(mode, url, player, m_raid=None, m_rsid=None): 27 | messages.clear() 28 | show_text = player._player.show_text 29 | c_aid, c_sid = None, None 30 | c_pid = player._media_item._part_node.get("id") 31 | 32 | success_ct = 0 33 | partial_ct = 0 34 | count = 0 35 | 36 | xml = XMLCollection(url) 37 | for video in xml.tree.findall("./Video"): 38 | name = "s{0}e{1:02}".format(int(video.get("parentIndex")), int(video.get("index"))) 39 | video = XMLCollection(xml.get_path(video.get("key"))).tree.find("./") 40 | for partxml in video.findall("./Media/Part"): 41 | count += 1 42 | audio_list = [Audio(s.get("id"), s.get("languageCode"), s.get("title"), 43 | s.get("displayTitle")) for s in partxml.findall("./Stream[@streamType='2']")] 44 | subtitle_list = [Subtitle(s.get("id"), s.get("languageCode"), s.get("title"), 45 | "Forced" in s.get("displayTitle"), s.get("displayTitle")) 46 | for s in partxml.findall("./Stream[@streamType='3']")] 47 | part = Part(partxml.get("id"), audio_list, subtitle_list) 48 | 49 | aid = None 50 | sid = "0" 51 | if mode == "subbed": 52 | audio, subtitle = get_subbed(part) 53 | if audio and subtitle: 54 | render_message("{0}: {1} ({2})".format( 55 | name, subtitle.plex_name, subtitle.name), show_text) 56 | aid, sid = audio.id, subtitle.id 57 | success_ct += 1 58 | elif mode == "dubbed": 59 | audio, subtitle = get_dubbed(part) 60 | if audio and subtitle: 61 | render_message("{0}: {1} ({2})".format( 62 | name, subtitle.plex_name, subtitle.name), show_text) 63 | aid, sid = audio.id, subtitle.id 64 | success_ct += 1 65 | elif audio: 66 | render_message("{0}: No Subtitles".format(name), show_text) 67 | aid = audio.id 68 | partial_ct += 1 69 | elif mode == "manual": 70 | if m_raid < len(part.audio) and m_rsid < len(part.subtitle): 71 | audio = part.audio[m_raid] 72 | aid = audio.id 73 | render_message("{0} a: {1} ({2})".format( 74 | name, audio.plex_name, audio.name), show_text) 75 | if m_rsid != -1: 76 | subtitle = part.subtitle[m_rsid] 77 | sid = subtitle.id 78 | render_message("{0} s: {1} ({2})".format( 79 | name, subtitle.plex_name, subtitle.name), show_text) 80 | success_ct += 1 81 | 82 | if aid: 83 | if c_pid == part.id: 84 | c_aid, c_sid = aid, sid 85 | 86 | args = { 87 | "allParts": "1", 88 | "audioStreamID": aid, 89 | "subtitleStreamID": sid 90 | } 91 | url = "/library/parts/{0}".format(part.id) 92 | requests.put(get_plex_url(urllib.parse.urljoin(xml.server_url, url), args), data=None) 93 | else: 94 | render_message("{0}: Fail".format(name), show_text) 95 | 96 | if mode == "subbed": 97 | render_message("Set Subbed: {0} ok, {1} fail".format( 98 | success_ct, count-success_ct), show_text) 99 | elif mode == "dubbed": 100 | render_message("Set Dubbed: {0} ok, {1} audio only, {2} fail".format( 101 | success_ct, partial_ct, count-success_ct-partial_ct), show_text) 102 | elif mode == "manual": 103 | render_message("Manual: {0} ok, {1} fail".format( 104 | success_ct, count-success_ct), show_text) 105 | time.sleep(3) 106 | if c_aid: 107 | render_message("Setting Current...", show_text) 108 | if player._media_item.is_transcode: 109 | player.put_task(player.set_streams, c_aid, c_sid) 110 | player.timeline_handle() 111 | else: 112 | player.set_streams(c_aid, c_sid) 113 | 114 | def get_subbed(part): 115 | japanese_audio = None 116 | english_subtitles = None 117 | subtitle_weight = None 118 | 119 | for audio in part.audio: 120 | lower_title = audio.name.lower() if audio.name is not None else "" 121 | if audio.language_code != "jpn" and not "japan" in lower_title: 122 | continue 123 | if "commentary" in lower_title: 124 | continue 125 | 126 | if japanese_audio is None: 127 | japanese_audio = audio 128 | break 129 | 130 | for subtitle in part.subtitle: 131 | lower_title = subtitle.name.lower() if subtitle.name is not None else "" 132 | if subtitle.language_code != "eng" and not "english" in lower_title: 133 | continue 134 | if subtitle.is_forced: 135 | continue 136 | 137 | weight = dialogue_weight(lower_title) 138 | if subtitle_weight is None or weight < subtitle_weight: 139 | subtitle_weight = weight 140 | english_subtitles = subtitle 141 | 142 | if japanese_audio and english_subtitles: 143 | return japanese_audio, english_subtitles 144 | return None, None 145 | 146 | def get_dubbed(part): 147 | english_audio = None 148 | sign_subtitles = None 149 | subtitle_weight = None 150 | 151 | for audio in part.audio: 152 | lower_title = audio.name.lower() if audio.name is not None else "" 153 | if audio.language_code != "eng" and not "english" in lower_title: 154 | continue 155 | if "commentary" in lower_title: 156 | continue 157 | 158 | if english_audio is None: 159 | english_audio = audio 160 | break 161 | 162 | for subtitle in part.subtitle: 163 | lower_title = subtitle.name.lower() if subtitle.name is not None else "" 164 | if subtitle.language_code != "eng" and not "english" in lower_title: 165 | continue 166 | if subtitle.is_forced: 167 | sign_subtitles = subtitle 168 | break 169 | 170 | weight = sign_weight(lower_title) 171 | if weight == 0: 172 | continue 173 | 174 | if subtitle_weight is None or weight < subtitle_weight: 175 | subtitle_weight = weight 176 | sign_subtitles = subtitle 177 | 178 | if english_audio: 179 | return english_audio, sign_subtitles 180 | return None, None 181 | 182 | def dialogue_weight(text): 183 | if not text: 184 | return 900 185 | lower_text = text.lower() 186 | has_dialogue = "main" in lower_text or "full" in lower_text or "dialogue" in lower_text 187 | has_songs = "op/ed" in lower_text or "song" in lower_text or "lyric" in lower_text 188 | has_signs = "sign" in lower_text 189 | vendor = "bd" in lower_text or "retail" in lower_text 190 | weight = 900 191 | 192 | if has_dialogue and has_songs: 193 | weight -= 100 194 | if has_songs: 195 | weight += 200 196 | if has_dialogue and has_signs: 197 | weight -= 100 198 | elif has_signs: 199 | weight += 700 200 | if vendor: 201 | weight += 50 202 | return weight 203 | 204 | def sign_weight(text): 205 | if not text: 206 | return 0 207 | lower_text = text.lower() 208 | has_songs = "op/ed" in lower_text or "song" in lower_text or "lyric" in lower_text 209 | has_signs = "sign" in lower_text 210 | vendor = "bd" in lower_text or "retail" in lower_text 211 | weight = 900 212 | 213 | if not (has_songs or has_signs): 214 | return 0 215 | if has_songs: 216 | weight -= 200 217 | if has_signs: 218 | weight -= 300 219 | if vendor: 220 | weight += 50 221 | return weight 222 | -------------------------------------------------------------------------------- /plex_mpv_shim/cli_mgr.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | class UserInterface(object): 4 | def __init__(self): 5 | self.open_player_menu = lambda: None 6 | self.stop = lambda: None 7 | 8 | def run(self): 9 | while True: 10 | time.sleep(1) 11 | 12 | userInterface = UserInterface() 13 | -------------------------------------------------------------------------------- /plex_mpv_shim/client.py: -------------------------------------------------------------------------------- 1 | import certifi 2 | import datetime 3 | import json 4 | import logging 5 | import os 6 | import posixpath 7 | import threading 8 | import urllib.request, urllib.parse, urllib.error 9 | import urllib.parse 10 | import socket 11 | from time import mktime 12 | from email.utils import formatdate 13 | 14 | from http.server import HTTPServer 15 | from http.server import SimpleHTTPRequestHandler 16 | from socketserver import ThreadingMixIn 17 | from .media import MediaType 18 | from .utils import upd_token, sanitize_msg, plex_color_to_mpv 19 | from .conf import settings 20 | 21 | try: 22 | from xml.etree import cElementTree as et 23 | except: 24 | from xml.etree import ElementTree as et 25 | 26 | from io import BytesIO 27 | 28 | from .conf import settings 29 | from .media import Media 30 | from .player import playerManager 31 | from .subscribers import remoteSubscriberManager, RemoteSubscriber 32 | from .timeline import timelineManager 33 | 34 | log = logging.getLogger("client") 35 | 36 | STATIC_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'static') 37 | 38 | NAVIGATION_DICT = { 39 | "/player/navigation/moveDown": "down", 40 | "/player/navigation/moveUp": "up", 41 | "/player/navigation/select": "ok", 42 | "/player/navigation/moveLeft": "left", 43 | "/player/navigation/moveRight": "right", 44 | "/player/navigation/home": "home", 45 | "/player/navigation/back": "back" 46 | } 47 | 48 | class HttpHandler(SimpleHTTPRequestHandler): 49 | xmlOutput = None 50 | completed = False 51 | 52 | handlers = ( 53 | (("/resources",), "resources"), 54 | (("/player/playback/playMedia", 55 | "/player/application/playMedia",), "playMedia"), 56 | (("/player/playback/stepForward", 57 | "/player/playback/stepBack",), "stepFunction"), 58 | (("/player/playback/skipNext",), "skipNext"), 59 | (("/player/playback/skipPrevious",), "skipPrevious"), 60 | (("/player/playback/stop",), "stop"), 61 | (("/player/playback/seekTo",), "seekTo"), 62 | (("/player/playback/skipTo",), "skipTo"), 63 | (("/player/playback/setParameters",), "set"), 64 | (("/player/playback/setStreams",), "setStreams"), 65 | (("/player/playback/pause", 66 | "/player/playback/play",), "pausePlay"), 67 | (("/player/timeline/subscribe",), "subscribe"), 68 | (("/player/timeline/unsubscribe",), "unsubscribe"), 69 | (("/player/timeline/poll",), "poll"), 70 | (("/player/application/setText", 71 | "/player/application/sendString",), "sendString"), 72 | (("/player/application/sendVirtualKey", 73 | "/player/application/sendKey",), "sendVKey"), 74 | (("/player/playback/bigStepForward", 75 | "/player/playback/bigStepBack",), "stepFunction"), 76 | (("/player/playback/refreshPlayQueue",),"refreshPlayQueue"), 77 | (("/player/mirror/details",), "mirror"), 78 | ) 79 | 80 | def log_request(self, *args, **kwargs): 81 | pass 82 | 83 | def setStandardResponse(self, code=200, status="OK"): 84 | el = et.Element("Response") 85 | el.set("code", str(code)) 86 | el.set("status", str(status)) 87 | 88 | if self.xmlOutput: 89 | self.xmlOutput.append(el) 90 | else: 91 | self.xmlOutput = el 92 | 93 | def getSubFromRequest(self, arguments): 94 | uuid = self.headers.get("X-Plex-Client-Identifier", None) 95 | name = self.headers.get("X-Plex-Device-Name", None) 96 | if not name: 97 | name = arguments.get("X-Plex-Device-Name") 98 | 99 | if not uuid: 100 | log.warning("HttpHandler::getSubFromRequest subscriber didn't set X-Plex-Client-Identifier") 101 | self.setStandardResponse(500, "subscriber didn't set X-Plex-Client-Identifier") 102 | return 103 | 104 | if not name: 105 | log.warning("HttpHandler::getSubFromRequest subscriber didn't set X-Plex-Device-Name") 106 | self.setStandardResponse(500, "subscriber didn't set X-Plex-Device-Name") 107 | return 108 | 109 | port = int(arguments.get("port", 32400)) 110 | commandID = int(arguments.get("commandID", -1)) 111 | protocol = arguments.get("protocol", "http") 112 | ipaddress = self.client_address[0] 113 | 114 | return RemoteSubscriber(uuid, commandID, ipaddress, port, protocol, name) 115 | 116 | def get_querydict(self, query): 117 | querydict = {} 118 | for key, value in urllib.parse.parse_qsl(query): 119 | querydict[key] = value 120 | return querydict 121 | 122 | def updateCommandID(self, arguments): 123 | if "commandID" not in arguments: 124 | if self.path.find("unsubscribe") < 0: 125 | log.warning("HttpHandler::updateCommandID no commandID sent to this request!") 126 | return 127 | 128 | commandID = -1 129 | try: 130 | commandID = int(arguments["commandID"]) 131 | except: 132 | log.error("HttpHandler::updateCommandID invalid commandID: %s" % arguments["commandID"]) 133 | return 134 | 135 | uuid = self.headers.get("X-Plex-Client-Identifier", None) 136 | if not uuid: 137 | log.warning("HttpHandler::updateCommandID subscriber didn't set X-Plex-Client-Identifier") 138 | self.setStandardResponse(500, "When commandID is set you also need to specify X-Plex-Client-Identifier") 139 | return 140 | 141 | sub = remoteSubscriberManager.findSubscriberByUUID(uuid) 142 | if sub: 143 | sub.commandID = commandID 144 | 145 | def handle_request(self, method): 146 | if 'X-Plex-Device-Name' in self.headers: 147 | log.debug("HttpHandler::handle_request request from '%s' to '%s'" % (self.headers["X-Plex-Device-Name"], sanitize_msg(self.path))) 148 | else: 149 | log.debug("HttpHandler::handle_request request to '%s'" % sanitize_msg(self.path)) 150 | 151 | path = urllib.parse.urlparse(self.path) 152 | query = self.get_querydict(path.query) 153 | 154 | if method == "OPTIONS" and "Access-Control-Request-Method" in self.headers: 155 | self.send_response(200) 156 | self.send_header("Content-Type", "text/plain") 157 | self.send_header("Access-Control-Allow-Methods", "POST, GET, OPTIONS, DELETE, PUT, HEAD") 158 | self.send_header("Access-Control-Max-Age", "1209600") 159 | self.send_header("Connection", "close") 160 | 161 | if "Access-Control-Request-Headers" in self.headers: 162 | self.send_header("Access-Control-Allow-Headers", self.headers["Access-Control-Request-Headers"]) 163 | 164 | self.end_headers() 165 | self.wfile.flush() 166 | 167 | return 168 | 169 | self.setStandardResponse() 170 | 171 | self.updateCommandID(query) 172 | 173 | match = False 174 | for paths, handler in self.handlers: 175 | if path.path in paths: 176 | match = True 177 | getattr(self, handler)(path, query) 178 | break 179 | 180 | if not match: 181 | if path.path.startswith("/player/navigation"): 182 | self.navigation(path, query) 183 | else: 184 | self.setStandardResponse(500, "Nope, not implemented, sorry!") 185 | 186 | self.send_end() 187 | 188 | def translate_path(self, path): 189 | path = path.split('?',1)[0] 190 | path = path.split('#',1)[0] 191 | path = posixpath.normpath(urllib.parse.unquote(path)) 192 | return os.path.join(STATIC_DIR, path.lstrip("/")) 193 | 194 | 195 | def do_OPTIONS(self): 196 | self.handle_request("OPTIONS") 197 | 198 | def do_GET(self): 199 | self.handle_request("GET") 200 | 201 | def send_end(self): 202 | if self.completed: 203 | return 204 | 205 | response = BytesIO() 206 | tree = et.ElementTree(self.xmlOutput) 207 | tree.write(response, encoding="utf-8", xml_declaration=True) 208 | response.seek(0) 209 | 210 | xmlData = response.read() 211 | 212 | self.send_response(200) 213 | 214 | self.send_header("Access-Control-Allow-Origin", "*") 215 | self.send_header("Access-Control-Expose-Headers", "X-Plex-Client-Identifier") 216 | self.send_header("X-Plex-Client-Identifier", settings.client_uuid) 217 | self.send_header("Content-type", "text/xml") 218 | 219 | # https://stackoverflow.com/questions/225086/ 220 | now = datetime.datetime.now() 221 | stamp = mktime(now.timetuple()) 222 | self.send_header("Date", formatdate( 223 | timeval=stamp, 224 | localtime=False, 225 | usegmt=True 226 | )) 227 | 228 | self.send_header("Content-Length", str(len(xmlData))) 229 | 230 | self.end_headers() 231 | 232 | self.wfile.write(xmlData) 233 | self.wfile.flush() 234 | 235 | self.completed = True 236 | 237 | #-------------------------------------------------------------------------- 238 | # URL Handlers 239 | #-------------------------------------------------------------------------- 240 | def subscribe(self, path, arguments): 241 | sub = self.getSubFromRequest(arguments) 242 | if sub: 243 | remoteSubscriberManager.addSubscriber(sub) 244 | 245 | self.send_end() 246 | 247 | timelineManager.SendTimelineToSubscriber(sub) 248 | 249 | def unsubscribe(self, path, arguments): 250 | remoteSubscriberManager.removeSubscriber(self.getSubFromRequest(arguments)) 251 | 252 | def poll(self, path, arguments): 253 | uuid = self.headers.get("X-Plex-Client-Identifier", None) 254 | name = self.headers.get("X-Plex-Device-Name", "") 255 | 256 | commandID = -1 257 | try: 258 | commandID = int(arguments.get("commandID", -1)) 259 | except: 260 | pass 261 | 262 | if commandID == -1 or not uuid: 263 | log.warning("HttpHandler::poll the poller needs to set both X-Plex-Client-Identifier header and commandID arguments.") 264 | self.setStandardResponse(500, "You need to specify both x-Plex-Client-Identifier as a header and commandID as a argument") 265 | return 266 | 267 | pollSubscriber = RemoteSubscriber(uuid, commandID, name=name) 268 | remoteSubscriberManager.addSubscriber(pollSubscriber) 269 | 270 | if "wait" in arguments and arguments["wait"] in ("1", "true"): 271 | self.xmlOutput = timelineManager.WaitForTimeline(pollSubscriber) 272 | else: 273 | self.xmlOutput = timelineManager.GetCurrentTimeLinesXML(pollSubscriber) 274 | 275 | def resources(self, path, arguments): 276 | mediaContainer = et.Element("MediaContainer") 277 | player = et.Element("Player") 278 | 279 | capabilities = "timeline,playback,navigation" 280 | if settings.enable_play_queue: 281 | capabilities = "timeline,playback,navigation,playqueues" 282 | 283 | info = (("deviceClass", "pc"), 284 | ("machineIdentifier", settings.client_uuid), 285 | ("product", "Plex MPV Shim"), 286 | ("protocolCapabilities", capabilities), 287 | ("protocolVersion", "1"), 288 | ("title", settings.player_name), 289 | ("version", "1.0")) 290 | 291 | for key, value in info: 292 | player.set(key, value) 293 | 294 | mediaContainer.append(player) 295 | self.xmlOutput = mediaContainer 296 | 297 | def playMedia(self, path, arguments): 298 | address = arguments.get("address", None) 299 | protocol = arguments.get("protocol", "http") 300 | port = arguments.get("port", "32400") 301 | key = arguments.get("key", None) 302 | offset = int(int(arguments.get("offset", 0))/1e3) 303 | url = urllib.parse.urljoin("%s://%s:%s" % (protocol, address, port), key) 304 | playQueue = arguments.get("containerKey", None) 305 | mediaType = arguments.get("type", "video") 306 | 307 | if mediaType == "video": 308 | parsed_media_type = MediaType.VIDEO 309 | elif mediaType == "music": 310 | parsed_media_type = MediaType.MUSIC 311 | 312 | token = arguments.get("token", None) 313 | if token: 314 | upd_token(address, token) 315 | 316 | if settings.enable_play_queue and playQueue.startswith("/playQueue"): 317 | media = Media(url, media_type=parsed_media_type, play_queue=playQueue) 318 | else: 319 | media = Media(url, media_type=parsed_media_type) 320 | 321 | log.debug("HttpHandler::playMedia %s" % media) 322 | 323 | # TODO: Select video, media and part here based off user settings 324 | media_item = media.get_media_item(0) 325 | if media_item: 326 | if settings.pre_media_cmd: 327 | os.system(settings.pre_media_cmd) 328 | playerManager.play(media_item, offset) 329 | timelineManager.SendTimelineToSubscribers() 330 | 331 | def stop(self, path, arguments): 332 | playerManager.stop() 333 | timelineManager.SendTimelineToSubscribers() 334 | 335 | def pausePlay(self, path, arguments): 336 | playerManager.toggle_pause() 337 | timelineManager.SendTimelineToSubscribers() 338 | 339 | def skipNext(self, path, arguments): 340 | playerManager.play_next() 341 | 342 | def skipPrevious(self, path, arguments): 343 | playerManager.play_prev() 344 | 345 | def stepFunction(self, path, arguments): 346 | log.info("HttpHandler::stepFunction not implemented yet") 347 | 348 | def seekTo(self, path, arguments): 349 | offset = int(int(arguments.get("offset", 0))*1e-3) 350 | log.debug("HttpHandler::seekTo offset %ss" % offset) 351 | playerManager.seek(offset) 352 | 353 | def skipTo(self, path, arguments): 354 | playerManager.skip_to(arguments["key"]) 355 | 356 | def set(self, path, arguments): 357 | if "volume" in arguments: 358 | volume = arguments["volume"] 359 | log.debug("HttpHandler::set settings volume to %s" % volume) 360 | playerManager.set_volume(int(volume)) 361 | if "autoPlay" in arguments: 362 | settings.auto_play = arguments["autoPlay"] == "1" 363 | settings.save() 364 | subtitle_settings_upd = False 365 | if "subtitleSize" in arguments: 366 | subtitle_settings_upd = True 367 | settings.subtitle_size = int(arguments["subtitleSize"]) 368 | if "subtitlePosition" in arguments: 369 | subtitle_settings_upd = True 370 | settings.subtitle_position = arguments["subtitlePosition"] 371 | if "subtitleColor" in arguments: 372 | subtitle_settings_upd = True 373 | settings.subtitle_color = plex_color_to_mpv(arguments["subtitleColor"]) 374 | if subtitle_settings_upd: 375 | settings.save() 376 | playerManager.update_subtitle_visuals() 377 | 378 | def setStreams(self, path, arguments): 379 | audioStreamID = None 380 | subtitleStreamID = None 381 | if "audioStreamID" in arguments: 382 | audioStreamID = arguments["audioStreamID"] 383 | if "subtitleStreamID" in arguments: 384 | subtitleStreamID = arguments["subtitleStreamID"] 385 | playerManager.set_streams(audioStreamID, subtitleStreamID) 386 | 387 | def refreshPlayQueue(self, path, arguments): 388 | playerManager._media_item.parent.upd_play_queue() 389 | playerManager.upd_player_hide() 390 | timelineManager.SendTimelineToSubscribers() 391 | 392 | def mirror(self, path, arguments): 393 | timelineManager.delay_idle() 394 | 395 | def navigation(self, path, arguments): 396 | path = path.path 397 | if path in NAVIGATION_DICT: 398 | playerManager.menu.menu_action(NAVIGATION_DICT[path]) 399 | 400 | class HttpSocketServer(ThreadingMixIn, HTTPServer): 401 | allow_reuse_address = True 402 | 403 | class HttpServer(threading.Thread): 404 | def __init__(self, port): 405 | super(HttpServer, self).__init__(name="HTTP Server") 406 | self.port = port 407 | self.sock = None 408 | self.addr = ('', port) 409 | 410 | def run(self): 411 | log.info("Started HTTP server") 412 | self.sock = socket.socket (socket.AF_INET, socket.SOCK_STREAM) 413 | self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 414 | self.sock.bind(self.addr) 415 | self.sock.listen(5) 416 | 417 | self.servers = [HttpServerThread(i, self.sock, self.addr) for i in range(5)] 418 | 419 | def stop(self): 420 | log.info("Stopping HTTP server...") 421 | 422 | # Note: All of the server threads will die after the socket closes. 423 | # Attempting to stop a waiting thread prior will block indefinitely. 424 | self.sock.close() 425 | 426 | # Adapted from https://stackoverflow.com/a/46224191 427 | class HttpServerThread(threading.Thread): 428 | def __init__(self, i, sock, addr): 429 | super(HttpServerThread, self).__init__(name="HTTP Server %s" % i) 430 | 431 | self.i = i 432 | self.daemon = True 433 | self.server = None 434 | self.addr = addr 435 | self.sock = sock 436 | 437 | self.start() 438 | 439 | def run(self): 440 | self.server = HttpSocketServer(self.addr, HttpHandler, False) 441 | self.server.socket = self.sock 442 | self.server.server_bind = self.server_close = lambda self: None 443 | self.server.serve_forever() 444 | 445 | 446 | -------------------------------------------------------------------------------- /plex_mpv_shim/conf.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import uuid 4 | import pickle as pickle 5 | import socket 6 | import json 7 | import os.path 8 | import sys 9 | 10 | log = logging.getLogger('conf') 11 | 12 | class Settings(object): 13 | _listeners = [] 14 | 15 | _path = None 16 | _data = { 17 | "player_name": socket.gethostname(), 18 | "http_port": "3000", 19 | "audio_ac3passthrough": False, 20 | "audio_dtspassthrough": False, 21 | "client_uuid": str(uuid.uuid4()), 22 | "enable_play_queue": True, 23 | "allow_http": False, 24 | "media_ended_cmd": None, 25 | "pre_media_cmd": None, 26 | "stop_cmd": None, 27 | "auto_play": True, 28 | "idle_cmd": None, 29 | "idle_cmd_delay": 60, 30 | "always_transcode": False, 31 | "auto_transcode": True, 32 | "adaptive_transcode": False, 33 | "direct_limit": False, 34 | "transcode_kbps": 2000, 35 | "client_profile": "Plex Home Theater", 36 | "sanitize_output": True, 37 | "subtitle_size": 100, 38 | "subtitle_color": "#FFFFFFFF", 39 | "subtitle_position": "bottom", 40 | "fullscreen": True, 41 | "enable_gui": True, 42 | "media_key_seek": False, 43 | "mpv_ext": sys.platform.startswith("darwin"), 44 | "mpv_ext_path": None, 45 | "mpv_ext_ipc": None, 46 | "mpv_ext_start": True, 47 | "mpv_ext_no_ovr": False, 48 | "enable_osc": True, 49 | "log_decisions": False, 50 | "mpv_log_level": "info", 51 | "idle_when_paused": False, 52 | "stop_idle": False, 53 | "kb_stop": "q", 54 | "kb_prev": "<", 55 | "kb_next": ">", 56 | "kb_watched": "w", 57 | "kb_unwatched": "u", 58 | "kb_menu": "c", 59 | "kb_menu_esc": "esc", 60 | "kb_menu_ok": "enter", 61 | "kb_menu_left": "left", 62 | "kb_menu_right": "right", 63 | "kb_menu_up": "up", 64 | "kb_menu_down": "down", 65 | "kb_pause": "space", 66 | "kb_debug": "~", 67 | "seek_up": 60, 68 | "seek_down": -60, 69 | "seek_right": 5, 70 | "seek_left": -5, 71 | "skip_intro_always": False, 72 | "skip_intro_prompt": True, 73 | "skip_credits_always": False, 74 | "skip_credits_prompt": True, 75 | "shader_pack_enable": True, 76 | "shader_pack_custom": False, 77 | "shader_pack_remember": True, 78 | "shader_pack_profile": None, 79 | "svp_enable": False, 80 | "svp_url": "http://127.0.0.1:9901/", 81 | "svp_socket": None, 82 | "shader_pack_subtype": "lq", 83 | "menu_mouse": True, 84 | } 85 | 86 | def __getattr__(self, name): 87 | return self._data[name] 88 | 89 | def __setattr__(self, name, value): 90 | if name in self._data: 91 | self._data[name] = value 92 | self.save() 93 | 94 | for callback in self._listeners: 95 | try: 96 | callback(name, value) 97 | except: 98 | pass 99 | else: 100 | super(Settings, self).__setattr__(name, value) 101 | 102 | def __get_file(self, path, mode="r", create=True): 103 | created = False 104 | 105 | if not os.path.exists(path): 106 | try: 107 | fh = open(path, mode) 108 | except IOError as e: 109 | if e.errno == 2 and create: 110 | fh = open(path, 'w') 111 | json.dump(self._data, fh, indent=4, sort_keys=True) 112 | fh.close() 113 | created = True 114 | else: 115 | raise e 116 | except Exception as e: 117 | log.error("Error opening settings from path: %s" % path) 118 | return None 119 | 120 | # This should work now 121 | return open(path, mode), created 122 | 123 | def migrate_config(self, old_path, new_path): 124 | fh, created = self.__get_file(old_path, "rb+", False) 125 | if not created: 126 | try: 127 | data = pickle.load(fh) 128 | self._data.update(data) 129 | except Exception as e: 130 | log.error("Error loading settings from pickle: %s" % e) 131 | fh.close() 132 | return False 133 | 134 | os.remove(old_path) 135 | self._path = new_path 136 | fh.close() 137 | self.save() 138 | return True 139 | 140 | 141 | def load(self, path, create=True): 142 | fh, created = self.__get_file(path, "r", create) 143 | self._path = path 144 | if not created: 145 | try: 146 | data = json.load(fh) 147 | input_params = 0 148 | for key, value in data.items(): 149 | if key in self._data: 150 | input_params += 1 151 | self._data[key] = value 152 | log.info("Loaded settings from json: %s" % path) 153 | if input_params < len(self._data): 154 | self.save() 155 | except Exception as e: 156 | log.error("Error loading settings from json: %s" % e) 157 | fh.close() 158 | return False 159 | 160 | fh.close() 161 | return True 162 | 163 | def save(self): 164 | fh, created = self.__get_file(self._path, "w", True) 165 | 166 | try: 167 | json.dump(self._data, fh, indent=4, sort_keys=True) 168 | fh.flush() 169 | fh.close() 170 | except Exception as e: 171 | log.error("Error saving settings to json: %s" % e) 172 | return False 173 | 174 | return True 175 | 176 | def add_listener(self, callback): 177 | """ 178 | Register a callback to be called anytime a setting value changes. 179 | An example callback function: 180 | 181 | def my_callback(key, value): 182 | # Do something with the new setting ``value``... 183 | 184 | """ 185 | if callback not in self._listeners: 186 | self._listeners.append(callback) 187 | 188 | settings = Settings() 189 | -------------------------------------------------------------------------------- /plex_mpv_shim/conffile.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | import os 3 | import sys 4 | import getpass 5 | 6 | # If no platform is matched, use the current directory. 7 | _confdir = lambda app: '' 8 | username = getpass.getuser() 9 | 10 | def posix(app): 11 | if os.environ.get("XDG_CONFIG_HOME"): 12 | return os.path.join(os.environ["XDG_CONFIG_HOME"], app) 13 | else: 14 | return os.path.join(os.path.expanduser("~"),'.config',app) 15 | 16 | def win32(app): 17 | if os.environ.get("APPDATA"): 18 | return os.path.join(os.environ["APPDATA"], app) 19 | else: 20 | return os.path.join(r'C:\Users', username, r'AppData\Roaming', app) 21 | 22 | confdirs = ( 23 | ('linux', posix), 24 | ('win32', win32), 25 | ('cygwin', posix), 26 | ('darwin', lambda app: os.path.join('/Users',username,'Library/Application Support',app)) 27 | ) 28 | 29 | for platform, directory in confdirs: 30 | if sys.platform.startswith(platform): 31 | _confdir = directory 32 | 33 | custom_config = None 34 | for i, arg in enumerate(sys.argv): 35 | if arg == "--config" and len(sys.argv) > i+1: 36 | custom_config = sys.argv[i+1] 37 | 38 | def confdir(app): 39 | if custom_config is not None: 40 | return custom_config 41 | else: 42 | return _confdir(app) 43 | 44 | def get(app, conf_file, create=False): 45 | conf_folder = confdir(app) 46 | if not os.path.isdir(conf_folder): 47 | os.makedirs(conf_folder) 48 | conf_file = os.path.join(conf_folder,conf_file) 49 | if create and not os.path.isfile(conf_file): 50 | open(conf_file, 'w').close() 51 | return conf_file 52 | 53 | -------------------------------------------------------------------------------- /plex_mpv_shim/gdm.py: -------------------------------------------------------------------------------- 1 | """ 2 | PlexGDM.py - Version 0.3 3 | 4 | This class implements the Plex GDM (G'Day Mate) protocol to discover 5 | local Plex Media Servers. Also allow client registration into all local 6 | media servers. 7 | 8 | 9 | This program is free software; you can redistribute it and/or modify 10 | it under the terms of the GNU General Public License as published by 11 | the Free Software Foundation; either version 2 of the License, or 12 | (at your option) any later version. 13 | 14 | This program is distributed in the hope that it will be useful, 15 | but WITHOUT ANY WARRANTY; without even the implied warranty of 16 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 17 | GNU General Public License for more details. 18 | 19 | You should have received a copy of the GNU General Public License 20 | along with this program; if not, write to the Free Software 21 | Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, 22 | MA 02110-1301, USA. 23 | """ 24 | 25 | __author__ = 'DHJ (hippojay) ' 26 | 27 | import socket 28 | import struct 29 | import threading 30 | import time 31 | import urllib.request, urllib.error, urllib.parse 32 | from .conf import settings 33 | 34 | class PlexGDM: 35 | 36 | def __init__(self, debug=0): 37 | 38 | self.discover_message = b'M-SEARCH * HTTP/1.0' 39 | self.client_header = b'* HTTP/1.0' 40 | self.client_data = None 41 | self.client_id = None 42 | 43 | self._multicast_address = '239.0.0.250' 44 | self.discover_group = (self._multicast_address, 32414) 45 | self.client_register_group = (self._multicast_address, 32413) 46 | self.client_update_port = 32412 47 | 48 | self.server_list = [] 49 | self.discovery_interval = 120 50 | 51 | self._discovery_is_running = False 52 | self._registration_is_running = False 53 | 54 | self.discovery_complete = False 55 | self.client_registered = False 56 | self.debug = debug 57 | 58 | def __printDebug(self, message, level=1): 59 | if self.debug >= level: 60 | print("PlexGDM: %s" % message) 61 | 62 | def clientDetails(self, c_id, c_name, c_port, c_product, c_version): 63 | capabilities = b"timeline,playback,navigation" 64 | if settings.enable_play_queue: 65 | capabilities = b"timeline,playback,navigation,playqueues" 66 | 67 | data = { 68 | b"Name": str(c_name).encode("utf-8"), 69 | b"RawName": str(c_name).encode("utf-8"), 70 | b"Port": str(c_port).encode("utf-8"), 71 | b"Content-Type": b"plex/media-player", 72 | b"Product": str(c_product).encode("utf-8"), 73 | b"Protocol": b"plex", 74 | b"Protocol-Version": b"1", 75 | b"Protocol-Capabilities": capabilities, 76 | b"Version": str(c_version).encode("utf-8"), 77 | b"Resource-Identifier": str(c_id).encode("utf-8"), 78 | b"Device-Class": b"pc" 79 | } 80 | 81 | self.client_data = b"" 82 | for key, value in list(data.items()): 83 | self.client_data += b"%s: %s\n" % (key, value) 84 | self.client_data = self.client_data.strip() 85 | 86 | self.client_id = c_id 87 | 88 | def getClientDetails(self): 89 | if not self.client_data: 90 | self.__printDebug("Client data has not been initialised. Please use PlexGDM.clientDetails()") 91 | 92 | return self.client_data 93 | 94 | def client_update(self): 95 | update_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) 96 | 97 | # Set socket reuse, may not work on all OSs. 98 | try: 99 | update_sock.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1) 100 | except: 101 | pass 102 | 103 | # Attempt to bind to the socket to receive and send data. If we can;t do this, then we cannot send registration 104 | try: 105 | update_sock.bind(('0.0.0.0', self.client_update_port)) 106 | except: 107 | self.__printDebug("Error: Unable to bind to port [%s] -" 108 | " client will not be registered" % self.client_update_port, 0) 109 | return 110 | 111 | update_sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 255) 112 | update_sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, 113 | socket.inet_aton(self._multicast_address) + socket.inet_aton('0.0.0.0')) 114 | update_sock.setblocking(False) 115 | self.__printDebug("Sending registration data: HELLO %s\n%s" % (self.client_header, self.client_data), 3) 116 | 117 | # Send initial client registration 118 | try: 119 | update_sock.sendto(b"HELLO %s\n%s" % (self.client_header, self.client_data), self.client_register_group) 120 | except: 121 | self.__printDebug("Error: Unable to send registration message", 0) 122 | 123 | # Now, listen for client discovery reguests and respond. 124 | while self._registration_is_running: 125 | try: 126 | data, addr = update_sock.recvfrom(1024) 127 | self.__printDebug("Received UDP packet from [%s] containing [%s]" % (addr, data.strip()), 3) 128 | except socket.error: 129 | pass 130 | else: 131 | if b"M-SEARCH * HTTP/1." in data: 132 | self.__printDebug("Detected client discovery request from %s. Replying" % (addr,), 2) 133 | try: 134 | update_sock.sendto(b"HTTP/1.0 200 OK\n%s" % self.client_data, addr) 135 | except: 136 | self.__printDebug("Error: Unable to send client update message", 0) 137 | 138 | self.__printDebug("Sending registration data: HTTP/1.0 200 OK\n%s" % self.client_data, 3) 139 | self.client_registered = True 140 | time.sleep(0.5) 141 | 142 | self.__printDebug("Client Update loop stopped", 1) 143 | 144 | # When we are finished, then send a final goodbye message to deregister cleanly. 145 | self.__printDebug("Sending registration data: BYE %s\n%s" % (self.client_header, self.client_data), 3) 146 | try: 147 | update_sock.sendto(b"BYE %s\n%s" % (self.client_header, self.client_data), self.client_register_group) 148 | except: 149 | self.__printDebug( "Error: Unable to send client update message" ,0) 150 | 151 | self.client_registered = False 152 | 153 | def check_client_registration(self): 154 | if self.client_registered and self.discovery_complete: 155 | 156 | if not self.server_list: 157 | self.__printDebug("Server list is empty. Unable to check",2) 158 | return False 159 | 160 | try: 161 | media_server=self.server_list[0]['server'] 162 | media_port=self.server_list[0]['port'] 163 | 164 | self.__printDebug("Checking server [%s] on port [%s]" % (media_server, media_port) ,2) 165 | f = urllib.request.urlopen('http://%s:%s/clients' % (media_server, media_port)) 166 | client_result = f.read() 167 | if self.client_id in client_result: 168 | self.__printDebug("Client registration successful",1) 169 | self.__printDebug("Client data is: %s" % client_result, 3) 170 | return True 171 | else: 172 | self.__printDebug("Client registration not found",1) 173 | self.__printDebug("Client data is: %s" % client_result, 3) 174 | 175 | except: 176 | self.__printDebug("Unable to check status") 177 | pass 178 | 179 | return False 180 | 181 | def setInterval(self, interval): 182 | self.discovery_interval = interval 183 | 184 | def stop_all(self): 185 | self.stop_registration() 186 | 187 | def stop_registration(self): 188 | if self._registration_is_running: 189 | self.__printDebug("Registration shutting down", 1) 190 | self._registration_is_running = False 191 | self.register_t.join() 192 | del self.register_t 193 | else: 194 | self.__printDebug("Registration not running", 1) 195 | 196 | def start_registration(self, daemon = False): 197 | if not self._registration_is_running: 198 | self.__printDebug("Registration starting up", 1) 199 | self._registration_is_running = True 200 | self.register_t = threading.Thread(target=self.client_update) 201 | self.register_t.setDaemon(daemon) 202 | self.register_t.start() 203 | else: 204 | self.__printDebug("Registration already running", 1) 205 | 206 | def start_all(self, daemon = False): 207 | self.start_registration(daemon) 208 | 209 | gdm = PlexGDM() 210 | -------------------------------------------------------------------------------- /plex_mpv_shim/gui_mgr.py: -------------------------------------------------------------------------------- 1 | from pystray import Icon, MenuItem, Menu 2 | from PIL import Image 3 | from collections import deque 4 | import tkinter as tk 5 | from tkinter import ttk, messagebox 6 | import subprocess 7 | from multiprocessing import Process, Queue 8 | import threading 9 | import sys 10 | import logging 11 | import queue 12 | import os.path 13 | 14 | APP_NAME = "plex-mpv-shim" 15 | from .conffile import confdir 16 | 17 | if (sys.platform.startswith("win32") or sys.platform.startswith("cygwin")) and getattr(sys, 'frozen', False): 18 | # Detect if bundled via pyinstaller. 19 | # From: https://stackoverflow.com/questions/404744/ 20 | icon_file = os.path.join(sys._MEIPASS, "systray.png") 21 | else: 22 | icon_file = os.path.join(os.path.dirname(__file__), "systray.png") 23 | log = logging.getLogger('gui_mgr') 24 | 25 | # From https://stackoverflow.com/questions/6631299/ 26 | # This is for opening the config directory. 27 | def _show_file_darwin(path): 28 | subprocess.Popen(["open", path]) 29 | 30 | def _show_file_linux(path): 31 | subprocess.Popen(["xdg-open", path]) 32 | 33 | def _show_file_win32(path): 34 | subprocess.Popen(["explorer", path]) 35 | 36 | _show_file_func = {'darwin': _show_file_darwin, 37 | 'linux': _show_file_linux, 38 | 'win32': _show_file_win32, 39 | 'cygwin': _show_file_win32} 40 | 41 | try: 42 | show_file = _show_file_func[sys.platform] 43 | def open_config(): 44 | show_file(confdir(APP_NAME)) 45 | except KeyError: 46 | open_config = None 47 | log.warning("Platform does not support opening folders.") 48 | 49 | # Setup a log handler for log items. 50 | log_cache = deque([], 1000) 51 | root_logger = logging.getLogger('') 52 | 53 | class GUILogHandler(logging.Handler): 54 | def __init__(self): 55 | self.callback = None 56 | super().__init__() 57 | 58 | def emit(self, record): 59 | log_entry = self.format(record) 60 | log_cache.append(log_entry) 61 | 62 | if self.callback: 63 | try: 64 | self.callback(log_entry) 65 | except Exception: 66 | pass 67 | 68 | guiHandler = GUILogHandler() 69 | guiHandler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)8s] %(message)s")) 70 | root_logger.addHandler(guiHandler) 71 | 72 | # Why am I using another process for the GUI windows? 73 | # Because both pystray and tkinter must run 74 | # in the main thread of their respective process. 75 | 76 | class LoggerWindow(threading.Thread): 77 | def __init__(self): 78 | self.dead = False 79 | threading.Thread.__init__(self) 80 | 81 | def run(self): 82 | self.queue = Queue() 83 | self.r_queue = Queue() 84 | self.process = LoggerWindowProcess(self.queue, self.r_queue) 85 | 86 | def handle(message): 87 | self.handle("append", message) 88 | 89 | self.process.start() 90 | handle("\n".join(log_cache)) 91 | guiHandler.callback = handle 92 | while True: 93 | action, param = self.r_queue.get() 94 | if action == "die": 95 | self._die() 96 | break 97 | 98 | def handle(self, action, params=None): 99 | self.queue.put((action, params)) 100 | 101 | def stop(self, is_source=False): 102 | self.r_queue.put(("die", None)) 103 | 104 | def _die(self): 105 | guiHandler.callback = None 106 | self.handle("die") 107 | self.process.terminate() 108 | self.dead = True 109 | 110 | class LoggerWindowProcess(Process): 111 | def __init__(self, queue, r_queue): 112 | self.queue = queue 113 | self.r_queue = r_queue 114 | Process.__init__(self) 115 | 116 | def update(self): 117 | try: 118 | self.text.config(state=tk.NORMAL) 119 | while True: 120 | action, param = self.queue.get_nowait() 121 | if action == "append": 122 | self.text.config(state=tk.NORMAL) 123 | self.text.insert(tk.END, "\n") 124 | self.text.insert(tk.END, param) 125 | self.text.config(state=tk.DISABLED) 126 | self.text.see(tk.END) 127 | elif action == "die": 128 | self.root.destroy() 129 | self.root.quit() 130 | return 131 | except queue.Empty: 132 | pass 133 | self.text.after(100, self.update) 134 | 135 | def run(self): 136 | root = tk.Tk() 137 | self.root = root 138 | root.title("Application Log") 139 | text = tk.Text(root) 140 | text.pack(side=tk.LEFT, fill=tk.BOTH, expand = tk.YES) 141 | text.config(wrap=tk.WORD) 142 | self.text = text 143 | yscroll = tk.Scrollbar(command=text.yview) 144 | text['yscrollcommand'] = yscroll.set 145 | yscroll.pack(side=tk.RIGHT, fill=tk.Y) 146 | text.config(state=tk.DISABLED) 147 | self.update() 148 | root.mainloop() 149 | self.r_queue.put(("die", None)) 150 | 151 | # Q: OK. So you put Tkinter in it's own process. 152 | # Now why is Pystray in another process too?! 153 | # A: Because if I don't, MPV and GNOME Appindicator 154 | # try to access the same resources and cause the 155 | # entire application to segfault. 156 | # 157 | # I suppose this means I can put the Tkinter GUI back 158 | # into the main process. This is true, but then the 159 | # two need to be merged, which is non-trivial. 160 | 161 | class UserInterface: 162 | def __init__(self): 163 | self.dead = False 164 | self.open_player_menu = lambda: None 165 | self.icon_stop = lambda: None 166 | self.log_window = None 167 | self.preferences_window = None 168 | 169 | def run(self): 170 | self.queue = Queue() 171 | self.r_queue = Queue() 172 | self.process = STrayProcess(self.queue, self.r_queue) 173 | self.process.start() 174 | 175 | while True: 176 | try: 177 | action, param = self.r_queue.get() 178 | if hasattr(self, action): 179 | getattr(self, action)() 180 | elif action == "die": 181 | self._die() 182 | break 183 | except KeyboardInterrupt: 184 | log.info("Stopping due to CTRL+C.") 185 | self._die() 186 | break 187 | 188 | def handle(self, action, params=None): 189 | self.queue.put((action, params)) 190 | 191 | def stop(self): 192 | self.handle("die") 193 | 194 | def _die(self): 195 | self.process.terminate() 196 | self.dead = True 197 | 198 | if self.log_window and not self.log_window.dead: 199 | self.log_window.stop() 200 | 201 | def login_servers(self): 202 | is_logged_in = clientManager.try_connect() 203 | if not is_logged_in: 204 | self.show_preferences() 205 | 206 | def show_console(self): 207 | if self.log_window is None or self.log_window.dead: 208 | self.log_window = LoggerWindow() 209 | self.log_window.start() 210 | 211 | def open_config_brs(self): 212 | if open_config: 213 | open_config() 214 | else: 215 | log.error("Config opening is not available.") 216 | 217 | class STrayProcess(Process): 218 | def __init__(self, queue, r_queue): 219 | self.queue = queue 220 | self.r_queue = r_queue 221 | Process.__init__(self) 222 | 223 | def run(self): 224 | def get_wrapper(command): 225 | def wrapper(): 226 | self.r_queue.put((command, None)) 227 | return wrapper 228 | 229 | def die(): 230 | self.icon_stop() 231 | 232 | menu_items = [ 233 | MenuItem("Show Console", get_wrapper("show_console")), 234 | MenuItem("Application Menu", get_wrapper("open_player_menu")), 235 | MenuItem("Open Config Folder", get_wrapper("open_config_brs")), 236 | MenuItem("Quit", die) 237 | ] 238 | 239 | icon = Icon(APP_NAME, menu=Menu(*menu_items)) 240 | icon.icon = Image.open(icon_file) 241 | self.icon_stop = icon.stop 242 | 243 | def setup(icon: Icon): 244 | icon.visible = True 245 | 246 | icon.run(setup=setup) 247 | self.r_queue.put(("die", None)) 248 | 249 | userInterface = UserInterface() 250 | -------------------------------------------------------------------------------- /plex_mpv_shim/media.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from enum import Enum 3 | import logging 4 | import urllib.request, urllib.parse, urllib.error 5 | import urllib.parse 6 | import certifi 7 | import requests 8 | import uuid 9 | import ssl 10 | 11 | try: 12 | import xml.etree.cElementTree as et 13 | except: 14 | import xml.etree.ElementTree as et 15 | 16 | from .conf import settings 17 | from .utils import get_plex_url, safe_urlopen, is_local_domain, get_transcode_session, clear_transcode_session, sanitize_msg 18 | 19 | log = logging.getLogger('media') 20 | 21 | # http://192.168.0.12:32400/photo/:/transcode?url=http%3A%2F%2F127.0.0.1%3A32400%2F%3A%2Fresources%2Fvideo.png&width=75&height=75 22 | 23 | class MediaType(Enum): 24 | VIDEO = "video" 25 | MUSIC = "music" 26 | 27 | class MediaItem(ABC): 28 | def __init__(self, media_type, node, parent): 29 | self.media_type = media_type 30 | self.node = node 31 | self.parent = parent 32 | 33 | @abstractmethod 34 | def get_playback_url(self, offset=0): 35 | pass 36 | 37 | @abstractmethod 38 | def is_multipart(self): 39 | pass 40 | 41 | def get_duration(self): 42 | return self.node.get("duration") 43 | 44 | def get_rating_key(self): 45 | return self.node.get("ratingKey") 46 | 47 | def get_attr(self, attr, default=None): 48 | return self.node.get(attr, default) 49 | 50 | def get_proper_title(self): 51 | if not hasattr(self, "_title"): 52 | setattr(self, "_title", self.node.get('title')) 53 | return getattr(self, "_title") 54 | 55 | def set_played(self, watched=True): 56 | rating_key = self.get_rating_key() 57 | 58 | if rating_key is None: 59 | log.error("No 'ratingKey' could be found in XML from URL '%s'" % (sanitize_msg(self.parent.path.geturl()))) 60 | return False 61 | 62 | if watched: 63 | act = '/:/scrobble' 64 | else: 65 | act = '/:/unscrobble' 66 | 67 | url = urllib.parse.urljoin(self.parent.server_url, act) 68 | data = { 69 | "key": rating_key, 70 | "identifier": "com.plexapp.plugins.library" 71 | } 72 | 73 | self.played = safe_urlopen(url, data) 74 | return self.played 75 | 76 | class Video(MediaItem): 77 | def __init__(self, node, parent, media=0, part=0): 78 | super().__init__(MediaType.VIDEO, node, parent) 79 | self.played = False 80 | self._media = 0 81 | self._media_node = None 82 | self._part = 0 83 | self._part_node = None 84 | self.subtitle_seq = {} 85 | self.subtitle_uid = {} 86 | self.audio_seq = {} 87 | self.audio_uid = {} 88 | self.is_transcode = False 89 | self.trs_aid = None 90 | self.trs_sid = None 91 | self.trs_ovr = None 92 | self.intro_start = None 93 | self.intro_end = None 94 | self.credits_start = None 95 | self.credits_end = None 96 | 97 | if media: 98 | self.select_media(media, part) 99 | 100 | if not self._media_node: 101 | self.select_best_media(part) 102 | 103 | try: 104 | marker = self.node.find("./Marker[@type='intro']") 105 | if marker is not None: 106 | self.intro_start = float(marker.get('startTimeOffset')) / 1e3 107 | self.intro_end = float(marker.get('endTimeOffset')) / 1e3 108 | log.info("Intro Detected: {0} - {1}".format(self.intro_start, self.intro_end)) 109 | except: 110 | log.error("Could not detect intro.", exc_info=True) 111 | 112 | # TODO de-duplicate this code in some way - it's ugly 113 | try: 114 | marker = self.node.find("./Marker[@type='credits']") 115 | if marker is not None: 116 | self.credits_start = float(marker.get('startTimeOffset')) / 1e3 117 | self.credits_end = float(marker.get('endTimeOffset')) / 1e3 118 | log.info("Credits Detected: {0} - {1}".format(self.credits_start, self.credits_end)) 119 | except: 120 | log.error("Could not detect credits.", exc_info=True) 121 | 122 | self.map_streams() 123 | 124 | def map_streams(self): 125 | if not self._part_node: 126 | return 127 | 128 | for index, stream in enumerate(self._part_node.findall("./Stream[@streamType='2']") or []): 129 | self.audio_uid[index+1] = stream.attrib["id"] 130 | self.audio_seq[stream.attrib["id"]] = index+1 131 | 132 | for index, sub in enumerate(self._part_node.findall("./Stream[@streamType='3']") or []): 133 | if sub.attrib.get("key") is None: 134 | self.subtitle_uid[index+1] = sub.attrib["id"] 135 | self.subtitle_seq[sub.attrib["id"]] = index+1 136 | 137 | def get_transcode_streams(self): 138 | if not self.trs_aid: 139 | audio_obj = self._part_node.find("./Stream[@streamType='2'][@selected='1']") 140 | self.trs_aid = audio_obj.get("id") if audio_obj is not None else None 141 | if not self.trs_sid: 142 | subtitle_obj = self._part_node.find("./Stream[@streamType='3'][@selected='1']") 143 | self.trs_sid = subtitle_obj.get("id") if subtitle_obj is not None else None 144 | return self.trs_aid, self.trs_sid 145 | 146 | def select_best_media(self, part=0): 147 | """ 148 | Nodes are accessed via XPath, which is technically 1-indexed, while 149 | Plex is 0-indexed. 150 | """ 151 | # Select the best media based on resolution 152 | highest_res = 0 153 | best_node = 0 154 | for i, node in enumerate(self.node.findall('./Media')): 155 | res = int(node.get('height', 0))*int(node.get('height', 0)) 156 | if res > highest_res: 157 | highest_res = res 158 | best_node = i 159 | 160 | log.debug("Video::select_best_media selected media %s" % best_node) 161 | 162 | self.select_media(best_node) 163 | 164 | def select_media(self, media, part=0): 165 | node = self.node.find('./Media[%s]' % (media+1)) 166 | if node: 167 | self._media = media 168 | self._media_node = node 169 | if self.select_part(part): 170 | log.debug("Video::select_media selected media %d" % media) 171 | return True 172 | 173 | log.error("Video::select_media error selecting media %d" % media) 174 | return False 175 | 176 | def select_part(self, part): 177 | if self._media_node is None: 178 | return False 179 | 180 | node = self._media_node.find('./Part[%s]' % (part+1)) 181 | if node: 182 | self._part = part 183 | self._part_node = node 184 | return True 185 | 186 | log.error("Video::select_media error selecting part %s" % part) 187 | return False 188 | 189 | def is_multipart(self): 190 | if not self._media_node: 191 | return False 192 | return len(self._media_node.findall("./Part")) > 1 193 | 194 | def set_streams(self, audio_uid, sub_uid): 195 | args = {"allParts": "1"} 196 | 197 | if audio_uid is not None: 198 | args["audioStreamID"] = audio_uid 199 | self.trs_aid = audio_uid 200 | 201 | if sub_uid is not None: 202 | args["subtitleStreamID"] = sub_uid 203 | self.trs_sid = sub_uid 204 | 205 | if self._part_node != None: 206 | partid = self._part_node.get("id") 207 | url = "/library/parts/{0}".format(partid) 208 | requests.put(get_plex_url(urllib.parse.urljoin(self.parent.server_url, url), args), data=None) 209 | 210 | def get_proper_title(self): 211 | if not hasattr(self, "_title"): 212 | media_type = self.node.get('type') 213 | 214 | if self.parent.tree.find(".").get("identifier") != "com.plexapp.plugins.library": 215 | # Plugin? 216 | title = self.node.get('sourceTitle') or "" 217 | if title: 218 | title += " - " 219 | title += self.node.get('title') or "" 220 | else: 221 | # Assume local media 222 | if media_type == "movie": 223 | title = self.node.get("title") 224 | year = self.node.get("year") 225 | if year is not None: 226 | title = "%s (%s)" % (title, year) 227 | elif media_type == "episode": 228 | episode_name = self.node.get("title") 229 | episode_number = int(self.node.get("index")) 230 | season_number = int(self.node.get("parentIndex")) 231 | series_name = self.node.get("grandparentTitle") 232 | title = "%s - s%de%.2d - %s" % (series_name, season_number, episode_number, episode_name) 233 | else: 234 | # "clip", ... 235 | title = self.node.get("title") 236 | setattr(self, "_title", title) 237 | return getattr(self, "_title") + (" (Transcode)" if self.is_transcode else "") 238 | 239 | def set_trs_override(self, video_bitrate, force_transcode, force_bitrate): 240 | if force_transcode: 241 | self.trs_ovr = (video_bitrate, force_transcode, force_bitrate) 242 | else: 243 | self.trs_ovr = None 244 | 245 | def get_transcode_bitrate(self): 246 | if not self.is_transcode: 247 | return "none" 248 | elif self.trs_ovr is not None: 249 | if self.trs_ovr[0] is not None: 250 | return self.trs_ovr[0] 251 | elif self.trs_ovr[1]: 252 | return "max" 253 | elif is_local_domain(self.parent.path.hostname): 254 | return "max" 255 | else: 256 | return settings.transcode_kbps 257 | 258 | 259 | def get_formats(self): 260 | audio_formats = [] 261 | 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}" 262 | if settings.audio_ac3passthrough: 263 | audio_formats.append("add-transcode-target-audio-codec(type=videoProfile&context=streaming&protocol=hls&audioCodec=ac3)") 264 | audio_formats.append("add-transcode-target-audio-codec(type=videoProfile&context=streaming&protocol=hls&audioCodec=eac3)") 265 | protocols += ",ac3{bitrate:800000&channels:8}" 266 | if settings.audio_dtspassthrough: 267 | audio_formats.append("add-transcode-target-audio-codec(type=videoProfile&context=streaming&protocol=hls&audioCodec=dca)") 268 | protocols += ",dts{bitrate:800000&channels:8}" 269 | 270 | return audio_formats, protocols 271 | 272 | def get_decision(self, url, args): 273 | context = ssl.create_default_context(cafile=certifi.where()) 274 | tree = et.parse(urllib.request.urlopen(get_plex_url(urllib.parse.urljoin(self.parent.server_url, url), args), context=context)) 275 | treeRoot = tree.getroot() 276 | decisionText = treeRoot.get("generalDecisionText") or treeRoot.get("mdeDecisionText") 277 | decision = treeRoot.get("generalDecisionCode") or treeRoot.get("mdeDecisionCode") 278 | log.debug("Decision: {0}: {1}".format(decision, decisionText)) 279 | return decision 280 | 281 | def is_transcode_suggested(self, video_bitrate=None, force_transcode=False, force_bitrate=False): 282 | request_direct_play = "1" 283 | request_subtitle_mode = "none" 284 | is_local = is_local_domain(self.parent.path.hostname) 285 | 286 | # User would like us to always transcode. 287 | if settings.always_transcode or force_transcode: 288 | request_direct_play = "0" 289 | request_subtitle_mode = "burn" 290 | elif (settings.direct_limit and not is_local and 291 | int(self.node.find("./Media").get("bitrate")) > settings.transcode_kbps): 292 | request_direct_play = "0" 293 | request_subtitle_mode = "burn" 294 | 295 | # Regardless of if we need the data from the decision, Plex will sometimes deny access 296 | # if there is no decision for the current session. 297 | audio_formats, protocols = self.get_formats() 298 | 299 | url = "/video/:/transcode/universal/decision" 300 | args = { 301 | "hasMDE": "1", 302 | "path": self.node.get("key"), 303 | "session": get_transcode_session(self.parent.path.hostname), 304 | "protocol": "hls", 305 | "directPlay": request_direct_play, 306 | "directStream": "1", 307 | "fastSeek": "1", 308 | "mediaIndex": self._media or 0, 309 | "partIndex": self._part or 0, 310 | "location": "lan" if is_local else "wan", 311 | "autoAdjustQuality": str(int(settings.adaptive_transcode)), 312 | "directStreamAudio": "1", 313 | "subtitles": request_subtitle_mode, # Set to none first. 314 | "subtitleSize": settings.subtitle_size, 315 | "copyts": "1", 316 | } 317 | 318 | if video_bitrate is not None and (not is_local or force_bitrate): 319 | args["maxVideoBitrate"] = str(video_bitrate) 320 | 321 | if audio_formats: 322 | args["X-Plex-Client-Profile-Extra"] = "+".join(audio_formats) 323 | args["X-Plex-Client-Capabilities"] = protocols 324 | 325 | decision = self.get_decision(url, args) 326 | 327 | if request_direct_play == "0": 328 | return True 329 | # Use the decision from the Plex server. 330 | elif settings.auto_transcode: 331 | if decision == "1000": 332 | return False 333 | elif decision == "1001": 334 | # Need to request again or it will remember to use no subtitles... 335 | # There is almost certainly a better way to do this. 336 | if request_subtitle_mode == "none": 337 | args["directPlay"] = "0" 338 | args["subtitles"] = "burn" 339 | decision = self.get_decision(url, args) 340 | if decision != "1001": 341 | log.error("Server reports that file cannot be streamed.") 342 | return True 343 | else: 344 | log.error("Server reports that file cannot be streamed.") 345 | return False 346 | 347 | def terminate_transcode(self): 348 | if get_transcode_session(self.parent.path.hostname, False): 349 | if self.is_transcode: 350 | url = "/video/:/transcode/universal/stop" 351 | args = { 352 | "session": get_transcode_session(self.parent.path.hostname), 353 | } 354 | safe_urlopen(urllib.parse.urljoin(self.parent.server_url, url), args) 355 | clear_transcode_session(self.parent.path.hostname) 356 | 357 | def get_playback_url(self, direct_play=None, offset=0, video_bitrate=None, force_transcode=False, force_bitrate=False): 358 | """ 359 | Returns the URL to use for the trancoded file. 360 | """ 361 | self.terminate_transcode() 362 | 363 | if self.trs_ovr: 364 | video_bitrate, force_transcode, force_bitrate = self.trs_ovr 365 | elif video_bitrate is None: 366 | video_bitrate = settings.transcode_kbps 367 | 368 | if direct_play is None: 369 | # See if transcoding is suggested 370 | direct_play = not self.is_transcode_suggested(video_bitrate, force_transcode, force_bitrate) 371 | 372 | if direct_play: 373 | if not self._part_node: 374 | return 375 | self.is_transcode = False 376 | url = urllib.parse.urljoin(self.parent.server_url, self._part_node.get("key", "")) 377 | return get_plex_url(url) 378 | 379 | self.is_transcode = True 380 | is_local = is_local_domain(self.parent.path.hostname) 381 | 382 | url = "/video/:/transcode/universal/start.m3u8" 383 | args = { 384 | "path": self.node.get("key"), 385 | "session": get_transcode_session(self.parent.path.hostname), 386 | "protocol": "hls", 387 | "directPlay": "0", 388 | "directStream": "1", 389 | "fastSeek": "1", 390 | "mediaIndex": self._media or 0, 391 | "partIndex": self._part or 0, 392 | "location": "lan" if is_local else "wan", 393 | "offset": offset, 394 | "autoAdjustQuality": str(int(settings.adaptive_transcode)), 395 | "directStreamAudio": "1", 396 | "subtitles": "burn", 397 | "copyts": "1", 398 | "subtitleSize": settings.subtitle_size, 399 | #"skipSubtitles": "1", 400 | } 401 | 402 | if video_bitrate is not None and (not is_local or force_bitrate): 403 | args["maxVideoBitrate"] = str(video_bitrate) 404 | 405 | audio_formats, protocols = self.get_formats() 406 | 407 | if audio_formats: 408 | args["X-Plex-Client-Profile-Extra"] = "+".join(audio_formats) 409 | args["X-Plex-Client-Capabilities"] = protocols 410 | 411 | return get_plex_url(urllib.parse.urljoin(self.parent.server_url, url), args) 412 | 413 | def get_audio_idx(self): 414 | """ 415 | Returns the index of the selected stream 416 | """ 417 | if not self._part_node: 418 | return 419 | 420 | match = False 421 | index = None 422 | for index, stream in enumerate(self._part_node.findall("./Stream[@streamType='2']") or []): 423 | if stream.get('selected') == "1": 424 | match = True 425 | break 426 | 427 | if match: 428 | return index+1 429 | 430 | 431 | def get_subtitle_idx(self): 432 | if not self._part_node: 433 | return 434 | 435 | match = False 436 | index = None 437 | for index, sub in enumerate(self._part_node.findall("./Stream[@streamType='3']") or []): 438 | if sub.get('selected') == "1" and sub.get('key') is None: 439 | match = True 440 | break 441 | 442 | if match: 443 | return index+1 444 | 445 | def get_external_sub_id(self): 446 | if not self._part_node: 447 | return 448 | 449 | for sub in self._part_node.findall("./Stream[@streamType='3']") or []: 450 | if sub.get('selected') == "1" and sub.get('key') is not None: 451 | return sub.get("id") 452 | 453 | def get_external_sub(self, id): 454 | url = "/library/streams/{0}".format(id) 455 | return get_plex_url(urllib.parse.urljoin(self.parent.server_url, url)) 456 | 457 | def update_position(self, ms): 458 | """ 459 | Sets the state of the media as "playing" with a progress of ``ms`` milliseconds. 460 | """ 461 | rating_key = self.get_rating_key() 462 | 463 | if rating_key is None: 464 | log.error("No 'ratingKey' could be found in XML from URL '%s'" % (sanitize_msg(self.parent.path.geturl()))) 465 | return False 466 | 467 | url = urllib.parse.urljoin(self.parent.server_url, '/:/progress') 468 | data = { 469 | "key": rating_key, 470 | "time": int(ms), 471 | "identifier": "com.plexapp.plugins.library", 472 | "state": "playing" 473 | } 474 | 475 | return safe_urlopen(url, data) 476 | 477 | class Track(MediaItem): 478 | def __init__(self, node, parent, media=0, part=0): 479 | super().__init__(MediaType.MUSIC, node, parent) 480 | self.played = False 481 | self._media = 0 482 | self._media_node = None 483 | self._part = 0 484 | self._part_node = None 485 | self.is_transcode = False 486 | self.trs_aid = None 487 | self.trs_sid = None 488 | self.trs_ovr = None 489 | self.audio_seq = {} 490 | self.audio_uid = {} 491 | 492 | if media: 493 | self.select_media(media, part) 494 | 495 | if not self._media_node: 496 | self.select_best_media(part) 497 | 498 | self.map_streams() 499 | 500 | def map_streams(self): 501 | if not self._part_node: 502 | return 503 | 504 | for index, stream in enumerate(self._part_node.findall("./Stream[@streamType='2']") or []): 505 | self.audio_uid[index+1] = stream.attrib["id"] 506 | self.audio_seq[stream.attrib["id"]] = index+1 507 | 508 | def select_best_media(self, part=0): 509 | # Audio is much easier :) 510 | self.select_media(0) 511 | 512 | def select_media(self, media, part=0): 513 | node = self.node.find('./Media[%s]' % (media+1)) 514 | if node: 515 | self._media = media 516 | self._media_node = node 517 | if self.select_part(part): 518 | log.debug("Track::select_media selected media %d" % media) 519 | return True 520 | 521 | log.error("Track::select_media error selecting media %d" % media) 522 | return False 523 | 524 | def select_part(self, part): 525 | if self._media_node is None: 526 | return False 527 | 528 | node = self._media_node.find('./Part[%s]' % (part+1)) 529 | if node: 530 | self._part = part 531 | self._part_node = node 532 | return True 533 | 534 | log.error("Track::select_media error selecting part %s" % part) 535 | return False 536 | 537 | def get_playback_url(self, offset=0): 538 | url = urllib.parse.urljoin(self.parent.server_url, self._part_node.get("key", "")) 539 | return get_plex_url(url) 540 | 541 | def is_multipart(self): 542 | return False 543 | 544 | class XMLCollection(object): 545 | def __init__(self, url): 546 | """ 547 | ``url`` should be a URL to the Plex XML media item. 548 | """ 549 | self.path = urllib.parse.urlparse(url) 550 | self.server_url = self.path.scheme + "://" + self.path.netloc 551 | self.context = ssl.create_default_context(cafile=certifi.where()) 552 | self.tree = et.parse(urllib.request.urlopen(get_plex_url(url), context=self.context)) 553 | 554 | def get_path(self, path): 555 | parsed_url = urllib.parse.urlparse(path) 556 | query = urllib.parse.parse_qs(parsed_url.query) 557 | query.update(urllib.parse.parse_qs(self.path.query)) 558 | query = urllib.parse.urlencode(query, doseq=True) 559 | 560 | return urllib.parse.urlunparse((self.path.scheme, self.path.netloc, parsed_url.path, 561 | self.path.params, query, self.path.fragment)) 562 | 563 | def __str__(self): 564 | return self.path.path 565 | 566 | class Media(XMLCollection): 567 | def __init__(self, url, series=None, seq=None, play_queue=None, play_queue_xml=None, media_type=MediaType.VIDEO): 568 | # Include Markers 569 | if "?" in url: 570 | sep = "&" 571 | else: 572 | sep = "?" 573 | url = url + sep + "includeMarkers=1" 574 | 575 | XMLCollection.__init__(self, url) 576 | 577 | self.media_type = media_type 578 | 579 | if self.media_type == MediaType.VIDEO: 580 | self.media_item = self.tree.find('./Video') 581 | self.is_tv = self.media_item.get("type") == "episode" 582 | elif self.media_type == MediaType.MUSIC: 583 | self.media_item = self.tree.find('./Track') 584 | 585 | self.seq = None 586 | self.has_next = False 587 | self.has_prev = False 588 | self.play_queue = play_queue 589 | self.play_queue_xml = play_queue_xml 590 | 591 | if self.play_queue: 592 | if not series: 593 | self.upd_play_queue() 594 | else: 595 | self.series = series 596 | self.seq = seq 597 | self.has_next = self.seq < len(self.series) - 1 598 | self.has_prev = self.seq > 0 599 | elif self.is_tv: 600 | if series: 601 | self.series = series 602 | self.seq = seq 603 | else: 604 | self.series = [] 605 | specials = [] 606 | series_xml = XMLCollection(self.get_path(self.media_item.get("grandparentKey")+"/allLeaves")) 607 | videos = series_xml.tree.findall('./Video') 608 | 609 | # This part is kind of nasty, so we only try to do it once per cast session. 610 | key = self.media_item.get('key') 611 | is_special = False 612 | for i, video in enumerate(videos): 613 | if video.get('key') == key: 614 | self.seq = i 615 | is_special = video.get('parentIndex') == '0' 616 | if video.get('parentIndex') == '0': 617 | specials.append(video) 618 | else: 619 | self.series.append(video) 620 | if is_special: 621 | self.seq += len(self.series) 622 | else: 623 | self.seq -= len(specials) 624 | self.series.extend(specials) 625 | self.has_next = self.seq < len(self.series) - 1 626 | self.has_prev = self.seq > 0 627 | 628 | def upd_play_queue(self): 629 | if self.play_queue: 630 | if self.media_type == MediaType.VIDEO: 631 | self.play_queue_xml = XMLCollection(self.get_path(self.play_queue)) 632 | videos = self.play_queue_xml.tree.findall('./Video') 633 | self.series = [] 634 | 635 | key = self.media_item.get('key') 636 | for i, video in enumerate(videos): 637 | if video.get('key') == key: 638 | self.seq = i 639 | self.series.append(video) 640 | 641 | self.has_next = self.seq < len(self.series) - 1 642 | self.has_prev = self.seq > 0 643 | elif self.media_type == MediaType.MUSIC: 644 | self.play_queue_xml = XMLCollection(self.get_path(self.play_queue)) 645 | tracks = self.play_queue_xml.tree.findall('./Track') 646 | self.series = [] 647 | 648 | key = self.media_item.get('key') 649 | for i, track in enumerate(tracks): 650 | if track.get('key') == key: 651 | self.seq = i 652 | self.series.append(track) 653 | 654 | self.has_next = self.seq < len(self.series) - 1 655 | self.has_prev = self.seq > 0 656 | 657 | def get_queue_info(self): 658 | return { 659 | "containerKey": self.play_queue, 660 | "playQueueID": self.play_queue_xml.tree.find(".").get("playQueueID"), 661 | "playQueueVersion": self.play_queue_xml.tree.find(".").get("playQueueVersion"), 662 | "playQueueItemID": self.series[self.seq].get("playQueueItemID") 663 | } 664 | 665 | def get_next(self): 666 | if self.has_next: 667 | if self.play_queue and self.seq+2 == len(self.series): 668 | self.upd_play_queue() 669 | next_video = self.series[self.seq+1] 670 | return Media(self.get_path(next_video.get('key')), self.series, self.seq+1, self.play_queue, self.play_queue_xml, self.media_type) 671 | 672 | def get_prev(self): 673 | if self.has_prev: 674 | if self.play_queue and self.seq-1 == 0: 675 | self.upd_play_queue() 676 | prev_video = self.series[self.seq-1] 677 | return Media(self.get_path(prev_video.get('key')), self.series, self.seq-1, self.play_queue, self.play_queue_xml, self.media_type) 678 | 679 | def get_from_key(self, key): 680 | if self.play_queue: 681 | self.upd_play_queue() 682 | for i, video in enumerate(self.series): 683 | if video.get("key") == key: 684 | return Media(self.get_path(key), self.series, i, self.play_queue, self.play_queue_xml, self.media_type) 685 | return None 686 | else: 687 | return Media(self.get_path(key), media_type=self.media_type) 688 | 689 | def get_media_item(self, index, media=0, part=0): 690 | if self.media_type == MediaType.VIDEO: 691 | if index == 0 and self.media_item: 692 | return Video(self.media_item, self, media, part) 693 | 694 | video = self.tree.find('./Video[%s]' % (index+1)) 695 | if video: 696 | return Video(video, self, media, part) 697 | 698 | elif self.media_type == MediaType.MUSIC: 699 | if index == 0 and self.media_item: 700 | return Track(self.media_item, self, media, part) 701 | 702 | track = self.tree.find('./Track[%s]' % (index+1)) 703 | if track: 704 | return Track(track, self, media, part) 705 | 706 | log.error("Media::get_media_item couldn't find {} at index {}".format(self.media_type, index)) 707 | 708 | def get_machine_identifier(self): 709 | if not hasattr(self, "_machine_identifier"): 710 | context = ssl.create_default_context(cafile=certifi.where()) 711 | doc = urllib.request.urlopen(get_plex_url(self.server_url), context=context) 712 | tree = et.parse(doc) 713 | setattr(self, "_machine_identifier", tree.find('.').get("machineIdentifier")) 714 | return getattr(self, "_machine_identifier", None) 715 | 716 | -------------------------------------------------------------------------------- /plex_mpv_shim/menu.py: -------------------------------------------------------------------------------- 1 | from queue import Queue, LifoQueue 2 | from .bulk_subtitle import process_series 3 | from .conf import settings 4 | from .utils import mpv_color_to_plex 5 | from .video_profile import VideoProfileManager 6 | from .svp_integration import SVPManager 7 | import time 8 | import logging 9 | 10 | log = logging.getLogger('menu') 11 | 12 | TRANSCODE_LEVELS = ( 13 | ("1080p 20 Mbps", 20000), 14 | ("1080p 12 Mbps", 12000), 15 | ("1080p 10 Mbps", 10000), 16 | ("720p 4 Mbps", 4000), 17 | ("720p 3 Mbps", 3000), 18 | ("720p 2 Mbps", 2000), 19 | ("480p 1.5 Mbps", 1500), 20 | ("328p 0.7 Mbps", 720), 21 | ("240p 0.3 Mbps", 320), 22 | ("160p 0.2 Mbps", 208), 23 | ) 24 | 25 | COLOR_LIST = ( 26 | ("White", "#FFFFFFFF"), 27 | ("Yellow", "#FFFFEE00"), 28 | ("Black", "#FF000000"), 29 | ("Cyan", "#FF00FFFF"), 30 | ("Blue", "#FF0000FF"), 31 | ("Green", "#FF00FF00"), 32 | ("Magenta", "#FFEE00EE"), 33 | ("Red", "#FFFF0000"), 34 | ("Gray", "#FF808080"), 35 | ) 36 | 37 | SIZE_LIST = ( 38 | ("Tiny", 50), 39 | ("Small", 75), 40 | ("Normal", 100), 41 | ("Large", 125), 42 | ("Huge", 200), 43 | ) 44 | 45 | HEX_TO_COLOR = {v:c for c,v in COLOR_LIST} 46 | 47 | class OSDMenu(object): 48 | def __init__(self, playerManager): 49 | self.playerManager = playerManager 50 | 51 | self.is_menu_shown = False 52 | self.menu_title = "" 53 | self.menu_stack = LifoQueue() 54 | self.menu_list = [] 55 | self.menu_selection = 0 56 | self.menu_tmp = None 57 | self.mouse_back = False 58 | self.original_osd_color = playerManager._player.osd_back_color 59 | self.original_osd_size = playerManager._player.osd_font_size 60 | 61 | self.profile_menu = None 62 | self.profile_manager = None 63 | if settings.shader_pack_enable: 64 | try: 65 | self.profile_manager = VideoProfileManager(self, playerManager) 66 | self.profile_menu = self.profile_manager.menu_action 67 | except Exception: 68 | log.error("Could not load profile manager.", exc_info=True) 69 | 70 | self.svp_menu = None 71 | try: 72 | self.svp_menu = SVPManager(self, playerManager) 73 | except Exception: 74 | log.error("Could not load SVP integration.", exc_info=True) 75 | 76 | # The menu is a bit of a hack... 77 | # It works using multiline OSD. 78 | # We also have to force the window to open. 79 | 80 | def refresh_menu(self): 81 | if not self.is_menu_shown: 82 | return 83 | 84 | items = self.menu_list 85 | selected_item = self.menu_selection 86 | 87 | if self.mouse_back: 88 | menu_text = "(<--) {0}".format(self.menu_title) 89 | else: 90 | menu_text = self.menu_title 91 | for i, item in enumerate(items): 92 | fmt = "\n {0}" 93 | if i == selected_item and not self.mouse_back: 94 | fmt = "\n **{0}**" 95 | menu_text += fmt.format(item[0]) 96 | 97 | self.playerManager._player.show_text(menu_text,2**30,1) 98 | 99 | def mouse_select(self, idx): 100 | if idx < 0 or idx > len(self.menu_list): 101 | return 102 | if idx == 0: 103 | self.mouse_back = True 104 | else: 105 | self.mouse_back = False 106 | self.menu_selection = idx - 1 107 | self.refresh_menu() 108 | 109 | def show_menu(self): 110 | self.is_menu_shown = True 111 | player = self.playerManager._player 112 | player.osd_back_color = '#CC333333' 113 | player.osd_font_size = 40 114 | 115 | if hasattr(player, 'osc'): 116 | player.osc = False 117 | 118 | player.command("script-message", "shim-menu-enable", "True") 119 | 120 | self.menu_title = "Main Menu" 121 | self.menu_selection = 0 122 | self.mouse_back = False 123 | 124 | if self.playerManager._media_item and not player.playback_abort: 125 | self.menu_list = [ 126 | ("Change Audio", self.change_audio_menu), 127 | ("Change Subtitles", self.change_subtitle_menu), 128 | ("Change Video Quality", self.change_transcode_quality), 129 | ] 130 | if self.profile_menu is not None: 131 | self.menu_list.append(("Change Video Playback Profile", self.profile_menu)) 132 | if self.playerManager._media_item.parent.is_tv: 133 | self.menu_list.append(("Auto Set Audio/Subtitles (Entire Series)", self.change_tracks_menu)) 134 | self.menu_list.append(("Quit and Mark Unwatched", self.unwatched_menu_handle)) 135 | else: 136 | self.menu_list = [] 137 | if self.profile_menu is not None: 138 | self.menu_list.append(("Video Playback Profiles", self.profile_menu)) 139 | 140 | if self.svp_menu is not None and self.svp_menu.is_available(): 141 | self.menu_list.append(("SVP Settings", self.svp_menu.menu_action)) 142 | 143 | self.menu_list.extend([ 144 | ("Preferences", self.preferences_menu), 145 | ("Close Menu", self.hide_menu) 146 | ]) 147 | 148 | self.refresh_menu() 149 | 150 | # Wait until the menu renders to pause. 151 | time.sleep(0.2) 152 | 153 | if player.playback_abort: 154 | player.force_window = True 155 | player.keep_open = True 156 | player.play("") 157 | if settings.fullscreen: 158 | player.fs = True 159 | else: 160 | player.pause = True 161 | 162 | def hide_menu(self): 163 | player = self.playerManager._player 164 | if self.is_menu_shown: 165 | player.osd_back_color = self.original_osd_color 166 | player.osd_font_size = self.original_osd_size 167 | player.show_text("",0,0) 168 | player.force_window = False 169 | player.keep_open = False 170 | 171 | if hasattr(player, 'osc'): 172 | player.osc = settings.enable_osc 173 | 174 | player.command("script-message", "shim-menu-enable", "False") 175 | 176 | if player.playback_abort: 177 | player.play("") 178 | else: 179 | player.pause = False 180 | 181 | self.is_menu_shown = False 182 | 183 | def put_menu(self, title, entries=None, selected=0): 184 | if entries is None: 185 | entries = [] 186 | 187 | self.menu_stack.put((self.menu_title, self.menu_list, self.menu_selection)) 188 | self.menu_title = title 189 | self.menu_list = entries 190 | self.menu_selection = selected 191 | 192 | def menu_action(self, action): 193 | if not self.is_menu_shown and action in ("home", "ok"): 194 | self.show_menu() 195 | else: 196 | if action == "up": 197 | self.menu_selection = (self.menu_selection - 1) % len(self.menu_list) 198 | elif action == "down": 199 | self.menu_selection = (self.menu_selection + 1) % len(self.menu_list) 200 | elif action == "back": 201 | if self.menu_stack.empty(): 202 | self.hide_menu() 203 | else: 204 | self.menu_title, self.menu_list, self.menu_selection = self.menu_stack.get_nowait() 205 | elif action == "ok": 206 | if self.mouse_back: 207 | self.menu_action("back") 208 | else: 209 | self.menu_list[self.menu_selection][1]() 210 | elif action == "home": 211 | self.show_menu() 212 | self.mouse_back = False 213 | self.refresh_menu() 214 | 215 | def change_audio_menu_handle(self): 216 | if self.playerManager._media_item.is_transcode: 217 | self.playerManager.put_task(self.playerManager.set_streams, self.menu_list[self.menu_selection][2], None) 218 | self.playerManager.timeline_handle() 219 | else: 220 | self.playerManager.set_streams(self.menu_list[self.menu_selection][2], None) 221 | self.menu_action("back") 222 | 223 | def change_audio_menu(self): 224 | self.put_menu("Select Audio Track") 225 | 226 | selected_aid, _ = self.playerManager.get_track_ids() 227 | audio_streams = self.playerManager._media_item._part_node.findall("./Stream[@streamType='2']") 228 | for i, audio_track in enumerate(audio_streams): 229 | aid = audio_track.get("id") 230 | self.menu_list.append([ 231 | "{0} ({1})".format(audio_track.get("displayTitle"), audio_track.get("title")), 232 | self.change_audio_menu_handle, 233 | aid 234 | ]) 235 | if aid == selected_aid: 236 | self.menu_selection = i 237 | 238 | def change_subtitle_menu_handle(self): 239 | if self.playerManager._media_item.is_transcode: 240 | self.playerManager.put_task(self.playerManager.set_streams, None, self.menu_list[self.menu_selection][2]) 241 | self.playerManager.timeline_handle() 242 | else: 243 | self.playerManager.set_streams(None, self.menu_list[self.menu_selection][2]) 244 | self.menu_action("back") 245 | 246 | def change_subtitle_menu(self): 247 | self.put_menu("Select Subtitle Track") 248 | 249 | _, selected_sid = self.playerManager.get_track_ids() 250 | subtitle_streams = self.playerManager._media_item._part_node.findall("./Stream[@streamType='3']") 251 | self.menu_list.append(["None", self.change_subtitle_menu_handle, "0"]) 252 | for i, subtitle_track in enumerate(subtitle_streams): 253 | sid = subtitle_track.get("id") 254 | self.menu_list.append([ 255 | "{0} ({1})".format(subtitle_track.get("displayTitle"), subtitle_track.get("title")), 256 | self.change_subtitle_menu_handle, 257 | sid 258 | ]) 259 | if sid == selected_sid: 260 | self.menu_selection = i+1 261 | 262 | def change_transcode_quality_handle(self): 263 | bitrate = self.menu_list[self.menu_selection][2] 264 | if bitrate == "none": 265 | self.playerManager._media_item.set_trs_override(None, False, False) 266 | elif bitrate == "max": 267 | self.playerManager._media_item.set_trs_override(None, True, False) 268 | else: 269 | self.playerManager._media_item.set_trs_override(bitrate, True, True) 270 | 271 | self.menu_action("back") 272 | self.playerManager.put_task(self.playerManager.restart_playback) 273 | self.playerManager.timeline_handle() 274 | 275 | def change_transcode_quality(self): 276 | handle = self.change_transcode_quality_handle 277 | self.put_menu("Select Transcode Quality", [ 278 | ("No Transcode", handle, "none"), 279 | ("Maximum", handle, "max") 280 | ]) 281 | 282 | for item in TRANSCODE_LEVELS: 283 | self.menu_list.append((item[0], handle, item[1])) 284 | 285 | self.menu_selection = 7 286 | cur_bitrate = self.playerManager._media_item.get_transcode_bitrate() 287 | for i, option in enumerate(self.menu_list): 288 | if cur_bitrate == option[2]: 289 | self.menu_selection = i 290 | 291 | def change_tracks_handle(self): 292 | mode = self.menu_list[self.menu_selection][2] 293 | parentSeriesKey = self.playerManager._media_item.parent.tree.find("./").get("parentKey") + "/children" 294 | url = self.playerManager._media_item.parent.get_path(parentSeriesKey) 295 | process_series(mode, url, self.playerManager) 296 | 297 | def change_tracks_manual_s1(self): 298 | self.change_audio_menu() 299 | for item in self.menu_list: 300 | item[1] = self.change_tracks_manual_s2 301 | 302 | def change_tracks_manual_s2(self): 303 | self.menu_tmp = self.menu_selection 304 | self.change_subtitle_menu() 305 | for item in self.menu_list: 306 | item[1] = self.change_tracks_manual_s3 307 | 308 | def change_tracks_manual_s3(self): 309 | aid, sid = self.menu_tmp, self.menu_selection - 1 310 | # Pop 3 menu items. 311 | for i in range(3): 312 | self.menu_action("back") 313 | parentSeriesKey = self.playerManager._media_item.parent.tree.find("./").get("parentKey") + "/children" 314 | url = self.playerManager._media_item.parent.get_path(parentSeriesKey) 315 | process_series("manual", url, self.playerManager, aid, sid) 316 | 317 | def change_tracks_menu(self): 318 | self.put_menu("Select Audio/Subtitle for Series", [ 319 | ("English Audio", self.change_tracks_handle, "dubbed"), 320 | ("Japanese Audio w/ English Subtitles", self.change_tracks_handle, "subbed"), 321 | ("Manual by Track Index (Less Reliable)", self.change_tracks_manual_s1), 322 | ]) 323 | 324 | def settings_toggle_bool(self): 325 | _, _, key, name = self.menu_list[self.menu_selection] 326 | setattr(settings, key, not getattr(settings, key)) 327 | settings.save() 328 | self.menu_list[self.menu_selection] = self.get_settings_toggle(name, key) 329 | 330 | def get_settings_toggle(self, name, setting): 331 | return ( 332 | "{0}: {1}".format(name, getattr(settings, setting)), 333 | self.settings_toggle_bool, 334 | setting, 335 | name 336 | ) 337 | 338 | def transcode_settings_handle(self): 339 | settings.transcode_kbps = self.menu_list[self.menu_selection][2] 340 | settings.save() 341 | 342 | # Need to re-render preferences menu. 343 | for i in range(2): 344 | self.menu_action("back") 345 | self.preferences_menu() 346 | 347 | def transcode_settings_menu(self): 348 | self.put_menu("Select Default Transcode Profile") 349 | handle = self.transcode_settings_handle 350 | 351 | for i, item in enumerate(TRANSCODE_LEVELS): 352 | self.menu_list.append((item[0], handle, item[1])) 353 | if settings.transcode_kbps == item[1]: 354 | self.menu_selection = i 355 | 356 | def get_subtitle_color(self, color): 357 | if color in HEX_TO_COLOR: 358 | return HEX_TO_COLOR[color] 359 | else: 360 | return mpv_color_to_plex(color) 361 | 362 | def sub_settings_handle(self): 363 | setting_name = self.menu_list[self.menu_selection][2] 364 | value = self.menu_list[self.menu_selection][3] 365 | setattr(settings, setting_name, value) 366 | settings.save() 367 | 368 | # Need to re-render preferences menu. 369 | for i in range(2): 370 | self.menu_action("back") 371 | self.preferences_menu() 372 | 373 | if self.playerManager._media_item.is_transcode: 374 | if setting_name == "subtitle_size": 375 | self.playerManager.put_task(self.playerManager.update_subtitle_visuals) 376 | else: 377 | self.playerManager.update_subtitle_visuals() 378 | 379 | def subtitle_color_menu(self): 380 | self.put_menu("Select Subtitle Color", [ 381 | (name, self.sub_settings_handle, "subtitle_color", color) 382 | for name, color in COLOR_LIST 383 | ]) 384 | 385 | def subtitle_size_menu(self): 386 | self.put_menu("Select Subtitle Size", [ 387 | (name, self.sub_settings_handle, "subtitle_size", size) 388 | for name, size in SIZE_LIST 389 | ], selected=2) 390 | 391 | def subtitle_position_menu(self): 392 | self.put_menu("Select Subtitle Position", [ 393 | ("Bottom", self.sub_settings_handle, "subtitle_position", "bottom"), 394 | ("Top", self.sub_settings_handle, "subtitle_position", "top"), 395 | ("Middle", self.sub_settings_handle, "subtitle_position", "middle"), 396 | ]) 397 | 398 | def preferences_menu(self): 399 | options = [ 400 | self.get_settings_toggle("Always Skip Intros", "skip_intro_always"), 401 | self.get_settings_toggle("Ask to Skip Intros", "skip_intro_prompt"), 402 | ("Transcode Quality: {0:0.1f} Mbps".format(settings.transcode_kbps/1000), self.transcode_settings_menu), 403 | ("Subtitle Size: {0}".format(settings.subtitle_size), self.subtitle_size_menu), 404 | ("Subtitle Position: {0}".format(settings.subtitle_position), self.subtitle_position_menu), 405 | ("Subtitle Color: {0}".format(self.get_subtitle_color(settings.subtitle_color)), self.subtitle_color_menu), 406 | self.get_settings_toggle("Auto Play", "auto_play"), 407 | self.get_settings_toggle("Always Transcode", "always_transcode"), 408 | self.get_settings_toggle("Adaptive Transcode", "adaptive_transcode"), 409 | self.get_settings_toggle("Always Transcode", "always_transcode"), 410 | self.get_settings_toggle("Limit Direct Play", "direct_limit"), 411 | self.get_settings_toggle("Auto Fullscreen", "fullscreen"), 412 | self.get_settings_toggle("Media Key Seek", "media_key_seek"), 413 | ] 414 | 415 | if self.profile_menu is not None: 416 | options.append( 417 | ( 418 | "Video Profile Subtype: {0}".format( 419 | settings.shader_pack_subtype 420 | ), 421 | self.shader_pack_subtype_menu, 422 | ), 423 | ) 424 | 425 | self.put_menu("Preferences", options) 426 | 427 | def shader_pack_subtype_menu(self): 428 | self.put_menu( 429 | "Video Profile Subtype", 430 | [ 431 | (option, self.shader_pack_subtype_handle) 432 | for option in self.profile_manager.profile_subtypes 433 | ], 434 | ) 435 | 436 | def shader_pack_subtype_handle(self): 437 | option_value = self.menu_list[self.menu_selection][0] 438 | settings.shader_pack_subtype = option_value 439 | settings.save() 440 | 441 | # Need to re-render preferences menu. 442 | for i in range(2): 443 | self.menu_action("back") 444 | self.video_preferences_menu() 445 | 446 | def unwatched_menu_handle(self): 447 | self.playerManager.put_task(self.playerManager.unwatched_quit) 448 | self.playerManager.timeline_handle() 449 | -------------------------------------------------------------------------------- /plex_mpv_shim/mouse.lua: -------------------------------------------------------------------------------- 1 | last_idx = -1 2 | function mouse_handler() 3 | local x, y = mp.get_mouse_pos() 4 | local hy = mp.get_property_native("osd-height") 5 | if hy == nil 6 | then 7 | return 8 | end 9 | idx = math.floor((y * 1000 / hy - 33) / 55) 10 | if idx ~= last_idx 11 | then 12 | last_idx = idx 13 | mp.commandv("script-message", "shim-menu-select", idx) 14 | end 15 | end 16 | 17 | function mouse_click_handler() 18 | last_idx = -1 -- Force refresh. 19 | mouse_handler() 20 | mp.commandv("script-message", "shim-menu-click") 21 | end 22 | 23 | function client_message_handler(event) 24 | if event["args"][1] == "shim-menu-enable" 25 | then 26 | if event["args"][2] == "True" 27 | then 28 | mp.log("info", "Enabled shim menu mouse events.") 29 | mp.add_key_binding("MOUSE_BTN0", "shim_mouse_click_handler", mouse_click_handler) 30 | mp.add_key_binding("MOUSE_MOVE", "shim_mouse_move_handler", mouse_handler) 31 | else 32 | mp.log("info", "Disabled shim menu mouse events.") 33 | mp.remove_key_binding("shim_mouse_click_handler") 34 | mp.remove_key_binding("shim_mouse_move_handler") 35 | end 36 | end 37 | end 38 | 39 | mp.add_key_binding("MOUSE_MOVE", "shim_mouse_move_handler", mouse_handler) 40 | mp.register_event("client-message", client_message_handler) 41 | -------------------------------------------------------------------------------- /plex_mpv_shim/mpv_shim.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import logging 4 | import sys 5 | import time 6 | import os.path 7 | import multiprocessing 8 | 9 | logging.basicConfig(level=logging.DEBUG, stream=sys.stdout, format="%(asctime)s [%(levelname)8s] %(name)s: %(message)s") 10 | 11 | from . import conffile 12 | from .conf import settings 13 | from .gdm import gdm 14 | 15 | HTTP_PORT = 3000 16 | APP_NAME = 'plex-mpv-shim' 17 | 18 | log = logging.getLogger('') 19 | 20 | logging.getLogger('requests').setLevel(logging.CRITICAL) 21 | 22 | def update_gdm_settings(name=None, value=None): 23 | gdm.clientDetails(settings.client_uuid, settings.player_name, 24 | settings.http_port, "Plex MPV Shim", "1.0") 25 | 26 | def main(): 27 | conf_file = conffile.get(APP_NAME,'conf.json') 28 | if os.path.isfile('settings.dat'): 29 | settings.migrate_config('settings.dat', conf_file) 30 | settings.load(conf_file) 31 | settings.add_listener(update_gdm_settings) 32 | 33 | if sys.platform.startswith("darwin"): 34 | multiprocessing.set_start_method('forkserver') 35 | 36 | use_gui = False 37 | if settings.enable_gui: 38 | try: 39 | from .gui_mgr import userInterface 40 | use_gui = True 41 | except Exception: 42 | log.warning("Cannot load GUI. Falling back to command line interface.", exc_info=1) 43 | 44 | if not use_gui: 45 | from .cli_mgr import userInterface 46 | 47 | from .player import playerManager 48 | from .timeline import timelineManager 49 | from .action_thread import actionThread 50 | from .client import HttpServer 51 | 52 | update_gdm_settings() 53 | gdm.start_all() 54 | 55 | log.info("Started GDM service") 56 | 57 | server = HttpServer(int(settings.http_port)) 58 | server.start() 59 | 60 | timelineManager.start() 61 | playerManager.timeline_trigger = timelineManager.trigger 62 | actionThread.start() 63 | playerManager.action_trigger = actionThread.trigger 64 | userInterface.open_player_menu = playerManager.menu.show_menu 65 | 66 | try: 67 | userInterface.run() 68 | except KeyboardInterrupt: 69 | print("") 70 | log.info("Stopping services...") 71 | finally: 72 | playerManager.terminate() 73 | server.stop() 74 | timelineManager.stop() 75 | actionThread.stop() 76 | gdm.stop_all() 77 | 78 | if __name__ == "__main__": 79 | main() 80 | 81 | -------------------------------------------------------------------------------- /plex_mpv_shim/player.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import sys 4 | import requests 5 | import urllib.parse 6 | 7 | from threading import RLock, Lock 8 | from queue import Queue 9 | from collections import OrderedDict 10 | 11 | from . import conffile 12 | from .utils import synchronous, Timer, get_resource 13 | from .conf import settings 14 | from .menu import OSDMenu 15 | from .media import MediaType 16 | 17 | log = logging.getLogger('player') 18 | mpv_log = logging.getLogger('mpv') 19 | 20 | python_mpv_available=True 21 | is_using_ext_mpv=False 22 | if not settings.mpv_ext: 23 | try: 24 | import mpv 25 | log.info("Using libmpv1 playback backend.") 26 | except OSError: 27 | log.warning("Could not find libmpv1.") 28 | python_mpv_available=False 29 | 30 | if settings.mpv_ext or not python_mpv_available: 31 | import python_mpv_jsonipc as mpv 32 | log.info("Using external mpv playback backend.") 33 | is_using_ext_mpv=True 34 | 35 | APP_NAME = 'plex-mpv-shim' 36 | 37 | SUBTITLE_POS = { 38 | "top": 0, 39 | "bottom": 100, 40 | "middle": 80, 41 | } 42 | 43 | mpv_log_levels = { 44 | "fatal": mpv_log.error, 45 | "error": mpv_log.error, 46 | "warn": mpv_log.warning, 47 | "info": mpv_log.info 48 | } 49 | 50 | def mpv_log_handler(level, prefix, text): 51 | if level in mpv_log_levels: 52 | mpv_log_levels[level]("{0}: {1}".format(prefix, text)) 53 | else: 54 | mpv_log.debug("{0}: {1}".format(prefix, text)) 55 | 56 | win_utils = None 57 | if sys.platform.startswith("win32") or sys.platform.startswith("cygwin"): 58 | try: 59 | from . import win_utils 60 | except ModuleNotFoundError: 61 | log.warning("win_utils is not available.") 62 | 63 | # Q: What is with the put_task call? 64 | # A: Some calls to python-mpv require event processing. 65 | # put_task is used to deal with the events originating from 66 | # the event thread, which would cause deadlock if they run there. 67 | 68 | class PlayerManager(object): 69 | """ 70 | Manages the relationship between a ``Player`` instance and a ``Media`` 71 | item. This is designed to be used as a singleton via the ``playerManager`` 72 | instance in this module. All communication between a caller and either the 73 | current ``player`` or ``media`` instance should be done through this class 74 | for thread safety reasons as all methods that access the ``player`` or 75 | ``media`` are thread safe. 76 | """ 77 | def __init__(self): 78 | mpv_options = OrderedDict() 79 | self._media_item = None 80 | self._lock = RLock() 81 | self._finished_lock = Lock() 82 | self.last_update = Timer() 83 | self.__part = 1 84 | self.timeline_trigger = None 85 | self.action_trigger = None 86 | self.external_subtitles = {} 87 | self.external_subtitles_rev = {} 88 | self.url = None 89 | self.evt_queue = Queue() 90 | self.is_in_intro = False 91 | self.is_in_credits = False 92 | self.intro_has_triggered = False 93 | self.credits_has_triggered = False 94 | 95 | if is_using_ext_mpv: 96 | mpv_options.update( 97 | { 98 | "start_mpv": settings.mpv_ext_start, 99 | "ipc_socket": settings.mpv_ext_ipc, 100 | "mpv_location": settings.mpv_ext_path, 101 | "player-operation-mode": "cplayer" 102 | } 103 | ) 104 | 105 | if settings.menu_mouse: 106 | if is_using_ext_mpv: 107 | mpv_options["script"] = get_resource("mouse.lua") 108 | else: 109 | mpv_options["scripts"] = get_resource("mouse.lua") 110 | 111 | if not (settings.mpv_ext and settings.mpv_ext_no_ovr): 112 | mpv_options["include"] = conffile.get(APP_NAME, "mpv.conf", True) 113 | mpv_options["input_conf"] = conffile.get(APP_NAME, "input.conf", True) 114 | 115 | self._player = mpv.MPV(input_default_bindings=True, input_vo_keyboard=True, 116 | input_media_keys=True, log_handler=mpv_log_handler, 117 | loglevel=settings.mpv_log_level, **mpv_options) 118 | self.menu = OSDMenu(self) 119 | if hasattr(self._player, 'osc'): 120 | self._player.osc = settings.enable_osc 121 | else: 122 | log.warning("This mpv version doesn't support on-screen controller.") 123 | 124 | # Wrapper for on_key_press that ignores None. 125 | def keypress(key): 126 | def wrapper(func): 127 | if key is not None: 128 | self._player.on_key_press(key)(func) 129 | return func 130 | return wrapper 131 | 132 | @self._player.on_key_press('CLOSE_WIN') 133 | @self._player.on_key_press('STOP') 134 | @keypress(settings.kb_stop) 135 | def handle_stop(): 136 | self.stop() 137 | self.timeline_handle() 138 | 139 | @keypress(settings.kb_prev) 140 | def handle_prev(): 141 | self.put_task(self.play_prev) 142 | 143 | @keypress(settings.kb_next) 144 | def handle_next(): 145 | self.put_task(self.play_next) 146 | 147 | @self._player.on_key_press('PREV') 148 | @self._player.on_key_press('XF86_PREV') 149 | def handle_media_prev(): 150 | if settings.media_key_seek: 151 | self._player.command("seek", -15) 152 | else: 153 | self.put_task(self.play_prev) 154 | 155 | @self._player.on_key_press('NEXT') 156 | @self._player.on_key_press('XF86_NEXT') 157 | def handle_media_next(): 158 | if settings.media_key_seek: 159 | if self.is_in_intro: 160 | self.skip_intro() 161 | elif self.is_in_credits: 162 | self.skip_credits() 163 | else: 164 | self._player.command("seek", 30) 165 | else: 166 | self.put_task(self.play_next) 167 | 168 | @keypress(settings.kb_watched) 169 | def handle_watched(): 170 | self.put_task(self.watched_skip) 171 | 172 | @keypress(settings.kb_unwatched) 173 | def handle_unwatched(): 174 | self.put_task(self.unwatched_quit) 175 | 176 | @keypress(settings.kb_menu) 177 | def menu_open(): 178 | if not self.menu.is_menu_shown: 179 | self.menu.show_menu() 180 | else: 181 | self.menu.hide_menu() 182 | 183 | @keypress(settings.kb_menu_esc) 184 | def menu_back(): 185 | if self.menu.is_menu_shown: 186 | self.menu.menu_action('back') 187 | else: 188 | self._player.command('set', 'fullscreen', 'no') 189 | 190 | @keypress(settings.kb_menu_ok) 191 | def menu_ok(): 192 | self.menu.menu_action('ok') 193 | 194 | @keypress(settings.kb_menu_left) 195 | def menu_left(): 196 | if self.menu.is_menu_shown: 197 | self.menu.menu_action('left') 198 | else: 199 | self._player.command("seek", settings.seek_left) 200 | 201 | @keypress(settings.kb_menu_right) 202 | def menu_right(): 203 | if self.menu.is_menu_shown: 204 | self.menu.menu_action('right') 205 | else: 206 | if self.is_in_intro: 207 | self.skip_intro() 208 | elif self.is_in_credits: 209 | self.skip_credits() 210 | else: 211 | self._player.command("seek", settings.seek_right) 212 | 213 | @keypress(settings.kb_menu_up) 214 | def menu_up(): 215 | if self.menu.is_menu_shown: 216 | self.menu.menu_action('up') 217 | else: 218 | if self.is_in_intro: 219 | self.skip_intro() 220 | elif self.is_in_credits: 221 | self.skip_credits() 222 | else: 223 | self._player.command("seek", settings.seek_up) 224 | 225 | @keypress(settings.kb_menu_down) 226 | def menu_down(): 227 | if self.menu.is_menu_shown: 228 | self.menu.menu_action('down') 229 | else: 230 | self._player.command("seek", settings.seek_down) 231 | 232 | @keypress(settings.kb_pause) 233 | def handle_pause(): 234 | if self.menu.is_menu_shown: 235 | self.menu.menu_action('ok') 236 | else: 237 | self.toggle_pause() 238 | 239 | # This gives you an interactive python debugger prompt. 240 | @keypress(settings.kb_debug) 241 | def handle_debug(): 242 | import pdb 243 | pdb.set_trace() 244 | 245 | # Fires between episodes. 246 | @self._player.property_observer('eof-reached') 247 | def handle_end(_name, reached_end): 248 | if self._media_item and reached_end: 249 | has_lock = self._finished_lock.acquire(False) 250 | self.put_task(self.finished_callback, has_lock) 251 | 252 | # Fires at the end. 253 | @self._player.property_observer("playback-abort") 254 | def handle_end_idle(_name, value): 255 | if self._media_item and value: 256 | has_lock = self._finished_lock.acquire(False) 257 | self.put_task(self.finished_callback, has_lock) 258 | 259 | @self._player.event_callback('client-message') 260 | def handle_client_message(event): 261 | try: 262 | # Python-MPV 1.0 uses a class/struct combination now 263 | if hasattr(event, "as_dict"): 264 | event = event.as_dict() 265 | if 'event' in event: 266 | event['event'] = event['event'].decode('utf-8') 267 | if 'args' in event: 268 | event['args'] = [d.decode('utf-8') for d in event['args']] 269 | 270 | if "event_id" in event: 271 | args = event["event"]["args"] 272 | else: 273 | args = event["args"] 274 | if len(args) == 0: 275 | return 276 | if args[0] == "shim-menu-select": 277 | # Apparently this can happen... 278 | if args[1] == "inf": 279 | return 280 | self.menu.mouse_select(int(args[1])) 281 | elif args[0] == "shim-menu-click": 282 | self.menu.menu_action("ok") 283 | except Exception: 284 | log.warning("Error when processing client-message.", exc_info=True) 285 | 286 | # Put a task to the event queue. 287 | # This ensures the task executes outside 288 | # of an event handler, which causes a crash. 289 | def put_task(self, func, *args): 290 | self.evt_queue.put([func, args]) 291 | if self.action_trigger: 292 | self.action_trigger.set() 293 | 294 | # Trigger the timeline to update all 295 | # clients immediately. 296 | def timeline_handle(self): 297 | if self.timeline_trigger: 298 | self.timeline_trigger.set() 299 | 300 | def skip_marker(self, end_point): 301 | if self._media_item.media_type == MediaType.VIDEO: 302 | self._player.playback_time = end_point 303 | self.timeline_handle() 304 | return True 305 | return False 306 | 307 | def skip_intro(self): 308 | end_point = self._media_item.intro_end 309 | if self.skip_marker(end_point): 310 | self.is_in_intro = False 311 | 312 | def skip_credits(self): 313 | end_point = self._media_item.credits_end 314 | if self.skip_marker(end_point): 315 | self.is_in_credits = False 316 | 317 | def check_intro_or_credits(self): 318 | if ((settings.skip_intro_always or settings.skip_intro_prompt) 319 | and self._media_item is not None and self._media_item.media_type == MediaType.VIDEO and self._media_item.intro_start is not None 320 | and self._player.playback_time is not None 321 | and self._player.playback_time > self._media_item.intro_start 322 | and self._player.playback_time < self._media_item.intro_end): 323 | 324 | if not self.is_in_intro: 325 | if settings.skip_intro_always and not self.intro_has_triggered: 326 | self.intro_has_triggered = True 327 | self.skip_intro() 328 | self._player.show_text("Skipped Intro", 3000, 1) 329 | elif settings.skip_intro_prompt: 330 | self._player.show_text("Seek to Skip Intro", 3000, 1) 331 | self.is_in_intro = True 332 | else: 333 | self.is_in_intro = False 334 | 335 | # TODO de-duplicate this code in some way - it's ugly 336 | if ((settings.skip_credits_always or settings.skip_credits_prompt) 337 | and self._media_item is not None and self._media_item.media_type == MediaType.VIDEO and self._media_item.credits_start is not None 338 | and self._player.playback_time is not None 339 | and self._player.playback_time > self._media_item.credits_start 340 | and self._player.playback_time < self._media_item.credits_end): 341 | 342 | if not self.is_in_credits: 343 | if settings.skip_credits_always and not self.credits_has_triggered: 344 | self.credits_has_triggered = True 345 | self.skip_credits() 346 | self._player.show_text("Skipped Credits", 3000, 1) 347 | elif settings.skip_credits_prompt: 348 | self._player.show_text("Seek to Skip Credits", 3000, 1) 349 | self.is_in_credits = True 350 | else: 351 | self.is_in_credits = False 352 | 353 | 354 | @synchronous('_lock') 355 | def update(self): 356 | self.check_intro_or_credits() 357 | while not self.evt_queue.empty(): 358 | func, args = self.evt_queue.get() 359 | func(*args) 360 | if self._media_item and not self._player.playback_abort: 361 | if not self.is_paused(): 362 | self.last_update.restart() 363 | 364 | def play(self, media_item, offset=0): 365 | url = media_item.get_playback_url() 366 | if not url: 367 | log.error("PlayerManager::play no URL found") 368 | return 369 | 370 | self._play_media(media_item, url, offset) 371 | 372 | @synchronous('_lock') 373 | def _play_media(self, media_item, url, offset=0): 374 | self.url = url 375 | self.menu.hide_menu() 376 | 377 | if settings.log_decisions: 378 | log.debug("Playing: {0}".format(url)) 379 | 380 | self._player.play(self.url) 381 | self._player.wait_for_property("duration") 382 | if settings.fullscreen: 383 | self._player.fs = True 384 | self._player.force_media_title = media_item.get_proper_title() 385 | self._media_item = media_item 386 | self.is_in_intro = False 387 | self.is_in_credits = False 388 | self.intro_has_triggered = False 389 | self.credits_has_triggered = False 390 | self.update_subtitle_visuals(False) 391 | self.upd_player_hide() 392 | self.external_subtitles = {} 393 | self.external_subtitles_rev = {} 394 | 395 | if win_utils: 396 | win_utils.raise_mpv() 397 | 398 | if offset > 0: 399 | self._player.playback_time = offset 400 | 401 | if media_item.media_type == MediaType.VIDEO and not media_item.is_transcode: 402 | audio_idx = media_item.get_audio_idx() 403 | if audio_idx is not None: 404 | log.debug("PlayerManager::play selecting audio stream index=%s" % audio_idx) 405 | self._player.audio = audio_idx 406 | 407 | sub_idx = media_item.get_subtitle_idx() 408 | xsub_id = media_item.get_external_sub_id() 409 | if sub_idx is not None: 410 | log.debug("PlayerManager::play selecting subtitle index=%s" % sub_idx) 411 | self._player.sub = sub_idx 412 | elif xsub_id is not None: 413 | log.debug("PlayerManager::play selecting external subtitle id=%s" % xsub_id) 414 | self.load_external_sub(xsub_id) 415 | else: 416 | self._player.sub = 'no' 417 | 418 | self._player.pause = False 419 | self.timeline_handle() 420 | if self._finished_lock.locked(): 421 | self._finished_lock.release() 422 | 423 | def exec_stop_cmd(self): 424 | if settings.stop_cmd: 425 | os.system(settings.stop_cmd) 426 | 427 | @synchronous('_lock') 428 | def stop(self, playend=False): 429 | if not playend and (not self._media_item or self._player.playback_abort): 430 | self.exec_stop_cmd() 431 | return 432 | 433 | if not playend: 434 | log.debug("PlayerManager::stop stopping playback of %s" % self._media_item) 435 | 436 | if self._media_item.media_type == MediaType.VIDEO: 437 | self._media_item.terminate_transcode() 438 | 439 | self._media_item = None 440 | self._player.command("stop") 441 | self._player.pause = False 442 | self.timeline_handle() 443 | 444 | if not playend: 445 | self.exec_stop_cmd() 446 | 447 | @synchronous('_lock') 448 | def get_volume(self, percent=False): 449 | if self._player: 450 | if not percent: 451 | return self._player.volume / 100 452 | return self._player.volume 453 | 454 | @synchronous('_lock') 455 | def toggle_pause(self): 456 | if not self._player.playback_abort: 457 | self._player.pause = not self._player.pause 458 | self.timeline_handle() 459 | 460 | @synchronous('_lock') 461 | def seek(self, offset): 462 | """ 463 | Seek to ``offset`` seconds 464 | """ 465 | if not self._player.playback_abort: 466 | if self.is_in_intro and offset > self._player.playback_time: 467 | self.skip_intro() 468 | elif self.is_in_credits and offset > self._player.playback_time: 469 | self.skip_credits() 470 | else: 471 | self._player.playback_time = offset 472 | self.timeline_handle() 473 | 474 | @synchronous('_lock') 475 | def set_volume(self, pct): 476 | if not self._player.playback_abort: 477 | self._player.volume = pct 478 | self.timeline_handle() 479 | 480 | @synchronous('_lock') 481 | def get_state(self): 482 | if self._player.playback_abort: 483 | return "stopped" 484 | 485 | if self._player.pause: 486 | return "paused" 487 | 488 | return "playing" 489 | 490 | @synchronous('_lock') 491 | def is_paused(self): 492 | if not self._player.playback_abort: 493 | return self._player.pause 494 | return False 495 | 496 | @synchronous('_lock') 497 | def finished_callback(self, has_lock): 498 | if not self._media_item: 499 | return 500 | 501 | self._media_item.set_played() 502 | 503 | if self._media_item.is_multipart(): 504 | if has_lock: 505 | log.debug("PlayerManager::finished_callback media is multi-part, checking for next part") 506 | # Try to select the next part 507 | next_part = self.__part+1 508 | if self._media_item.select_part(next_part): 509 | self.__part = next_part 510 | log.debug("PlayerManager::finished_callback starting next part") 511 | self.play(self._media_item) 512 | else: 513 | log.debug("PlayerManager::finished_callback No lock, skipping...") 514 | 515 | elif self._media_item.parent.has_next and settings.auto_play: 516 | if has_lock: 517 | log.debug("PlayerManager::finished_callback starting next episode") 518 | self.play(self._media_item.parent.get_next().get_media_item(0)) 519 | else: 520 | log.debug("PlayerManager::finished_callback No lock, skipping...") 521 | 522 | else: 523 | if settings.media_ended_cmd: 524 | os.system(settings.media_ended_cmd) 525 | log.debug("PlayerManager::finished_callback reached end") 526 | self.stop(playend=True) 527 | 528 | @synchronous('_lock') 529 | def watched_skip(self): 530 | if not self._media_item: 531 | return 532 | 533 | self._media_item.set_played() 534 | self.play_next() 535 | 536 | @synchronous('_lock') 537 | def unwatched_quit(self): 538 | if not self._media_item: 539 | return 540 | 541 | self._media_item.set_played(False) 542 | self.stop() 543 | 544 | @synchronous('_lock') 545 | def play_next(self): 546 | if self._media_item.parent.has_next: 547 | self.play(self._media_item.parent.get_next().get_media_item(0)) 548 | return True 549 | return False 550 | 551 | @synchronous('_lock') 552 | def skip_to(self, key): 553 | media = self._media_item.parent.get_from_key(key) 554 | if media: 555 | self.play(media.get_media_item(0)) 556 | return True 557 | return False 558 | 559 | @synchronous('_lock') 560 | def play_prev(self): 561 | if self._media_item.parent.has_prev: 562 | self.play(self._media_item.parent.get_prev().get_media_item(0)) 563 | return True 564 | return False 565 | 566 | @synchronous('_lock') 567 | def restart_playback(self): 568 | current_time = self._player.playback_time 569 | self.play(self._media_item, current_time) 570 | return True 571 | 572 | @synchronous('_lock') 573 | def get_media_item_attr(self, attr, default=None): 574 | if self._media_item: 575 | return self._media_item.get_media_item_attr(attr, default) 576 | return default 577 | 578 | @synchronous('_lock') 579 | def set_streams(self, audio_uid, sub_uid): 580 | if not self._media_item.is_transcode: 581 | if audio_uid is not None: 582 | log.debug("PlayerManager::play selecting audio stream index=%s" % audio_uid) 583 | self._player.audio = self._media_item.audio_seq[audio_uid] 584 | 585 | if sub_uid == '0': 586 | log.debug("PlayerManager::play selecting subtitle stream (none)") 587 | self._player.sub = 'no' 588 | elif sub_uid is not None: 589 | log.debug("PlayerManager::play selecting subtitle stream index=%s" % sub_uid) 590 | if sub_uid in self._media_item.subtitle_seq: 591 | self._player.sub = self._media_item.subtitle_seq[sub_uid] 592 | else: 593 | log.debug("PlayerManager::play selecting external subtitle id=%s" % sub_uid) 594 | self.load_external_sub(sub_uid) 595 | 596 | self._media_item.set_streams(audio_uid, sub_uid) 597 | 598 | if self._media_item.is_transcode: 599 | self.restart_playback() 600 | self.timeline_handle() 601 | 602 | @synchronous('_lock') 603 | def load_external_sub(self, sub_id): 604 | if sub_id in self.external_subtitles: 605 | self._player.sub = self.external_subtitles[sub_id] 606 | else: 607 | try: 608 | sub_url = self._media_item.get_external_sub(sub_id) 609 | if settings.log_decisions: 610 | log.debug("Load External Subtitle: {0}".format(sub_url)) 611 | self._player.sub_add(sub_url) 612 | self.external_subtitles[sub_id] = self._player.sub 613 | self.external_subtitles_rev[self._player.sub] = sub_id 614 | except SystemError: 615 | log.debug("PlayerManager::could not load external subtitle") 616 | 617 | def get_track_ids(self): 618 | if self._media_item.is_transcode: 619 | return self._media_item.get_transcode_streams() 620 | else: 621 | aid, sid = None, None 622 | if self._player.sub and self._player.sub != 'no': 623 | if self._player.sub in self.external_subtitles_rev: 624 | sid = self.external_subtitles_rev.get(self._player.sub, '') 625 | else: 626 | sid = self._media_item.subtitle_uid.get(self._player.sub, '') 627 | 628 | if self._player.audio != 'no': 629 | aid = self._media_item.audio_uid.get(self._player.audio, '') 630 | return aid, sid 631 | 632 | def update_subtitle_visuals(self, restart_transcode=True): 633 | if self._media_item.is_transcode: 634 | if restart_transcode: 635 | self.restart_playback() 636 | else: 637 | self._player.sub_pos = SUBTITLE_POS[settings.subtitle_position] 638 | self._player.sub_scale = settings.subtitle_size / 100 639 | self._player.sub_color = settings.subtitle_color 640 | self.timeline_handle() 641 | 642 | def upd_player_hide(self): 643 | self._player.keep_open = self._media_item.parent.has_next 644 | 645 | def terminate(self): 646 | self.stop() 647 | if is_using_ext_mpv: 648 | self._player.terminate() 649 | 650 | playerManager = PlayerManager() 651 | -------------------------------------------------------------------------------- /plex_mpv_shim/subscribers.py: -------------------------------------------------------------------------------- 1 | """ 2 | subscribers.py - Plex Remote Subscribers 3 | 4 | Modeled after Plex Home Theater Remote implementation: 5 | https://github.com/plexinc/plex-home-theater-public/blob/pht-frodo/plex/Remote/ 6 | """ 7 | import logging 8 | from threading import Event 9 | 10 | from .utils import Timer 11 | 12 | # give clients 90 seconds before we time them out 13 | SUBSCRIBER_REMOVE_INTERVAL = 90 14 | 15 | log = logging.getLogger('subscribers') 16 | subscriber_events = {} 17 | 18 | class RemoteSubscriberManager(object): 19 | subscribers = {} 20 | 21 | def addSubscriber(self, subscriber): 22 | if subscriber.uuid in self.subscribers: 23 | log.debug("RemoteSubscriberManager::addSubscriber refreshed %s" % subscriber.uuid) 24 | self.subscribers[subscriber.uuid].refresh(subscriber) 25 | else: 26 | log.debug("RemoteSubscriberManager::addSubscriber added %s [%s]" % (subscriber.url, subscriber.uuid)) 27 | self.subscribers[subscriber.uuid] = subscriber 28 | 29 | # timer.SetTimeout(PLEX_REMOTE_SUBSCRIBER_CHECK_INTERVAL * 1000, this); 30 | 31 | def updateSubscriberCommandID(self, subscriber): 32 | if subscriber.uuid in self.subscribers: 33 | self.subscribers[subscriber.uuid].commandID = subscriber.commandID 34 | 35 | def removeSubscriber(self, subscriber): 36 | if subscriber is not None and subscriber.uuid in self.subscribers: 37 | log.debug("RemoteSubscriberManager::removeSubscriber removing subscriber %s [%s]" % (subscriber.url, subscriber.uuid)) 38 | self.subscribers.pop(subscriber.uuid) 39 | 40 | def findSubscriberByUUID(self, uuid): 41 | if uuid in self.subscribers: 42 | return self.subscribers[uuid] 43 | 44 | def getSubscriberURL(self): 45 | urls = [] 46 | for uuid, subscriber in self.subscribers.items(): 47 | urls.append(subscriber.url) 48 | return urls 49 | 50 | class RemoteSubscriber(object): 51 | def __init__(self, uuid, commandID, ipaddress="", port=32400, protocol="http", name=""): 52 | self.poller = False 53 | self.uuid = uuid 54 | self.commandID = commandID 55 | self.url = "" 56 | self.name = name 57 | self.lastUpdated = Timer() 58 | 59 | if ipaddress and protocol: 60 | self.url = "%s://%s:%s" % (protocol, ipaddress, port) 61 | 62 | def refresh(self, sub): 63 | log.debug("RemoteSubscriber::refresh %s (cid=%s)" % (self.uuid, sub.commandID)) 64 | 65 | if sub.url != self.url: 66 | log.debug("RemoteSubscriber::refresh new url %s", sub.url) 67 | self.url = sub.url 68 | 69 | if sub.commandID != self.commandID: 70 | log.debug("RemoteSubscriber::refresh new commandID %s", sub.commandID) 71 | self.commandID = sub.commandID 72 | 73 | self.lastUpdated.restart() 74 | 75 | def get_poll_evt(self): 76 | if not self.uuid in subscriber_events: 77 | subscriber_events[self.uuid] = Event() 78 | return subscriber_events[self.uuid] 79 | 80 | def set_poll_evt(self): 81 | if self.uuid in subscriber_events: 82 | subscriber_events[self.uuid].set() 83 | subscriber_events[self.uuid] = Event() 84 | 85 | def shouldRemove(self): 86 | if self.lastUpdated.elapsed() > SUBSCRIBER_REMOVE_INTERVAL: 87 | log.debug("RemoteSubscriber::shouldRemove " 88 | "removing %s because elapsed: %d" % (self.uuid, self.lastUpdated.elapsed())) 89 | return True 90 | 91 | log.debug("RemoteSubscriber::shouldRemove will not " 92 | "remove %s because elapsed: %d" % (self.uuid, self.lastUpdated.elapsed())) 93 | return False 94 | 95 | 96 | remoteSubscriberManager = RemoteSubscriberManager() 97 | -------------------------------------------------------------------------------- /plex_mpv_shim/svp_integration.py: -------------------------------------------------------------------------------- 1 | from .conf import settings 2 | import urllib.request 3 | import urllib.error 4 | import logging 5 | import sys 6 | import time 7 | 8 | log = logging.getLogger('svp_integration') 9 | 10 | def list_request(path): 11 | try: 12 | response = urllib.request.urlopen(settings.svp_url + "?" + path) 13 | return response.read().decode('utf-8').replace('\r\n', '\n').split('\n') 14 | except urllib.error.URLError as ex: 15 | log.error("Could not reach SVP API server.", exc_info=1) 16 | return None 17 | 18 | def simple_request(path): 19 | response_list = list_request(path) 20 | if response_list is None: 21 | return None 22 | if len(response_list) != 1 or " = " not in response_list[0]: 23 | return None 24 | return response_list[0].split(" = ")[1] 25 | 26 | def get_profiles(): 27 | profile_ids = list_request("list=profiles") 28 | profiles = {} 29 | for profile_id in profile_ids: 30 | profile_id = profile_id.replace("profiles.", "") 31 | if profile_id == "predef": 32 | continue 33 | if profile_id == "P10000001_1001_1001_1001_100000000001": 34 | profile_name = "Automatic" 35 | else: 36 | profile_name = simple_request("profiles.{0}.title".format(profile_id)) 37 | if simple_request("profiles.{0}.on".format(profile_id)) == "false": 38 | continue 39 | profile_guid = "{" + profile_id[1:].replace("_", "-") + "}" 40 | profiles[profile_guid] = profile_name 41 | return profiles 42 | 43 | def get_name_from_guid(profile_id): 44 | profile_id = "P" + profile_id[1:-1].replace("-", "_") 45 | if profile_id == "P10000001_1001_1001_1001_100000000001": 46 | return "Automatic" 47 | else: 48 | return simple_request("profiles.{0}.title".format(profile_id)) 49 | 50 | def get_last_profile(): 51 | return simple_request("rt.playback.last_profile") 52 | 53 | def is_svp_alive(): 54 | try: 55 | response = list_request("") 56 | return response is not None 57 | except Exception: 58 | log.error("Could not reach SVP API server.", exc_info=1) 59 | return False 60 | 61 | def is_svp_enabled(): 62 | return simple_request("rt.disabled") == "false" 63 | 64 | def is_svp_active(): 65 | response = simple_request("rt.playback.active") 66 | if response is None: 67 | return False 68 | return response != "" 69 | 70 | def set_active_profile(profile_id): 71 | # As far as I know, there is no way to directly set the profile. 72 | if not is_svp_active(): 73 | return False 74 | if profile_id == get_last_profile(): 75 | return True 76 | for i in range(len(list_request("list=profiles"))): 77 | list_request("!profile_next") 78 | if get_last_profile() == profile_id: 79 | return True 80 | return False 81 | 82 | def set_disabled(disabled): 83 | return simple_request("rt.disabled={0}".format("true" if disabled else "false")) == "true" 84 | 85 | class SVPManager: 86 | def __init__(self, menu, playerManager): 87 | self.menu = menu 88 | 89 | if settings.svp_enable: 90 | socket = settings.svp_socket 91 | if socket is None: 92 | if sys.platform.startswith("win32") or sys.platform.startswith("cygwin"): 93 | socket = "mpvpipe" 94 | else: 95 | socket = "/tmp/mpvsocket" 96 | 97 | # This actually *adds* another ipc server. 98 | playerManager._player.input_ipc_server = socket 99 | 100 | if settings.svp_enable and not is_svp_alive(): 101 | log.error("SVP is not reachable. Please make sure you have the API enabled.") 102 | 103 | def is_available(self): 104 | if not settings.svp_enable: 105 | return False 106 | if not is_svp_alive(): 107 | return False 108 | return True 109 | 110 | def menu_set_profile(self): 111 | profile_id = self.menu.menu_list[self.menu.menu_selection][2] 112 | if profile_id is None: 113 | set_disabled(True) 114 | else: 115 | set_active_profile(profile_id) 116 | # Need to re-render menu. SVP has a race condition so we wait a second. 117 | time.sleep(1) 118 | self.menu.menu_action("back") 119 | self.menu_action() 120 | 121 | def menu_set_enabled(self): 122 | set_disabled(False) 123 | 124 | # Need to re-render menu. SVP has a race condition so we wait a second. 125 | time.sleep(1) 126 | self.menu.menu_action("back") 127 | self.menu_action() 128 | 129 | def menu_action(self): 130 | if is_svp_active(): 131 | selected = 0 132 | active_profile = get_last_profile() 133 | profile_option_list = [ 134 | ("Disabled", self.menu_set_profile, None) 135 | ] 136 | for i, (profile_id, profile_name) in enumerate(get_profiles().items()): 137 | profile_option_list.append( 138 | (profile_name, self.menu_set_profile, profile_id) 139 | ) 140 | if profile_id == active_profile: 141 | selected = i+1 142 | self.menu.put_menu("Select SVP Profile", profile_option_list, selected) 143 | else: 144 | if is_svp_enabled(): 145 | self.menu.put_menu("SVP is Not Active", [ 146 | ("Disable", self.menu_set_profile, None), 147 | ("Retry", self.menu_set_enabled) 148 | ], selected=1) 149 | else: 150 | self.menu.put_menu("SVP is Disabled", [ 151 | ("Enable SVP", self.menu_set_enabled) 152 | ]) 153 | -------------------------------------------------------------------------------- /plex_mpv_shim/systray.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iwalton3/plex-mpv-shim/fb1f1f3325285e33f9ce3425e9361f5f99277d9a/plex_mpv_shim/systray.png -------------------------------------------------------------------------------- /plex_mpv_shim/timeline.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import requests 3 | import threading 4 | import time 5 | import os 6 | from threading import Lock 7 | 8 | try: 9 | from xml.etree import cElementTree as et 10 | except: 11 | from xml.etree import ElementTree as et 12 | 13 | from io import BytesIO 14 | from multiprocessing.dummy import Pool 15 | 16 | from .conf import settings 17 | from .media import MediaType 18 | from .player import playerManager 19 | from .subscribers import remoteSubscriberManager 20 | from .utils import Timer, safe_urlopen, mpv_color_to_plex 21 | 22 | log = logging.getLogger("timeline") 23 | 24 | class TimelineManager(threading.Thread): 25 | def __init__(self): 26 | self.currentItems = {} 27 | self.currentStates = {} 28 | self.idleTimer = Timer() 29 | self.subTimer = Timer() 30 | self.serverTimer = Timer() 31 | self.stopped = False 32 | self.halt = False 33 | self.trigger = threading.Event() 34 | self.is_idle = True 35 | self.last_media_item = None 36 | self.sender_pool = Pool(5) 37 | self.sending_to_ps = Lock() 38 | self.last_server_url = None 39 | 40 | threading.Thread.__init__(self) 41 | 42 | def stop(self): 43 | self.halt = True 44 | self.sender_pool.close() 45 | self.join() 46 | 47 | def run(self): 48 | force_next = False 49 | while not self.halt: 50 | if (playerManager._player and playerManager._media_item and (not settings.idle_when_paused 51 | or not playerManager.is_paused())) or force_next: 52 | if not playerManager.is_paused() or force_next: 53 | self.SendTimelineToSubscribers() 54 | self.delay_idle() 55 | force_next = False 56 | if self.idleTimer.elapsed() > settings.idle_cmd_delay and not self.is_idle: 57 | if settings.idle_when_paused and settings.stop_idle and playerManager._media_item: 58 | playerManager.stop() 59 | if settings.idle_cmd: 60 | os.system(settings.idle_cmd) 61 | self.is_idle = True 62 | if self.trigger.wait(1): 63 | force_next = True 64 | self.trigger.clear() 65 | 66 | def delay_idle(self): 67 | self.idleTimer.restart() 68 | self.is_idle = False 69 | 70 | def SendTimelineToSubscribers(self): 71 | timeline = self.GetCurrentTimeline() 72 | 73 | # The sender_pool prevents the timeline from freezing 74 | # if a client times out or takes a while to respond. 75 | 76 | log.debug("TimelineManager::SendTimelineToSubscribers updating all subscribers") 77 | for sub in list(remoteSubscriberManager.subscribers.values()): 78 | self.sender_pool.apply_async(self.SendTimelineToSubscriber, (sub, timeline)) 79 | 80 | # Also send timeline to plex server. 81 | # Do not send the timeline if the last one if still sending. 82 | # (Plex servers can get overloaded... We don't want the UI to freeze.) 83 | # Note that we send anyway if the state is stopped. We don't want that to get lost. 84 | if self.sending_to_ps.acquire(False) or timeline["state"] == "stopped": 85 | self.sender_pool.apply_async(self.SendTimelineToPlexServer, (timeline,)) 86 | 87 | def SendTimelineToPlexServer(self, timeline): 88 | try: 89 | media_item = playerManager._media_item 90 | server_url = None 91 | if media_item: 92 | server_url = media_item.parent.server_url 93 | self.last_server_url = media_item.parent.server_url 94 | elif self.last_server_url: 95 | server_url = self.last_server_url 96 | if server_url: 97 | safe_urlopen("%s/:/timeline" % server_url, timeline, quiet=True) 98 | finally: 99 | self.sending_to_ps.release() 100 | 101 | def SendTimelineToSubscriber(self, subscriber, timeline=None): 102 | subscriber.set_poll_evt() 103 | if subscriber.url == "": 104 | return True 105 | 106 | timelineXML = self.GetCurrentTimeLinesXML(subscriber, timeline) 107 | url = "%s/:/timeline" % subscriber.url 108 | 109 | log.debug("TimelineManager::SendTimelineToSubscriber sending timeline to %s" % url) 110 | 111 | tree = et.ElementTree(timelineXML) 112 | tmp = BytesIO() 113 | tree.write(tmp, encoding="utf-8", xml_declaration=True) 114 | 115 | tmp.seek(0) 116 | xmlData = tmp.read() 117 | 118 | # TODO: Abstract this into a utility function and add other X-Plex-XXX fields 119 | try: 120 | requests.post(url, data=xmlData, headers={ 121 | "Content-Type": "application/x-www-form-urlencoded", 122 | "Connection": "keep-alive", 123 | "Content-Range": "bytes 0-/-1", 124 | "X-Plex-Client-Identifier": settings.client_uuid 125 | }, timeout=5) 126 | return True 127 | except requests.exceptions.ConnectTimeout: 128 | log.warning("TimelineManager::SendTimelineToSubscriber timeout sending to %s" % url) 129 | return False 130 | except Exception: 131 | log.warning("TimelineManager::SendTimelineToSubscriber error sending to %s" % url) 132 | return False 133 | 134 | def WaitForTimeline(self, subscriber): 135 | subscriber.get_poll_evt().wait(30) 136 | return self.GetCurrentTimeLinesXML(subscriber) 137 | 138 | def GetCurrentTimeLinesXML(self, subscriber, tlines=None): 139 | if tlines is None: 140 | tlines = self.GetCurrentTimeline() 141 | 142 | # 143 | # Only "video" is supported right now 144 | # 145 | mediaContainer = et.Element("MediaContainer") 146 | if subscriber.commandID is not None: 147 | mediaContainer.set("commandID", str(subscriber.commandID)) 148 | mediaContainer.set("location", tlines["location"]) 149 | 150 | lineEl = et.Element("Timeline") 151 | for key, value in list(tlines.items()): 152 | lineEl.set(key, str(value)) 153 | mediaContainer.append(lineEl) 154 | 155 | return mediaContainer 156 | 157 | def GetCurrentTimeline(self): 158 | # https://github.com/plexinc/plex-home-theater-public/blob/pht-frodo/plex/Client/PlexTimelineManager.cpp#L142 159 | # Note: location is set to "" to avoid pop-up of navigation menu. This may be abuse of the API. 160 | options = { 161 | "location": "", 162 | "state": playerManager.get_state(), 163 | "type": "video" 164 | } 165 | controllable = [] 166 | 167 | media_item = playerManager._media_item 168 | player = playerManager._player 169 | 170 | # The playback_time value can take on the value of none, probably 171 | # when playback is complete. This avoids the thread crashing. 172 | if media_item and not player.playback_abort and player.playback_time: 173 | self.last_media_item = media_item 174 | media = media_item.parent 175 | 176 | if media_item.media_type == MediaType.VIDEO: 177 | options["type"] = "video" 178 | options["location"] = "fullScreenVideo" 179 | elif media_item.media_type == MediaType.MUSIC: 180 | options["type"] = "video" 181 | options["location"] = "fullScreenMusic" 182 | 183 | options["time"] = int(player.playback_time * 1e3) 184 | options["autoPlay"] = '1' if settings.auto_play else '0' 185 | 186 | aid, sid = playerManager.get_track_ids() 187 | 188 | if aid: 189 | options["audioStreamID"] = aid 190 | if sid: 191 | options["subtitleStreamID"] = sid 192 | options["subtitleSize"] = settings.subtitle_size 193 | controllable.append("subtitleSize") 194 | 195 | if not media_item.is_transcode: 196 | options["subtitlePosition"] = settings.subtitle_position 197 | options["subtitleColor"] = mpv_color_to_plex(settings.subtitle_color) 198 | controllable.append("subtitlePosition") 199 | controllable.append("subtitleColor") 200 | 201 | options["ratingKey"] = media_item.get_attr("ratingKey") 202 | options["key"] = media_item.get_attr("key") 203 | options["containerKey"] = media_item.get_attr("key") 204 | options["guid"] = media_item.get_attr("guid") 205 | options["duration"] = media_item.get_attr("duration", "0") 206 | options["address"] = media.path.hostname 207 | options["protocol"] = media.path.scheme 208 | options["port"] = media.path.port 209 | options["machineIdentifier"] = media.get_machine_identifier() 210 | options["seekRange"] = "0-%s" % options["duration"] 211 | 212 | if media.play_queue: 213 | options.update(media.get_queue_info()) 214 | 215 | controllable.append("playPause") 216 | controllable.append("stop") 217 | controllable.append("stepBack") 218 | controllable.append("stepForward") 219 | controllable.append("seekTo") 220 | controllable.append("skipTo") 221 | controllable.append("autoPlay") 222 | 223 | controllable.append("subtitleStream") 224 | controllable.append("audioStream") 225 | 226 | if media_item.parent.has_next: 227 | controllable.append("skipNext") 228 | 229 | if media_item.parent.has_prev: 230 | controllable.append("skipPrevious") 231 | 232 | # If the duration is unknown, disable seeking 233 | if options["duration"] == "0": 234 | options.pop("duration") 235 | options.pop("seekRange") 236 | controllable.remove("seekTo") 237 | 238 | controllable.append("volume") 239 | options["volume"] = str(playerManager.get_volume(percent=True) or 0) 240 | 241 | options["controllable"] = ",".join(controllable) 242 | else: 243 | if self.last_media_item: 244 | media_item = self.last_media_item 245 | options["ratingKey"] = media_item.get_attr("ratingKey") 246 | options["key"] = media_item.get_attr("key") 247 | options["containerKey"] = media_item.get_attr("key") 248 | if media_item.parent.play_queue: 249 | options.update(media_item.parent.get_queue_info()) 250 | if player.playback_abort: 251 | options["state"] = "stopped" 252 | else: 253 | options["state"] = "buffering" 254 | 255 | return options 256 | 257 | 258 | timelineManager = TimelineManager() 259 | -------------------------------------------------------------------------------- /plex_mpv_shim/utils.py: -------------------------------------------------------------------------------- 1 | import certifi 2 | import logging 3 | import os 4 | import urllib.request, urllib.parse, urllib.error 5 | import socket 6 | import ipaddress 7 | import uuid 8 | import re 9 | import sys 10 | import ssl 11 | 12 | from .conf import settings 13 | from datetime import datetime 14 | from functools import wraps 15 | 16 | PLEX_TOKEN_RE = re.compile("(token|X-Plex-Token)=[^&]*") 17 | 18 | log = logging.getLogger("utils") 19 | plex_eph_tokens = {} 20 | plex_sessions = {} 21 | plex_transcode_sessions = {} 22 | 23 | class Timer(object): 24 | def __init__(self): 25 | self.restart() 26 | 27 | def restart(self): 28 | self.started = datetime.now() 29 | 30 | def elapsedMs(self): 31 | return self.elapsed() * 1e3 32 | 33 | def elapsed(self): 34 | return (datetime.now()-self.started).total_seconds() 35 | 36 | def synchronous(tlockname): 37 | """ 38 | A decorator to place an instance based lock around a method. 39 | From: http://code.activestate.com/recipes/577105-synchronization-decorator-for-class-methods/ 40 | """ 41 | 42 | def _synched(func): 43 | @wraps(func) 44 | def _synchronizer(self,*args, **kwargs): 45 | tlock = self.__getattribute__( tlockname) 46 | tlock.acquire() 47 | try: 48 | return func(self, *args, **kwargs) 49 | finally: 50 | tlock.release() 51 | return _synchronizer 52 | return _synched 53 | 54 | def upd_token(domain, token): 55 | plex_eph_tokens[domain] = token 56 | 57 | def get_transcode_session(domain, create=True): 58 | if domain not in plex_transcode_sessions: 59 | if not create: 60 | return 61 | session = str(uuid.uuid4()) 62 | plex_transcode_sessions[domain] = session 63 | return plex_transcode_sessions[domain] 64 | 65 | def clear_transcode_session(domain): 66 | if domain in plex_transcode_sessions: 67 | del plex_transcode_sessions[domain] 68 | 69 | def get_session(domain): 70 | if domain not in plex_sessions: 71 | session = str(uuid.uuid4()) 72 | plex_sessions[domain] = session 73 | return plex_sessions[domain] 74 | 75 | def get_plex_url(url, data=None, quiet=False): 76 | if not data: 77 | data = {} 78 | 79 | parsed_url = urllib.parse.urlsplit(url) 80 | domain = parsed_url.hostname 81 | 82 | if parsed_url.scheme != "https" and not settings.allow_http: 83 | raise ValueError("HTTP is not enabled in the configuration.") 84 | 85 | if domain in plex_eph_tokens: 86 | data.update({ 87 | "X-Plex-Token": plex_eph_tokens[domain] 88 | }) 89 | else: 90 | log.error("get_plex_url No token for: %s" % domain) 91 | 92 | data.update({ 93 | "X-Plex-Version": "2.0", 94 | "X-Plex-Client-Identifier": settings.client_uuid, 95 | "X-Plex-Provides": "player", 96 | "X-Plex-Device-Name": settings.player_name, 97 | "X-Plex-Model": "RaspberryPI", 98 | "X-Plex-Device": "RaspberryPI", 99 | "X-Plex-Session-Identifier": get_session(domain), 100 | 101 | # Lies 102 | "X-Plex-Product": "Plex MPV Shim", 103 | "X-Plex-Platform": "Plex Home Theater", 104 | "X-Plex-Client-Profile-Name": settings.client_profile, 105 | }) 106 | 107 | # Kinda ghetto... 108 | sep = "?" 109 | if sep in url: 110 | sep = "&" 111 | 112 | if data: 113 | url = "%s%s%s" % (url, sep, urllib.parse.urlencode(data)) 114 | 115 | if not quiet: 116 | log.debug("get_plex_url Created URL: %s" % sanitize_msg(url)) 117 | 118 | return url 119 | 120 | def safe_urlopen(url, data=None, quiet=False): 121 | """ 122 | Opens a url and returns True if an HTTP 200 code is returned, 123 | otherwise returns False. 124 | """ 125 | if not data: 126 | data = {} 127 | 128 | url = get_plex_url(url, data, quiet) 129 | 130 | try: 131 | context = ssl.create_default_context(cafile=certifi.where()) 132 | page = urllib.request.urlopen(url, context=context) 133 | if page.code == 200: 134 | return True 135 | log.error("Error opening URL '%s': page returned %d" % (sanitize_msg(url), 136 | page.code)) 137 | except Exception as e: 138 | log.error("Error opening URL '%s': %s" % (sanitize_msg(url), e)) 139 | 140 | return False 141 | 142 | def is_local_domain(domain): 143 | try: 144 | return ipaddress.ip_address(socket.gethostbyname(domain)).is_private 145 | except socket.gaierror as e: 146 | if e.errno == socket.EAI_NODATA: 147 | try: 148 | # try checking for an IPv6 address 149 | return ipaddress.ip_address(socket.getaddrinfo(domain, None, socket.AF_INET6)[0][4][0]).is_private 150 | except socket.gaierror: 151 | log.warning("Unable to check local/remote for domain (IPv6): %s" % domain, exc_info=True) 152 | else: 153 | log.warning("Unable to check local/remote for domain: %s" % domain, exc_info=True) 154 | return False 155 | 156 | def sanitize_msg(text): 157 | if settings.sanitize_output: 158 | return re.sub(PLEX_TOKEN_RE, "\\1=REDACTED", text) 159 | return text 160 | 161 | def mpv_color_to_plex(color): 162 | return '#'+color.lower()[3:] 163 | 164 | def plex_color_to_mpv(color): 165 | return '#FF'+color.upper()[1:] 166 | 167 | def get_resource(*path): 168 | # Detect if bundled via pyinstaller. 169 | # From: https://stackoverflow.com/questions/404744/ 170 | if getattr(sys, '_MEIPASS', False): 171 | application_path = os.path.join(sys._MEIPASS, "plex_mpv_shim") 172 | else: 173 | application_path = os.path.dirname(os.path.abspath(__file__)) 174 | 175 | return os.path.join(application_path, *path) 176 | -------------------------------------------------------------------------------- /plex_mpv_shim/video_profile.py: -------------------------------------------------------------------------------- 1 | from .conf import settings 2 | from . import conffile 3 | from .utils import get_resource 4 | import logging 5 | import os.path 6 | import shutil 7 | import json 8 | import time 9 | 10 | APP_NAME = 'plex-mpv-shim' 11 | log = logging.getLogger('video_profile') 12 | 13 | class MPVSettingError(Exception): 14 | """Raised when MPV does not support a required setting.""" 15 | pass 16 | 17 | class VideoProfileManager: 18 | def __init__(self, menu, playerManager): 19 | self.menu = menu 20 | self.playerManager = playerManager 21 | self.used_settings = set() 22 | self.current_profile = None 23 | self.profile_subtypes = [] 24 | 25 | self.load_shader_pack() 26 | 27 | def load_shader_pack(self): 28 | shader_pack_builtin = get_resource("default_shader_pack") 29 | 30 | self.shader_pack = shader_pack_builtin 31 | if settings.shader_pack_custom: 32 | self.shader_pack = conffile.get(APP_NAME, "shader_pack") 33 | if not os.path.exists(self.shader_pack): 34 | shutil.copytree(shader_pack_builtin, self.shader_pack) 35 | 36 | pack_name = "pack-next.json" 37 | if not os.path.exists(os.path.join(self.shader_pack, pack_name)): 38 | pack_name = "pack.json" 39 | 40 | if not os.path.exists(os.path.join(self.shader_pack, pack_name)): 41 | raise FileNotFoundError("Could not find default shader pack.") 42 | 43 | with open(os.path.join(self.shader_pack, pack_name)) as fh: 44 | pack = json.load(fh) 45 | self.default_groups = pack.get("default-setting-groups") or [] 46 | self.profiles = pack.get("profiles") or {} 47 | self.groups = pack.get("setting-groups") or {} 48 | self.revert_ignore = set(pack.get("setting-revert-ignore") or []) 49 | 50 | self.profile_subtypes = set() 51 | for profile in self.profiles.values(): 52 | for subtype in profile.get("subtype", []): 53 | self.profile_subtypes.add(subtype) 54 | 55 | self.defaults = {} 56 | for group in self.groups.values(): 57 | setting_group = group.get("settings") 58 | if setting_group is None: 59 | continue 60 | 61 | for key, value in setting_group: 62 | if key in self.defaults or key in self.revert_ignore: 63 | continue 64 | try: 65 | self.defaults[key] = getattr(self.playerManager._player, key) 66 | except Exception: 67 | log.warning("Your MPV does not support setting {0} used in shader pack.".format(key), exc_info=1) 68 | 69 | if settings.shader_pack_profile is not None: 70 | self.load_profile(settings.shader_pack_profile, reset=False) 71 | 72 | def process_setting_group(self, group_name, settings_to_apply, shaders_to_apply): 73 | group = self.groups[group_name] 74 | for key, value in group.get("settings", []): 75 | if key not in self.defaults: 76 | if key not in self.revert_ignore: 77 | raise MPVSettingError("Cannot use setting group {0} due to MPV not supporting {1}".format(group_name, key)) 78 | else: 79 | self.used_settings.add(key) 80 | settings_to_apply.append((key, value)) 81 | for shader in group.get("shaders", []): 82 | shaders_to_apply.append(os.path.join(self.shader_pack, "shaders", shader)) 83 | 84 | def load_profile(self, profile_name, reset=True): 85 | if reset: 86 | self.unload_profile() 87 | log.info("Loading shader profile {0}.".format(profile_name)) 88 | if profile_name not in self.profiles: 89 | log.error("Shader profile {0} does not exist.".format(profile_name)) 90 | return False 91 | 92 | profile = self.profiles[profile_name] 93 | settings_to_apply = [] 94 | shaders_to_apply = [] 95 | try: 96 | # Read Settings & Shaders 97 | for group in self.default_groups: 98 | self.process_setting_group(group, settings_to_apply, shaders_to_apply) 99 | for group in profile.get("setting-groups", []): 100 | self.process_setting_group(group, settings_to_apply, shaders_to_apply) 101 | for shader in profile.get("shaders", []): 102 | shaders_to_apply.append(os.path.join(self.shader_pack, "shaders", shader)) 103 | 104 | # Apply Settings 105 | already_set = set() 106 | for key, value in settings_to_apply: 107 | if (key, value) in already_set: 108 | continue 109 | log.debug("Set MPV setting {0} to {1}".format(key, value)) 110 | setattr(self.playerManager._player, key, value) 111 | already_set.add((key, value)) 112 | 113 | # Apply Shaders 114 | log.debug("Set shaders: {0}".format(shaders_to_apply)) 115 | self.playerManager._player.glsl_shaders = shaders_to_apply 116 | self.current_profile = profile_name 117 | return True 118 | except MPVSettingError as ex: 119 | log.error("Could not apply shader profile.", exc_info=1) 120 | return False 121 | 122 | def unload_profile(self): 123 | log.info("Unloading shader profile.") 124 | self.playerManager._player.glsl_shaders = [] 125 | for setting in self.used_settings: 126 | try: 127 | value = self.defaults[setting] 128 | setattr(self.playerManager._player, setting, value) 129 | except Exception: 130 | log.warning("Default setting {0} value {1} is invalid.".format(setting, value)) 131 | self.current_profile = None 132 | 133 | def menu_handle(self): 134 | profile_name = self.menu.menu_list[self.menu.menu_selection][2] 135 | settings_were_successful = True 136 | if profile_name is None: 137 | self.unload_profile() 138 | else: 139 | settings_were_successful = self.load_profile(profile_name) 140 | if settings.shader_pack_remember and settings_were_successful: 141 | settings.shader_pack_profile = profile_name 142 | settings.save() 143 | 144 | # Need to re-render menu. 145 | self.menu.menu_action("back") 146 | self.menu_action() 147 | 148 | def menu_action(self): 149 | selected = 0 150 | profile_option_list = [ 151 | ("None (Disabled)", self.menu_handle, None) 152 | ] 153 | for i, (profile_name, profile) in enumerate(self.profiles.items()): 154 | if (profile.get("subtype", None) is not None and 155 | not settings.shader_pack_subtype in profile["subtype"]): 156 | continue 157 | 158 | profile_option_list.append( 159 | (profile["displayname"], self.menu_handle, profile_name) 160 | ) 161 | if profile_name == self.current_profile: 162 | selected = i+1 163 | self.menu.put_menu("Select Shader Profile", profile_option_list, selected) 164 | -------------------------------------------------------------------------------- /plex_mpv_shim/win_utils.py: -------------------------------------------------------------------------------- 1 | import win32gui 2 | import traceback 3 | 4 | def windowEnumerationHandler(hwnd, top_windows): 5 | top_windows.append((hwnd, win32gui.GetWindowText(hwnd))) 6 | 7 | def raise_mpv(): 8 | # This workaround is madness. Apparently SetForegroundWindow 9 | # won't work randomly, so I have to call ShowWindow twice. 10 | # Once to hide the window, and again to successfully raise the window. 11 | try: 12 | top_windows = [] 13 | fg_win = win32gui.GetForegroundWindow() 14 | win32gui.EnumWindows(windowEnumerationHandler, top_windows) 15 | for i in top_windows: 16 | if " - mpv" in i[1].lower(): 17 | if i[0] != fg_win: 18 | win32gui.ShowWindow(i[0], 6) # Minimize 19 | win32gui.ShowWindow(i[0], 9) # Un-minimize 20 | break 21 | 22 | except Exception: 23 | print("Could not raise MPV.") 24 | traceback.print_exc() 25 | 26 | -------------------------------------------------------------------------------- /run.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | # Newer revisions of python-mpv require mpv-1.dll in the PATH. 4 | import os 5 | import sys 6 | import multiprocessing 7 | if sys.platform.startswith("win32") or sys.platform.startswith("cygwin"): 8 | # Detect if bundled via pyinstaller. 9 | # From: https://stackoverflow.com/questions/404744/ 10 | if getattr(sys, 'frozen', False): 11 | application_path = sys._MEIPASS 12 | else: 13 | application_path = os.path.dirname(os.path.abspath(__file__)) 14 | os.environ["PATH"] = application_path + os.pathsep + os.environ["PATH"] 15 | 16 | from plex_mpv_shim.mpv_shim import main 17 | if __name__ == '__main__': 18 | # https://stackoverflow.com/questions/24944558/pyinstaller-built-windows-exe-fails-with-multiprocessing 19 | multiprocessing.freeze_support() 20 | 21 | main() 22 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | with open("README.md", "r") as fh: 4 | long_description = fh.read() 5 | 6 | setup( 7 | name='plex-mpv-shim', 8 | version='1.11.1', 9 | author="Ian Walton", 10 | author_email="iwalton3@gmail.com", 11 | description="Cast media from Plex Mobile and Web apps to MPV. (Unofficial)", 12 | license='MIT', 13 | long_description=open('README.md').read(), 14 | long_description_content_type="text/markdown", 15 | url="https://github.com/iwalton3/plex-mpv-shim", 16 | packages=['plex_mpv_shim'], 17 | entry_points={ 18 | 'console_scripts': [ 19 | 'plex-mpv-shim=plex_mpv_shim.mpv_shim:main', 20 | ] 21 | }, 22 | classifiers=[ 23 | "Programming Language :: Python :: 3", 24 | "License :: OSI Approved :: MIT License", 25 | "Operating System :: OS Independent", 26 | ], 27 | python_requires='>=3.6', 28 | install_requires=['mpv', 'requests', 'python-mpv-jsonipc>=1.1.8', 'certifi'], 29 | include_package_data=True 30 | 31 | ) 32 | --------------------------------------------------------------------------------