├── .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 |
4 |
5 |
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 |
10 |
11 |
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 |
--------------------------------------------------------------------------------