├── .gitignore ├── LICENSE ├── README.md ├── example.py ├── mpv.py └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | lib/ 17 | lib64/ 18 | parts/ 19 | sdist/ 20 | var/ 21 | *.egg-info/ 22 | .installed.cfg 23 | *.egg 24 | 25 | # PyInstaller 26 | # Usually these files are written by a python script from a template 27 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 28 | *.manifest 29 | *.spec 30 | 31 | # Installer logs 32 | pip-log.txt 33 | pip-delete-this-directory.txt 34 | 35 | # Unit test / coverage reports 36 | htmlcov/ 37 | .tox/ 38 | .coverage 39 | .cache 40 | nosetests.xml 41 | coverage.xml 42 | 43 | # Translations 44 | *.mo 45 | *.pot 46 | 47 | # Django stuff: 48 | *.log 49 | 50 | # Sphinx documentation 51 | docs/_build/ 52 | 53 | # PyBuilder 54 | target/ 55 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Lars Gustäbel 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # python-mpv 2 | 3 | Control mpv from Python using JSON IPC. 4 | 5 | ## About 6 | 7 | *mpv.py* allows you to start an instance of the [mpv](http://mpv.io) video 8 | player and control it using mpv's [JSON IPC API](http://mpv.io/manual/master/#json-ipc). 9 | 10 | At the moment, think of it more as a toolbox to write your own customized 11 | controller. There is no high-level API (yet). Instead, *mpv.py* offers 12 | everything you should require to implement the commands you need and to receive 13 | events. 14 | 15 | *mpv.py* requires Python >= 3.2. 16 | 17 | ## License 18 | 19 | Copyright(c) 2015, Lars Gustäbel 20 | 21 | It is distributed under the MIT License. 22 | -------------------------------------------------------------------------------- /example.py: -------------------------------------------------------------------------------- 1 | # 2 | # usage: python example.py 3 | # 4 | # For lists of commands, events and properties consult the mpv reference: 5 | # 6 | # http://mpv.io/manual/stable/#list-of-input-commands 7 | # http://mpv.io/manual/stable/#list-of-events 8 | # http://mpv.io/manual/stable/#property-list 9 | # 10 | 11 | import sys 12 | import time 13 | import threading 14 | 15 | from mpv import MPV 16 | 17 | 18 | class MyMPV(MPV): 19 | #------------------------------------------------------------------------- 20 | # Initialization. 21 | #------------------------------------------------------------------------- 22 | 23 | # The mpv process and the communication code run in their own thread 24 | # context. This results in the callback methods below being run in that 25 | # thread as well. 26 | def __init__(self, path): 27 | # Pass a window id to embed mpv into that window. Change debug to True 28 | # to see the json communication. 29 | super().__init__(window_id=None, debug=False) 30 | 31 | self.command("loadfile", path, "append") 32 | self.set_property("playlist-pos", 0) 33 | 34 | self.loaded = threading.Event() 35 | self.loaded.wait() 36 | 37 | #------------------------------------------------------------------------- 38 | # Callbacks 39 | #------------------------------------------------------------------------- 40 | 41 | # The MPV base class automagically registers event callback methods 42 | # if they are specially named: "file-loaded" -> on_file_loaded(). 43 | def on_file_loaded(self): 44 | self.loaded.set() 45 | 46 | # The same applies to property change events: 47 | # "time-pos" -> on_property_time_pos(). 48 | def on_property_time_pos(self, position=None): 49 | if position is None: 50 | return 51 | print("position:", position) 52 | 53 | def on_property_length(self, length=None): 54 | if length is None: 55 | return 56 | print("length in seconds:", length) 57 | 58 | #------------------------------------------------------------------------- 59 | # Commands 60 | #------------------------------------------------------------------------- 61 | # Many commands must be implemented by changing properties. 62 | def play(self): 63 | self.set_property("pause", False) 64 | 65 | def pause(self): 66 | self.set_property("pause", True) 67 | 68 | def seek(self, position): 69 | self.command("seek", position, "absolute") 70 | 71 | 72 | if __name__ == "__main__": 73 | # Open the video player and load a file. 74 | try: 75 | mpv = MyMPV(sys.argv[1]) 76 | except IndexError: 77 | raise SystemExit("usage: python example.py ") 78 | 79 | # Seek to 5 minutes. 80 | mpv.seek(300) 81 | 82 | # Start playback. 83 | mpv.play() 84 | 85 | # Playback for 15 seconds. 86 | time.sleep(15) 87 | 88 | # Pause playback. 89 | mpv.pause() 90 | 91 | # Wait again. 92 | time.sleep(3) 93 | 94 | # Terminate the video player. 95 | mpv.close() 96 | 97 | -------------------------------------------------------------------------------- /mpv.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # 3 | # mpv.py - Control mpv from Python using JSON IPC 4 | # 5 | # Copyright (c) 2015 Lars Gustäbel 6 | # 7 | # Permission is hereby granted, free of charge, to any person obtaining a copy 8 | # of this software and associated documentation files (the "Software"), to deal 9 | # in the Software without restriction, including without limitation the rights 10 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | # copies of the Software, and to permit persons to whom the Software is 12 | # furnished to do so, subject to the following conditions: 13 | # 14 | # The above copyright notice and this permission notice shall be included in all 15 | # copies or substantial portions of the Software. 16 | # 17 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 | # SOFTWARE. 24 | # 25 | # ------------------------------------------------------------------------------ 26 | 27 | import sys 28 | import os 29 | import time 30 | import json 31 | import socket 32 | import select 33 | import tempfile 34 | import threading 35 | import subprocess 36 | import inspect 37 | 38 | from distutils.spawn import find_executable 39 | from queue import Queue, Empty, Full 40 | 41 | 42 | TIMEOUT = 10 43 | 44 | 45 | class MPVError(Exception): 46 | pass 47 | 48 | class MPVProcessError(MPVError): 49 | pass 50 | 51 | class MPVCommunicationError(MPVError): 52 | pass 53 | 54 | class MPVCommandError(MPVError): 55 | pass 56 | 57 | class MPVTimeoutError(MPVError): 58 | pass 59 | 60 | 61 | class MPVBase: 62 | """Base class for communication with the mpv media player via unix socket 63 | based JSON IPC. 64 | """ 65 | 66 | executable = find_executable("mpv") 67 | 68 | default_argv = [ 69 | "--idle=yes", 70 | "--no-input-default-bindings", 71 | "--no-input-terminal" 72 | ] 73 | 74 | def __init__(self, window_id=None, debug=False): 75 | self.window_id = window_id 76 | self.debug = debug 77 | 78 | self._prepare_socket() 79 | self._prepare_process() 80 | self._start_process() 81 | self._start_socket() 82 | self._prepare_thread() 83 | self._start_thread() 84 | 85 | def __del__(self): 86 | self._stop_thread() 87 | self._stop_process() 88 | self._stop_socket() 89 | 90 | def _thread_id(self): 91 | return threading.get_ident() 92 | 93 | # 94 | # Process 95 | # 96 | def _prepare_process(self): 97 | """Prepare the argument list for the mpv process. 98 | """ 99 | self.argv = [self.executable] 100 | self.argv += self.default_argv 101 | self.argv.append(f"--input-ipc-server={self._sock_filename}") 102 | if self.window_id is not None: 103 | self.argv.append(f"--wid={self.window_id}") 104 | 105 | def _start_process(self): 106 | """Start the mpv process. 107 | """ 108 | self._proc = subprocess.Popen(self.argv) 109 | 110 | def _stop_process(self): 111 | """Stop the mpv process. 112 | """ 113 | if hasattr(self, "_proc"): 114 | try: 115 | self._proc.terminate() 116 | except ProcessLookupError: 117 | pass 118 | 119 | # 120 | # Socket communication 121 | # 122 | def _prepare_socket(self): 123 | """Create a random socket filename which we pass to mpv with the 124 | --input-unix-socket option. 125 | """ 126 | fd, self._sock_filename = tempfile.mkstemp(prefix="mpv.") 127 | os.close(fd) 128 | os.remove(self._sock_filename) 129 | 130 | def _start_socket(self): 131 | """Wait for the mpv process to create the unix socket and finish 132 | startup. 133 | """ 134 | # FIXME timeout to give up 135 | while self.is_running(): 136 | time.sleep(0.1) 137 | 138 | try: 139 | self._sock = socket.socket(socket.AF_UNIX) 140 | self._sock.connect(self._sock_filename) 141 | except (FileNotFoundError, ConnectionRefusedError): 142 | continue 143 | else: 144 | break 145 | 146 | else: 147 | raise MPVProcessError("unable to start process") 148 | 149 | def _stop_socket(self): 150 | """Clean up the socket. 151 | """ 152 | if hasattr(self, "_sock_filename"): 153 | try: 154 | os.remove(self._sock_filename) 155 | except OSError: 156 | pass 157 | 158 | def _prepare_thread(self): 159 | """Set up the queues for the communication threads. 160 | """ 161 | self._request_queue = Queue(1) 162 | self._response_queues = {} 163 | self._event_queue = Queue() 164 | self._stop_event = threading.Event() 165 | 166 | def _start_thread(self): 167 | """Start up the communication threads. 168 | """ 169 | self._thread = threading.Thread(target=self._reader) 170 | self._thread.start() 171 | 172 | def _stop_thread(self): 173 | """Stop the communication threads. 174 | """ 175 | if hasattr(self, "_stop_event"): 176 | self._stop_event.set() 177 | if hasattr(self, "_thread"): 178 | self._thread.join() 179 | 180 | def _reader(self): 181 | """Read the incoming json messages from the unix socket that is 182 | connected to the mpv process. Pass them on to the message handler. 183 | """ 184 | buf = b"" 185 | while not self._stop_event.is_set(): 186 | r, w, e = select.select([self._sock], [], [], 1) 187 | if r: 188 | try: 189 | b = self._sock.recv(1024) 190 | except ConnectionResetError: 191 | self._stop_process() 192 | self._stop_event.set() 193 | break 194 | 195 | if not b: 196 | break 197 | buf += b 198 | 199 | newline = buf.find(b"\n") 200 | while newline >= 0: 201 | data = buf[:newline + 1] 202 | buf = buf[newline + 1:] 203 | 204 | if self.debug: 205 | sys.stderr.write("<<< " + data.decode("utf8", "replace")) 206 | 207 | message = self._parse_message(data) 208 | self._handle_message(message) 209 | 210 | newline = buf.find(b"\n") 211 | 212 | # 213 | # Message handling 214 | # 215 | def _compose_message(self, message): 216 | """Return a json representation from a message dictionary. 217 | """ 218 | # XXX may be strict is too strict ;-) 219 | data = json.dumps(message, separators=",:") 220 | return data.encode("utf8", "strict") + b"\n" 221 | 222 | def _parse_message(self, data): 223 | """Return a message dictionary from a json representation. 224 | """ 225 | # XXX may be strict is too strict ;-) 226 | data = data.decode("utf8", "strict") 227 | return json.loads(data) 228 | 229 | def _handle_message(self, message): 230 | """Handle different types of incoming messages, i.e. responses to 231 | commands or asynchronous events. 232 | """ 233 | if "error" in message: 234 | # This message is a reply to a request. 235 | try: 236 | thread_id = self._request_queue.get(timeout=TIMEOUT) 237 | except Empty: 238 | raise MPVCommunicationError("got a response without a pending request") 239 | 240 | self._response_queues[thread_id].put(message) 241 | 242 | elif "event" in message: 243 | # This message is an asynchronous event. 244 | self._event_queue.put(message) 245 | 246 | else: 247 | raise MPVCommunicationError("invalid message %r" % message) 248 | 249 | def _send_message(self, message, timeout=None): 250 | """Send a message/command to the mpv process, message must be a 251 | dictionary of the form {"command": ["arg1", "arg2", ...]}. Responses 252 | from the mpv process must be collected using _get_response(). 253 | """ 254 | data = self._compose_message(message) 255 | 256 | if self.debug: 257 | sys.stderr.write(">>> " + data.decode("utf8", "replace")) 258 | 259 | # Request/response cycles are coordinated across different threads, so 260 | # that they don't get mixed up. This makes it possible to use commands 261 | # (e.g. fetch properties) from event callbacks that run in a different 262 | # thread context. 263 | thread_id = self._thread_id() 264 | if thread_id not in self._response_queues: 265 | # Prepare a response queue for the thread to wait on. 266 | self._response_queues[thread_id] = Queue() 267 | 268 | # Put the id of the current thread on the request queue. This id is 269 | # later used to associate responses from the mpv process with this 270 | # request. 271 | try: 272 | self._request_queue.put(thread_id, block=True, timeout=timeout) 273 | except Full: 274 | raise MPVTimeoutError("unable to put request") 275 | 276 | # Write the message data to the socket. 277 | while data: 278 | size = self._sock.send(data) 279 | if size == 0: 280 | raise MPVCommunicationError("broken sender socket") 281 | data = data[size:] 282 | 283 | def _get_response(self, timeout=None): 284 | """Collect the response message to a previous request. If there was an 285 | error a MPVCommandError exception is raised, otherwise the command 286 | specific data is returned. 287 | """ 288 | try: 289 | message = self._response_queues[self._thread_id()].get(block=True, timeout=timeout) 290 | except Empty: 291 | raise MPVTimeoutError("unable to get response") 292 | 293 | if message["error"] != "success": 294 | raise MPVCommandError(message["error"]) 295 | else: 296 | return message.get("data") 297 | 298 | def _get_event(self, timeout=None): 299 | """Collect a single event message that has been received out-of-band 300 | from the mpv process. If a timeout is specified and there have not 301 | been any events during that period, None is returned. 302 | """ 303 | try: 304 | return self._event_queue.get(block=timeout is not None, timeout=timeout) 305 | except Empty: 306 | return None 307 | 308 | def _send_request(self, message, timeout=None): 309 | """Send a command to the mpv process and collect the result. 310 | """ 311 | self._send_message(message, timeout) 312 | try: 313 | return self._get_response(timeout) 314 | except MPVCommandError as e: 315 | raise MPVCommandError("%r: %s" % (message["command"], e)) 316 | 317 | # 318 | # Public API 319 | # 320 | def is_running(self): 321 | """Return True if the mpv process is still active. 322 | """ 323 | return self._proc.poll() is None 324 | 325 | def close(self): 326 | """Shutdown the mpv process and our communication setup. 327 | """ 328 | if self.is_running(): 329 | self._send_request({"command": ["quit"]}, timeout=TIMEOUT) 330 | self._stop_process() 331 | self._stop_thread() 332 | self._stop_socket() 333 | 334 | 335 | class MPV(MPVBase): 336 | """Class for communication with the mpv media player via unix socket 337 | based JSON IPC. It adds a few usable methods and a callback API. 338 | 339 | To automatically register methods as event callbacks, subclass this 340 | class and define specially named methods as follows: 341 | 342 | def on_file_loaded(self): 343 | # This is called for every 'file-loaded' event. 344 | ... 345 | 346 | def on_property_time_pos(self, position): 347 | # This is called whenever the 'time-pos' property is updated. 348 | ... 349 | 350 | Please note that callbacks are executed inside a separate thread. The 351 | MPV class itself is completely thread-safe. Requests from different 352 | threads to the same MPV instance are synchronized. 353 | """ 354 | 355 | def __init__(self, *args, **kwargs): 356 | self._callbacks_queue = Queue() 357 | self._callbacks_initialized = False 358 | 359 | super().__init__(*args, **kwargs) 360 | 361 | self._callbacks = {} 362 | self._property_serials = {} 363 | self._new_serial = iter(range(sys.maxsize)) 364 | 365 | # Enumerate all methods and auto-register callbacks for 366 | # events and property-changes. 367 | for method_name, method in inspect.getmembers(self): 368 | if not inspect.ismethod(method): 369 | continue 370 | 371 | if method_name.startswith("on_property_"): 372 | name = method_name[12:] 373 | name = name.replace("_", "-") 374 | self.register_property_callback(name, method) 375 | 376 | elif method_name.startswith("on_"): 377 | name = method_name[3:] 378 | name = name.replace("_", "-") 379 | self.register_callback(name, method) 380 | 381 | self._callbacks_initialized = True 382 | while True: 383 | try: 384 | message = self._callbacks_queue.get_nowait() 385 | except Empty: 386 | break 387 | self._handle_event(message) 388 | 389 | # Simulate an init event when the process and all callbacks have been 390 | # completely set up. 391 | if hasattr(self, "on_init"): 392 | self.on_init() 393 | 394 | # 395 | # Socket communication 396 | # 397 | def _start_thread(self): 398 | """Start up the communication threads. 399 | """ 400 | super()._start_thread() 401 | self._event_thread = threading.Thread(target=self._event_reader) 402 | self._event_thread.start() 403 | 404 | def _stop_thread(self): 405 | """Stop the communication threads. 406 | """ 407 | super()._stop_thread() 408 | if hasattr(self, "_event_thread"): 409 | self._event_thread.join() 410 | 411 | # 412 | # Event/callback API 413 | # 414 | def _event_reader(self): 415 | """Collect incoming event messages and call the event handler. 416 | """ 417 | while not self._stop_event.is_set(): 418 | message = self._get_event(timeout=1) 419 | if message is None: 420 | continue 421 | 422 | self._handle_event(message) 423 | 424 | def _handle_event(self, message): 425 | """Lookup and call the callbacks for a particular event message. 426 | """ 427 | if not self._callbacks_initialized: 428 | self._callbacks_queue.put(message) 429 | return 430 | 431 | if message["event"] == "property-change": 432 | name = "property-" + message["name"] 433 | else: 434 | name = message["event"] 435 | 436 | for callback in self._callbacks.get(name, []): 437 | if message["event"] == "client-message": 438 | callback(message["args"]) 439 | elif "data" in message: 440 | callback(message["data"]) 441 | else: 442 | callback() 443 | 444 | def register_callback(self, name, callback): 445 | """Register a function `callback` for the event `name`. 446 | """ 447 | try: 448 | self.command("enable_event", name) 449 | except MPVCommandError: 450 | raise MPVError("no such event %r" % name) 451 | 452 | self._callbacks.setdefault(name, []).append(callback) 453 | 454 | def unregister_callback(self, name, callback): 455 | """Unregister a previously registered function `callback` for the event 456 | `name`. 457 | """ 458 | try: 459 | callbacks = self._callbacks[name] 460 | except KeyError: 461 | raise MPVError("no callbacks registered for event %r" % name) 462 | 463 | try: 464 | callbacks.remove(callback) 465 | except ValueError: 466 | raise MPVError("callback %r not registered for event %r" % (callback, name)) 467 | 468 | def register_property_callback(self, name, callback): 469 | """Register a function `callback` for the property-change event on 470 | property `name`. 471 | """ 472 | # Property changes are normally not sent over the connection unless they 473 | # are requested using the 'observe_property' command. 474 | self._callbacks.setdefault("property-" + name, []).append(callback) 475 | 476 | # 'observe_property' expects some kind of id which can be used later 477 | # for unregistering with 'unobserve_property'. 478 | serial = next(self._new_serial) 479 | self.command("observe_property", serial, name) 480 | self._property_serials[(name, callback)] = serial 481 | return serial 482 | 483 | def unregister_property_callback(self, name, callback): 484 | """Unregister a previously registered function `callback` for the 485 | property-change event on property `name`. 486 | """ 487 | try: 488 | callbacks = self._callbacks["property-" + name] 489 | except KeyError: 490 | raise MPVError("no callbacks registered for property %r" % name) 491 | 492 | try: 493 | callbacks.remove(callback) 494 | except ValueError: 495 | raise MPVError("callback %r not registered for property %r" % (callback, name)) 496 | 497 | serial = self._property_serials.pop((name, callback)) 498 | self.command("unobserve_property", serial) 499 | 500 | # 501 | # Public API 502 | # 503 | def command(self, *args, timeout=TIMEOUT): 504 | """Execute a single command on the mpv process and return the result. 505 | """ 506 | return self._send_request({"command": list(args)}, timeout=timeout) 507 | 508 | def get_property(self, name): 509 | """Return the value of property `name`. 510 | """ 511 | return self.command("get_property", name) 512 | 513 | def set_property(self, name, value): 514 | """Set the value of property `name`. 515 | """ 516 | return self.command("set_property", name, value) 517 | 518 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from distutils.core import setup 4 | 5 | kwargs = { 6 | "name": "python-mpv", 7 | "author": "Lars Gustäbel", 8 | "author_email": "lars@gustaebel.de", 9 | "url": "http://github.com/gustaebel/python-mpv/", 10 | "description": "control mpv from Python using JSON IPC", 11 | "license": "MIT", 12 | "py_modules": ["mpv"], 13 | } 14 | 15 | setup(**kwargs) 16 | 17 | --------------------------------------------------------------------------------