From 7887c2bf1445ddf515645fd8b5fc80c9b61afa9d Mon Sep 17 00:00:00 2001 From: et0h Date: Sun, 10 May 2020 16:26:33 +0100 Subject: [PATCH] Add iwalton3's Python MPV JSONIPC library (Apache 2.0) --- .../players/python_mpv_jsonipc/LICENSE.md | 195 ++++++ syncplay/players/python_mpv_jsonipc/README.md | 56 ++ syncplay/players/python_mpv_jsonipc/docs.md | 460 +++++++++++++ .../python_mpv_jsonipc/python_mpv_jsonipc.py | 607 ++++++++++++++++++ syncplay/players/python_mpv_jsonipc/setup.py | 25 + 5 files changed, 1343 insertions(+) create mode 100644 syncplay/players/python_mpv_jsonipc/LICENSE.md create mode 100644 syncplay/players/python_mpv_jsonipc/README.md create mode 100644 syncplay/players/python_mpv_jsonipc/docs.md create mode 100644 syncplay/players/python_mpv_jsonipc/python_mpv_jsonipc.py create mode 100644 syncplay/players/python_mpv_jsonipc/setup.py diff --git a/syncplay/players/python_mpv_jsonipc/LICENSE.md b/syncplay/players/python_mpv_jsonipc/LICENSE.md new file mode 100644 index 0000000..f5f4b8b --- /dev/null +++ b/syncplay/players/python_mpv_jsonipc/LICENSE.md @@ -0,0 +1,195 @@ +Apache License +============== + +_Version 2.0, January 2004_ +_<>_ + +### Terms and Conditions for use, reproduction, and distribution + +#### 1. Definitions + +“License” shall mean the terms and conditions for use, reproduction, and +distribution as defined by Sections 1 through 9 of this document. + +“Licensor” shall mean the copyright owner or entity authorized by the copyright +owner that is granting the License. + +“Legal Entity” shall mean the union of the acting entity and all other entities +that control, are controlled by, or are under common control with that entity. +For the purposes of this definition, “control” means **(i)** the power, direct or +indirect, to cause the direction or management of such entity, whether by +contract or otherwise, or **(ii)** ownership of fifty percent (50%) or more of the +outstanding shares, or **(iii)** beneficial ownership of such entity. + +“You” (or “Your”) shall mean an individual or Legal Entity exercising +permissions granted by this License. + +“Source” form shall mean the preferred form for making modifications, including +but not limited to software source code, documentation source, and configuration +files. + +“Object” form shall mean any form resulting from mechanical transformation or +translation of a Source form, including but not limited to compiled object code, +generated documentation, and conversions to other media types. + +“Work” shall mean the work of authorship, whether in Source or Object form, made +available under the License, as indicated by a copyright notice that is included +in or attached to the work (an example is provided in the Appendix below). + +“Derivative Works” shall mean any work, whether in Source or Object form, that +is based on (or derived from) the Work and for which the editorial revisions, +annotations, elaborations, or other modifications represent, as a whole, an +original work of authorship. For the purposes of this License, Derivative Works +shall not include works that remain separable from, or merely link (or bind by +name) to the interfaces of, the Work and Derivative Works thereof. + +“Contribution” shall mean any work of authorship, including the original version +of the Work and any modifications or additions to that Work or Derivative Works +thereof, that is intentionally submitted to Licensor for inclusion in the Work +by the copyright owner or by an individual or Legal Entity authorized to submit +on behalf of the copyright owner. For the purposes of this definition, +“submitted” means any form of electronic, verbal, or written communication sent +to the Licensor or its representatives, including but not limited to +communication on electronic mailing lists, source code control systems, and +issue tracking systems that are managed by, or on behalf of, the Licensor for +the purpose of discussing and improving the Work, but excluding communication +that is conspicuously marked or otherwise designated in writing by the copyright +owner as “Not a Contribution.” + +“Contributor” shall mean Licensor and any individual or Legal Entity on behalf +of whom a Contribution has been received by Licensor and subsequently +incorporated within the Work. + +#### 2. Grant of Copyright License + +Subject to the terms and conditions of this License, each Contributor hereby +grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, +irrevocable copyright license to reproduce, prepare Derivative Works of, +publicly display, publicly perform, sublicense, and distribute the Work and such +Derivative Works in Source or Object form. + +#### 3. Grant of Patent License + +Subject to the terms and conditions of this License, each Contributor hereby +grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, +irrevocable (except as stated in this section) patent license to make, have +made, use, offer to sell, sell, import, and otherwise transfer the Work, where +such license applies only to those patent claims licensable by such Contributor +that are necessarily infringed by their Contribution(s) alone or by combination +of their Contribution(s) with the Work to which such Contribution(s) was +submitted. If You institute patent litigation against any entity (including a +cross-claim or counterclaim in a lawsuit) alleging that the Work or a +Contribution incorporated within the Work constitutes direct or contributory +patent infringement, then any patent licenses granted to You under this License +for that Work shall terminate as of the date such litigation is filed. + +#### 4. Redistribution + +You may reproduce and distribute copies of the Work or Derivative Works thereof +in any medium, with or without modifications, and in Source or Object form, +provided that You meet the following conditions: + +* **(a)** You must give any other recipients of the Work or Derivative Works a copy of +this License; and +* **(b)** You must cause any modified files to carry prominent notices stating that You +changed the files; and +* **(c)** You must retain, in the Source form of any Derivative Works that You distribute, +all copyright, patent, trademark, and attribution notices from the Source form +of the Work, excluding those notices that do not pertain to any part of the +Derivative Works; and +* **(d)** If the Work includes a “NOTICE” text file as part of its distribution, then any +Derivative Works that You distribute must include a readable copy of the +attribution notices contained within such NOTICE file, excluding those notices +that do not pertain to any part of the Derivative Works, in at least one of the +following places: within a NOTICE text file distributed as part of the +Derivative Works; within the Source form or documentation, if provided along +with the Derivative Works; or, within a display generated by the Derivative +Works, if and wherever such third-party notices normally appear. The contents of +the NOTICE file are for informational purposes only and do not modify the +License. You may add Your own attribution notices within Derivative Works that +You distribute, alongside or as an addendum to the NOTICE text from the Work, +provided that such additional attribution notices cannot be construed as +modifying the License. + +You may add Your own copyright statement to Your modifications and may provide +additional or different license terms and conditions for use, reproduction, or +distribution of Your modifications, or for any such Derivative Works as a whole, +provided Your use, reproduction, and distribution of the Work otherwise complies +with the conditions stated in this License. + +#### 5. Submission of Contributions + +Unless You explicitly state otherwise, any Contribution intentionally submitted +for inclusion in the Work by You to the Licensor shall be under the terms and +conditions of this License, without any additional terms or conditions. +Notwithstanding the above, nothing herein shall supersede or modify the terms of +any separate license agreement you may have executed with Licensor regarding +such Contributions. + +#### 6. Trademarks + +This License does not grant permission to use the trade names, trademarks, +service marks, or product names of the Licensor, except as required for +reasonable and customary use in describing the origin of the Work and +reproducing the content of the NOTICE file. + +#### 7. Disclaimer of Warranty + +Unless required by applicable law or agreed to in writing, Licensor provides the +Work (and each Contributor provides its Contributions) on an “AS IS” BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, +including, without limitation, any warranties or conditions of TITLE, +NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are +solely responsible for determining the appropriateness of using or +redistributing the Work and assume any risks associated with Your exercise of +permissions under this License. + +#### 8. Limitation of Liability + +In no event and under no legal theory, whether in tort (including negligence), +contract, or otherwise, unless required by applicable law (such as deliberate +and grossly negligent acts) or agreed to in writing, shall any Contributor be +liable to You for damages, including any direct, indirect, special, incidental, +or consequential damages of any character arising as a result of this License or +out of the use or inability to use the Work (including but not limited to +damages for loss of goodwill, work stoppage, computer failure or malfunction, or +any and all other commercial damages or losses), even if such Contributor has +been advised of the possibility of such damages. + +#### 9. Accepting Warranty or Additional Liability + +While redistributing the Work or Derivative Works thereof, You may choose to +offer, and charge a fee for, acceptance of support, warranty, indemnity, or +other liability obligations and/or rights consistent with this License. However, +in accepting such obligations, You may act only on Your own behalf and on Your +sole responsibility, not on behalf of any other Contributor, and only if You +agree to indemnify, defend, and hold each Contributor harmless for any liability +incurred by, or claims asserted against, such Contributor by reason of your +accepting any such warranty or additional liability. + +_END OF TERMS AND CONDITIONS_ + +### APPENDIX: How to apply the Apache License to your work + +To apply the Apache License to your work, attach the following boilerplate +notice, with the fields enclosed by brackets `[]` replaced with your own +identifying information. (Don't include the brackets!) The text should be +enclosed in the appropriate comment syntax for the file format. We also +recommend that a file or class name and description of purpose be included on +the same “printed page” as the copyright notice for easier identification within +third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + diff --git a/syncplay/players/python_mpv_jsonipc/README.md b/syncplay/players/python_mpv_jsonipc/README.md new file mode 100644 index 0000000..cc3cea7 --- /dev/null +++ b/syncplay/players/python_mpv_jsonipc/README.md @@ -0,0 +1,56 @@ +# Python MPV JSONIPC + +This implements an interface similar to `python-mpv`, but it uses the JSON IPC protocol instead of the C API. This means +you can control external instances of MPV including players like SMPlayer, and it can use MPV players that are prebuilt +instead of needing `libmpv1`. It may also be more resistant to crashes such as Segmentation Faults, but since it isn't +directly communicating with MPV via the C API the performance will be worse. + +Please note that this only implements the subset of `python-mpv` that is used by `plex-mpv-shim` and +`jellyfin-mpv-shim`. Other functionality has not been implemented. + +## Installation + +```bash +sudo pip3 install python-mpv-jsonipc +``` + +## Basic usage + +Create an instance of MPV. You can use an already running MPV or have the library start a +managed copy of MPV. Command arguments can be specified when initializing MPV if you are +starting a managed copy of MPV. + +Please also see the [API Documentation](https://github.com/iwalton3/python-mpv-jsonipc/blob/master/docs.md). + +```python +from python_mpv_jsonipc import MPV + +# Uses MPV that is in the PATH. +mpv = MPV() + +# Use MPV that is running and connected to /tmp/mpv-socket. +mpv = MPV(start_mpv=False, ipc_socket="/tmp/mpv-socket") + +# Uses MPV that is found at /path/to/mpv. +mpv = MPV(mpv_location="/path/to/mpv") + +# After you have an MPV, you can read and set (if applicable) properties. +mpv.volume # 100.0 by default +mpv.volume = 20 + +# You can also send commands. +mpv.command_name(arg1, arg2) + +# Bind to key press events with a decorator +@mpv.on_key_press("space") +def space_handler(): + pass + +# You can also observe and wait for properties. +@mpv.property_observer("eof-reached") +def handle_eof(name, value): + pass + +# Or simply wait for the value to change once. +mpv.wait_for_property("duration") +``` diff --git a/syncplay/players/python_mpv_jsonipc/docs.md b/syncplay/players/python_mpv_jsonipc/docs.md new file mode 100644 index 0000000..3e01b3d --- /dev/null +++ b/syncplay/players/python_mpv_jsonipc/docs.md @@ -0,0 +1,460 @@ + + +## python\_mpv\_jsonipc + + + +### MPVError + +``` python +class MPVError(Exception): + | MPVError(**args, ****kwargs) +``` + +An error originating from MPV or due to a problem with MPV. + + + +### WindowsSocket + +``` python +class WindowsSocket(threading.Thread) +``` + +Wraps a Windows named pipe in a high-level interface. (Internal) + +Data is automatically encoded and decoded as JSON. The callback +function will be called for each inbound message. + + + +#### \_\_init\_\_ + +``` python + | __init__(ipc_socket, callback=None) +``` + +Create the wrapper. + +**ipc\_socket** is the pipe name. (Not including \\\\.\\pipe\\) +**callback(json\_data)** is the function for recieving events. + + + +#### stop + +``` python + | stop() +``` + +Terminate the thread. + + + +#### send + +``` python + | send(data) +``` + +Send **data** to the pipe, encoded as JSON. + + + +#### run + +``` python + | run() +``` + +Process pipe events. Do not run this directly. Use **start**. + + + +### UnixSocket + +``` python +class UnixSocket(threading.Thread) +``` + +Wraps a Unix/Linux socket in a high-level interface. (Internal) + +Data is automatically encoded and decoded as JSON. The callback +function will be called for each inbound message. + + + +#### \_\_init\_\_ + +``` python + | __init__(ipc_socket, callback=None) +``` + +Create the wrapper. + +**ipc\_socket** is the path to the socket. +**callback(json\_data)** is the function for recieving events. + + + +#### stop + +``` python + | stop() +``` + +Terminate the thread. + + + +#### send + +``` python + | send(data) +``` + +Send **data** to the socket, encoded as JSON. + + + +#### run + +``` python + | run() +``` + +Process socket events. Do not run this directly. Use **start**. + + + +### MPVProcess + +``` python +class MPVProcess() +``` + +Manages an MPV process, ensuring the socket or pipe is available. (Internal) + + + +#### \_\_init\_\_ + +``` python + | __init__(ipc_socket, mpv_location=None, ****kwargs) +``` + +Create and start the MPV process. Will block until socket/pipe is available. + +**ipc\_socket** is the path to the Unix/Linux socket or name of the Windows pipe. +**mpv\_location** is the path to mpv. If left unset it tries the one in the PATH. + +All other arguments are forwarded to MPV as command-line arguments. + + + +#### stop + +``` python + | stop() +``` + +Terminate the process. + + + +### MPVInter + +``` python +class MPVInter() +``` + +Low-level interface to MPV. Does NOT manage an mpv process. (Internal) + + + +#### \_\_init\_\_ + +``` python + | __init__(ipc_socket, callback=None) +``` + +Create the wrapper. + +**ipc\_socket** is the path to the Unix/Linux socket or name of the Windows pipe. +**callback(event\_name, data)** is the function for recieving events. + + + +#### stop + +``` python + | stop() +``` + +Terminate the underlying connection. + + + +#### event\_callback + +``` python + | event_callback(data) +``` + +Internal callback for recieving events from MPV. + + + +#### command + +``` python + | command(command, **args) +``` + +Issue a command to MPV. Will block until completed or timeout is reached. + +**command** is the name of the MPV command + +All further arguments are forwarded to the MPV command. +Throws TimeoutError if timeout of 120 seconds is reached. + + + +### EventHandler + +``` python +class EventHandler(threading.Thread) +``` + +Event handling thread. (Internal) + + + +#### \_\_init\_\_ + +``` python + | __init__() +``` + +Create an instance of the thread. + + + +#### put\_task + +``` python + | put_task(func, **args) +``` + +Put a new task to the thread. + +**func** is the function to call + +All further arguments are forwarded to **func**. + + + +#### stop + +``` python + | stop() +``` + +Terminate the thread. + + + +#### run + +``` python + | run() +``` + +Process socket events. Do not run this directly. Use **start**. + + + +### MPV + +``` python +class MPV() +``` + +The main MPV interface class. Use this to control MPV. + +This will expose all mpv commands as callable methods and all properties. +You can set properties and call the commands directly. + +Please note that if you are using a really old MPV version, a fallback command +list is used. Not all commands may actually work when this fallback is used. + + + +#### \_\_init\_\_ + +``` python + | __init__(start_mpv=True, ipc_socket=None, mpv_location=None, log_handler=None, loglevel=None, ****kwargs) +``` + +Create the interface to MPV and process instance. + +**start\_mpv** will start an MPV process if true. (Default: True) +**ipc\_socket** is the path to the Unix/Linux socket or name of Windows pipe. (Default: Random Temp File) +**mpv\_location** is the location of MPV for **start\_mpv**. (Default: Use MPV in PATH) +**log\_handler(level, prefix, text)** is an optional handler for log events. (Default: Disabled) +**loglevel** is the level for log messages. Levels are fatal, error, warn, info, v, debug, trace. (Default: Disabled) + +All other arguments are forwarded to MPV as command-line arguments if **start\_mpv** is used. + + + +#### bind\_event + +``` python + | bind_event(name, callback) +``` + +Bind a callback to an MPV event. + +**name** is the MPV event name. +**callback(event\_data)** is the function to call. + + + +#### on\_event + +``` python + | on_event(name) +``` + +Decorator to bind a callback to an MPV event. + +@on\_event(name) +def my\_callback(event\_data): +pass + + + +#### event\_callback + +``` python + | event_callback(name) +``` + +An alias for on\_event to maintain compatibility with python-mpv. + + + +#### on\_key\_press + +``` python + | on_key_press(name) +``` + +Decorator to bind a callback to an MPV keypress event. + +@on\_key\_press(key\_name) +def my\_callback(): +pass + + + +#### bind\_key\_press + +``` python + | bind_key_press(name, callback) +``` + +Bind a callback to an MPV keypress event. + +**name** is the key symbol. +**callback()** is the function to call. + + + +#### bind\_property\_observer + +``` python + | bind_property_observer(name, callback) +``` + +Bind a callback to an MPV property change. + +**name** is the property name. +**callback(name, data)** is the function to call. + +Returns a unique observer ID needed to destroy the observer. + + + +#### unbind\_property\_observer + +``` python + | unbind_property_observer(observer_id) +``` + +Remove callback to an MPV property change. + +**observer\_id** is the id returned by bind\_property\_observer. + + + +#### property\_observer + +``` python + | property_observer(name) +``` + +Decorator to bind a callback to an MPV property change. + +@on\_key\_press(property\_name) +def my\_callback(name, data): +pass + + + +#### wait\_for\_property + +``` python + | wait_for_property(name) +``` + +Waits for the value of a property to change. + +**name** is the name of the property. + + + +#### play + +``` python + | play(url) +``` + +Play the specified URL. An alias to loadfile(). + + + +#### terminate + +``` python + | terminate() +``` + +Terminate the connection to MPV and process (if started by this module). + + + +#### command + +``` python + | command(command, **args) +``` + +Send a command to MPV. All commands are bound to the class by default, +except JSON IPC specific commands. This may also be useful to retain +compatibility with python-mpv, as it does not bind all of the commands. + +**command** is the command name. + +All further arguments are forwarded to the MPV command. diff --git a/syncplay/players/python_mpv_jsonipc/python_mpv_jsonipc.py b/syncplay/players/python_mpv_jsonipc/python_mpv_jsonipc.py new file mode 100644 index 0000000..f4a4b5a --- /dev/null +++ b/syncplay/players/python_mpv_jsonipc/python_mpv_jsonipc.py @@ -0,0 +1,607 @@ +import threading +import socket +import json +import os +import time +import subprocess +import random +import queue +import logging + +log = logging.getLogger('mpv-jsonipc') + +if os.name == "nt": + import _winapi + from multiprocessing.connection import PipeConnection + +TIMEOUT = 120 + +# Older MPV versions do not allow us to dynamically retrieve the command list. +FALLBACK_COMMAND_LIST = [ + 'ignore', 'seek', 'revert-seek', 'quit', 'quit-watch-later', 'stop', 'frame-step', 'frame-back-step', + 'playlist-next', 'playlist-prev', 'playlist-shuffle', 'playlist-unshuffle', 'sub-step', 'sub-seek', + 'print-text', 'show-text', 'expand-text', 'expand-path', 'show-progress', 'sub-add', 'audio-add', + 'video-add', 'sub-remove', 'audio-remove', 'video-remove', 'sub-reload', 'audio-reload', 'video-reload', + 'rescan-external-files', 'screenshot', 'screenshot-to-file', 'screenshot-raw', 'loadfile', 'loadlist', + 'playlist-clear', 'playlist-remove', 'playlist-move', 'run', 'subprocess', 'set', 'change-list', 'add', + 'cycle', 'multiply', 'cycle-values', 'enable-section', 'disable-section', 'define-section', 'ab-loop', + 'drop-buffers', 'af', 'vf', 'af-command', 'vf-command', 'ao-reload', 'script-binding', 'script-message', + 'script-message-to', 'overlay-add', 'overlay-remove', 'osd-overlay', 'write-watch-later-config', + 'hook-add', 'hook-ack', 'mouse', 'keybind', 'keypress', 'keydown', 'keyup', 'apply-profile', + 'load-script', 'dump-cache', 'ab-loop-dump-cache', 'ab-loop-align-cache'] + +class MPVError(Exception): + """An error originating from MPV or due to a problem with MPV.""" + def __init__(self, *args, **kwargs): + super(MPVError, self).__init__(*args, **kwargs) + +class WindowsSocket(threading.Thread): + """ + Wraps a Windows named pipe in a high-level interface. (Internal) + + Data is automatically encoded and decoded as JSON. The callback + function will be called for each inbound message. + """ + def __init__(self, ipc_socket, callback=None): + """Create the wrapper. + + *ipc_socket* is the pipe name. (Not including \\\\.\\pipe\\) + *callback(json_data)* is the function for recieving events. + """ + ipc_socket = "\\\\.\\pipe\\" + ipc_socket + self.callback = callback + + access = _winapi.GENERIC_READ | _winapi.GENERIC_WRITE + limit = 5 # Connection may fail at first. Try 5 times. + for _ in range(limit): + try: + pipe_handle = _winapi.CreateFile( + ipc_socket, access, 0, _winapi.NULL, _winapi.OPEN_EXISTING, + _winapi.FILE_FLAG_OVERLAPPED, _winapi.NULL + ) + break + except OSError: + time.sleep(1) + else: + raise MPVError("Cannot connect to pipe.") + self.socket = PipeConnection(pipe_handle) + + if self.callback is None: + self.callback = lambda data: None + + threading.Thread.__init__(self) + + def stop(self): + """Terminate the thread.""" + if self.socket is not None: + self.socket.close() + self.join() + + def send(self, data): + """Send *data* to the pipe, encoded as JSON.""" + self.socket.send_bytes(json.dumps(data).encode('utf-8') + b'\n') + + def run(self): + """Process pipe events. Do not run this directly. Use *start*.""" + data = b'' + try: + while True: + current_data = self.socket.recv_bytes(2048) + if current_data == b'': + break + + data += current_data + if data[-1] != 10: + continue + + data = data.decode('utf-8', 'ignore').encode('utf-8') + for item in data.split(b'\n'): + if item == b'': + continue + json_data = json.loads(item) + self.callback(json_data) + data = b'' + except EOFError: + pass + +class UnixSocket(threading.Thread): + """ + Wraps a Unix/Linux socket in a high-level interface. (Internal) + + Data is automatically encoded and decoded as JSON. The callback + function will be called for each inbound message. + """ + def __init__(self, ipc_socket, callback=None): + """Create the wrapper. + + *ipc_socket* is the path to the socket. + *callback(json_data)* is the function for recieving events. + """ + self.ipc_socket = ipc_socket + self.callback = callback + self.socket = socket.socket(socket.AF_UNIX) + self.socket.connect(self.ipc_socket) + + if self.callback is None: + self.callback = lambda data: None + + threading.Thread.__init__(self) + + def stop(self): + """Terminate the thread.""" + if self.socket is not None: + self.socket.shutdown(socket.SHUT_WR) + self.socket.close() + self.join() + + def send(self, data): + """Send *data* to the socket, encoded as JSON.""" + self.socket.send(json.dumps(data).encode('utf-8') + b'\n') + + def run(self): + """Process socket events. Do not run this directly. Use *start*.""" + data = b'' + while True: + current_data = self.socket.recv(1024) + if current_data == b'': + break + + data += current_data + if data[-1] != 10: + continue + + data = data.decode('utf-8', 'ignore').encode('utf-8') + for item in data.split(b'\n'): + if item == b'': + continue + json_data = json.loads(item) + self.callback(json_data) + data = b'' + +class MPVProcess: + """ + Manages an MPV process, ensuring the socket or pipe is available. (Internal) + """ + def __init__(self, ipc_socket, mpv_location=None, **kwargs): + """ + Create and start the MPV process. Will block until socket/pipe is available. + + *ipc_socket* is the path to the Unix/Linux socket or name of the Windows pipe. + *mpv_location* is the path to mpv. If left unset it tries the one in the PATH. + + All other arguments are forwarded to MPV as command-line arguments. + """ + if mpv_location is None: + if os.name == 'nt': + mpv_location = "mpv.exe" + else: + mpv_location = "mpv" + + log.debug("Staring MPV from {0}.".format(mpv_location)) + ipc_socket_name = ipc_socket + if os.name == 'nt': + ipc_socket = "\\\\.\\pipe\\" + ipc_socket + + if os.name != 'nt' and os.path.exists(ipc_socket): + os.remove(ipc_socket) + + log.debug("Using IPC socket {0} for MPV.".format(ipc_socket)) + self.ipc_socket = ipc_socket + args = [mpv_location] + self._set_default(kwargs, "idle", True) + self._set_default(kwargs, "input_ipc_server", ipc_socket_name) + self._set_default(kwargs, "input_terminal", False) + self._set_default(kwargs, "terminal", False) + args.extend("--{0}={1}".format(v[0].replace("_", "-"), self._mpv_fmt(v[1])) + for v in kwargs.items()) + self.process = subprocess.Popen(args) + ipc_exists = False + for _ in range(100): # Give MPV 10 seconds to start. + time.sleep(0.1) + self.process.poll() + if os.path.exists(ipc_socket): + ipc_exists = True + log.debug("Found MPV socket.") + break + if self.process.returncode is not None: + log.error("MPV failed with returncode {0}.".format(self.process.returncode)) + break + else: + self.process.terminate() + raise MPVError("MPV start timed out.") + + if not ipc_exists or self.process.returncode is not None: + self.process.terminate() + raise MPVError("MPV not started.") + + def _set_default(self, prop_dict, key, value): + if key not in prop_dict: + prop_dict[key] = value + + def _mpv_fmt(self, data): + if data == True: + return "yes" + elif data == False: + return "no" + else: + return data + + def stop(self): + """Terminate the process.""" + self.process.terminate() + if os.name != 'nt' and os.path.exists(self.ipc_socket): + os.remove(self.ipc_socket) + +class MPVInter: + """ + Low-level interface to MPV. Does NOT manage an mpv process. (Internal) + """ + def __init__(self, ipc_socket, callback=None): + """Create the wrapper. + + *ipc_socket* is the path to the Unix/Linux socket or name of the Windows pipe. + *callback(event_name, data)* is the function for recieving events. + """ + Socket = UnixSocket + if os.name == 'nt': + Socket = WindowsSocket + + self.callback = callback + if self.callback is None: + self.callback = lambda event, data: None + + self.socket = Socket(ipc_socket, self.event_callback) + self.socket.start() + self.command_id = 1 + self.rid_lock = threading.Lock() + self.socket_lock = threading.Lock() + self.cid_result = {} + self.cid_wait = {} + + def stop(self): + """Terminate the underlying connection.""" + self.socket.stop() + + def event_callback(self, data): + """Internal callback for recieving events from MPV.""" + if "request_id" in data: + self.cid_result[data["request_id"]] = data + self.cid_wait[data["request_id"]].set() + elif "event" in data: + self.callback(data["event"], data) + + def command(self, command, *args): + """ + Issue a command to MPV. Will block until completed or timeout is reached. + + *command* is the name of the MPV command + + All further arguments are forwarded to the MPV command. + Throws TimeoutError if timeout of 120 seconds is reached. + """ + self.rid_lock.acquire() + command_id = self.command_id + self.command_id += 1 + self.rid_lock.release() + + event = threading.Event() + self.cid_wait[command_id] = event + + command_list = [command] + command_list.extend(args) + try: + self.socket_lock.acquire() + self.socket.send({"command":command_list, "request_id": command_id}) + finally: + self.socket_lock.release() + + has_event = event.wait(timeout=TIMEOUT) + if has_event: + data = self.cid_result[command_id] + del self.cid_result[command_id] + del self.cid_wait[command_id] + if data["error"] != "success": + if data["error"] == "property unavailable": + return None + raise MPVError(data["error"]) + else: + return data.get("data") + else: + raise TimeoutError("No response from MPV.") + +class EventHandler(threading.Thread): + """Event handling thread. (Internal)""" + def __init__(self): + """Create an instance of the thread.""" + self.queue = queue.Queue() + threading.Thread.__init__(self) + + def put_task(self, func, *args): + """ + Put a new task to the thread. + + *func* is the function to call + + All further arguments are forwarded to *func*. + """ + self.queue.put((func, args)) + + def stop(self): + """Terminate the thread.""" + self.queue.put("quit") + self.join() + + def run(self): + """Process socket events. Do not run this directly. Use *start*.""" + while True: + event = self.queue.get() + if event == "quit": + break + try: + event[0](*event[1]) + except Exception: + log.error("EventHandler caught exception from {0}.".format(event), exc_info=1) + +class MPV: + """ + The main MPV interface class. Use this to control MPV. + + This will expose all mpv commands as callable methods and all properties. + You can set properties and call the commands directly. + + Please note that if you are using a really old MPV version, a fallback command + list is used. Not all commands may actually work when this fallback is used. + """ + def __init__(self, start_mpv=True, ipc_socket=None, mpv_location=None, log_handler=None, loglevel=None, **kwargs): + """ + Create the interface to MPV and process instance. + + *start_mpv* will start an MPV process if true. (Default: True) + *ipc_socket* is the path to the Unix/Linux socket or name of Windows pipe. (Default: Random Temp File) + *mpv_location* is the location of MPV for *start_mpv*. (Default: Use MPV in PATH) + *log_handler(level, prefix, text)* is an optional handler for log events. (Default: Disabled) + *loglevel* is the level for log messages. Levels are fatal, error, warn, info, v, debug, trace. (Default: Disabled) + + All other arguments are forwarded to MPV as command-line arguments if *start_mpv* is used. + """ + self.properties = {} + self.event_bindings = {} + self.key_bindings = {} + self.property_bindings = {} + self.mpv_process = None + self.mpv_inter = None + self.event_handler = EventHandler() + self.event_handler.start() + if ipc_socket is None: + rand_file = "mpv{0}".format(random.randint(0, 2**48)) + if os.name == "nt": + ipc_socket = rand_file + else: + ipc_socket = "/tmp/{0}".format(rand_file) + + if start_mpv: + # Attempt to start MPV 3 times. + for i in range(3): + try: + self.mpv_process = MPVProcess(ipc_socket, mpv_location, **kwargs) + break + except MPVError: + log.warning("MPV start failed.", exc_info=1) + continue + else: + raise MPVError("MPV process retry limit reached.") + + self.mpv_inter = MPVInter(ipc_socket, self._callback) + self.properties = set(x.replace("-", "_") for x in self.command("get_property", "property-list")) + try: + command_list = [x["name"] for x in self.command("get_property", "command-list")] + except MPVError: + log.warning("Using fallback command list.") + command_list = FALLBACK_COMMAND_LIST + for command in command_list: + object.__setattr__(self, command.replace("-", "_"), self._get_wrapper(command)) + + self._dir = list(self.properties) + self._dir.extend(object.__dir__(self)) + + self.observer_id = 1 + self.observer_lock = threading.Lock() + self.keybind_id = 1 + self.keybind_lock = threading.Lock() + + if log_handler is not None and loglevel is not None: + self.command("request_log_messages", loglevel) + @self.on_event("log-message") + def log_handler_event(data): + self.event_handler.put_task(log_handler, data["level"], data["prefix"], data["text"].strip()) + + @self.on_event("property-change") + def event_handler(data): + if data.get("id") in self.property_bindings: + self.event_handler.put_task(self.property_bindings[data["id"]], data["name"], data.get("data")) + + @self.on_event("client-message") + def client_message_handler(data): + args = data["args"] + if len(args) == 2 and args[0] == "custom-bind": + self.event_handler.put_task(self.key_bindings[args[1]]) + + def bind_event(self, name, callback): + """ + Bind a callback to an MPV event. + + *name* is the MPV event name. + *callback(event_data)* is the function to call. + """ + if name not in self.event_bindings: + self.event_bindings[name] = set() + self.event_bindings[name].add(callback) + + def on_event(self, name): + """ + Decorator to bind a callback to an MPV event. + + @on_event(name) + def my_callback(event_data): + pass + """ + def wrapper(func): + self.bind_event(name, func) + return func + return wrapper + + # Added for compatibility. + def event_callback(self, name): + """An alias for on_event to maintain compatibility with python-mpv.""" + return self.on_event(name) + + def on_key_press(self, name): + """ + Decorator to bind a callback to an MPV keypress event. + + @on_key_press(key_name) + def my_callback(): + pass + """ + def wrapper(func): + self.bind_key_press(name, func) + return func + return wrapper + + def bind_key_press(self, name, callback): + """ + Bind a callback to an MPV keypress event. + + *name* is the key symbol. + *callback()* is the function to call. + """ + self.keybind_lock.acquire() + keybind_id = self.keybind_id + self.keybind_id += 1 + self.keybind_lock.release() + + bind_name = "bind{0}".format(keybind_id) + self.key_bindings["bind{0}".format(keybind_id)] = callback + try: + self.keybind(name, "script-message custom-bind {0}".format(bind_name)) + except MPVError: + self.define_section(bind_name, "{0} script-message custom-bind {1}".format(name, bind_name)) + self.enable_section(bind_name) + + def bind_property_observer(self, name, callback): + """ + Bind a callback to an MPV property change. + + *name* is the property name. + *callback(name, data)* is the function to call. + + Returns a unique observer ID needed to destroy the observer. + """ + self.observer_lock.acquire() + observer_id = self.observer_id + self.observer_id += 1 + self.observer_lock.release() + + self.property_bindings[observer_id] = callback + self.command("observe_property", observer_id, name) + return observer_id + + def unbind_property_observer(self, observer_id): + """ + Remove callback to an MPV property change. + + *observer_id* is the id returned by bind_property_observer. + """ + self.command("unobserve_property", observer_id) + del self.property_bindings[observer_id] + + def property_observer(self, name): + """ + Decorator to bind a callback to an MPV property change. + + @property_observer(property_name) + def my_callback(name, data): + pass + """ + def wrapper(func): + self.bind_property_observer(name, func) + return func + return wrapper + + def wait_for_property(self, name): + """ + Waits for the value of a property to change. + + *name* is the name of the property. + """ + event = threading.Event() + first_event = True + def handler(*_): + nonlocal first_event + if first_event == True: + first_event = False + else: + event.set() + observer_id = self.bind_property_observer(name, handler) + event.wait() + self.unbind_property_observer(observer_id) + + def _get_wrapper(self, name): + def wrapper(*args): + return self.command(name, *args) + return wrapper + + def _callback(self, event, data): + if event in self.event_bindings: + for callback in self.event_bindings[event]: + self.event_handler.put_task(callback, data) + + def play(self, url): + """Play the specified URL. An alias to loadfile().""" + self.loadfile(url) + + def __del__(self): + self.terminate() + + def terminate(self): + """Terminate the connection to MPV and process (if *start_mpv* is used).""" + if self.mpv_process: + self.mpv_process.stop() + if self.mpv_inter: + self.mpv_inter.stop() + self.event_handler.stop() + + def command(self, command, *args): + """ + Send a command to MPV. All commands are bound to the class by default, + except JSON IPC specific commands. This may also be useful to retain + compatibility with python-mpv, as it does not bind all of the commands. + + *command* is the command name. + + All further arguments are forwarded to the MPV command. + """ + return self.mpv_inter.command(command, *args) + + def __getattr__(self, name): + if name in self.properties: + return self.command("get_property", name.replace("_", "-")) + return object.__getattribute__(self, name) + + def __setattr__(self, name, value): + if name not in {"properties", "command"} and name in self.properties: + return self.command("set_property", name.replace("_", "-"), value) + return object.__setattr__(self, name, value) + + def __hasattr__(self, name): + if object.__hasattr__(self, name): + return True + else: + try: + getattr(self, name) + return True + except MPVError: + return False + + def __dir__(self): + return self._dir diff --git a/syncplay/players/python_mpv_jsonipc/setup.py b/syncplay/players/python_mpv_jsonipc/setup.py new file mode 100644 index 0000000..11be65a --- /dev/null +++ b/syncplay/players/python_mpv_jsonipc/setup.py @@ -0,0 +1,25 @@ +from setuptools import setup +import os + +with open("README.md", "r") as fh: + long_description = fh.read() + +setup( + name='python-mpv-jsonipc', + version='1.1.10', + author="Ian Walton", + author_email="iwalton3@gmail.com", + description="Python API to MPV using JSON IPC", + license='Apache-2.0', + long_description=open('README.md').read(), + long_description_content_type="text/markdown", + url="https://github.com/iwalton3/python-mpv-jsonipc", + py_modules=['python_mpv_jsonipc'], + classifiers=[ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: Apache Software License", + "Operating System :: OS Independent", + ], + python_requires='>=3.6', + install_requires=[] +)