python-mpv-jsonipc 1.3.0__py3-none-any.whl → 1.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-mpv-jsonipc
3
- Version: 1.3.0
3
+ Version: 1.5.0
4
4
  Summary: Python API to MPV using JSON IPC
5
5
  Author-email: Izzie Walton <izzie@iwalton.com>
6
6
  License-Expression: Apache-2.0
@@ -55,6 +55,10 @@ mpv.volume = 20
55
55
  # You can also send commands.
56
56
  mpv.command_name(arg1, arg2)
57
57
 
58
+ # Or send one without waiting for it. Returns a concurrent.futures.Future.
59
+ mpv.command_async("sub-add", "https://example.com/slow.srt",
60
+ callback=lambda error, result: print(error or "loaded"))
61
+
58
62
  # Bind to key press events with a decorator
59
63
  @mpv.on_key_press("space")
60
64
  def space_handler():
@@ -0,0 +1,6 @@
1
+ python_mpv_jsonipc.py,sha256=6xQ45aP3vLNxLy6OdyReB-MPnQvmOJ00CzzNQ13iGS4,61266
2
+ python_mpv_jsonipc-1.5.0.dist-info/licenses/LICENSE.md,sha256=7GQBUOA3dNG1Jk-yaPOCncNnRVURCRMev23QpZ1ew-s,10495
3
+ python_mpv_jsonipc-1.5.0.dist-info/METADATA,sha256=axbDuWhKzIPoR1rvoF-b-MXVJx78LqF2MzFK6CGbmP8,2489
4
+ python_mpv_jsonipc-1.5.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
5
+ python_mpv_jsonipc-1.5.0.dist-info/top_level.txt,sha256=nGk4yjpVHKmIrL39XdGD2PhCeWF0oMjwey0nsNOd2TM,19
6
+ python_mpv_jsonipc-1.5.0.dist-info/RECORD,,
python_mpv_jsonipc.py CHANGED
@@ -8,12 +8,105 @@ import subprocess
8
8
  import random
9
9
  import queue
10
10
  import logging
11
+ from concurrent.futures import Future
11
12
 
12
13
  log = logging.getLogger('mpv-jsonipc')
13
14
 
14
15
  if os.name == "nt":
15
- import _winapi
16
- from multiprocessing.connection import PipeConnection
16
+ import ctypes
17
+ import ctypes.wintypes as _w
18
+
19
+ # The Windows transport calls kernel32 directly rather than going through
20
+ # ``_winapi`` and ``multiprocessing.connection.PipeConnection``. The second
21
+ # of those is a live bug, not tidiness:
22
+ #
23
+ # PipeConnection says outright that "a connection should only be used by
24
+ # a single thread", and this library used it from two -- a reader parked
25
+ # in recv_bytes, and whoever called stop(). Its _close() cancels a
26
+ # pending *send* before closing the handle, but has no equivalent for a
27
+ # pending *read*, because _recv_bytes never stores its OVERLAPPED
28
+ # anywhere reachable. So stop() closed the handle with an overlapped
29
+ # read outstanding and a kernel-owned buffer still live, which is
30
+ # undefined: the kernel may complete the I/O into memory Python is
31
+ # tearing down. CPython 3.13+ allocates its thread primitives from the
32
+ # parking lot, so that corruption now lands as an abort --
33
+ # ``Fatal Python error: _PySemaphore_Wakeup: ReleaseSemaphore failed
34
+ # (error: 6)`` -- seen in CI on both 3.13 and 3.14.
35
+ #
36
+ # Neither ``_winapi`` (private C extension) nor ``PipeConnection`` (absent
37
+ # from __all__, defined conditionally, an implementation detail of Pipe())
38
+ # carries a compatibility contract, so this also removes a dependency that
39
+ # breaks along the *Python* axis rather than ours. ctypes is stdlib and
40
+ # needs no PyInstaller hooks or bundled DLLs, which is the property the
41
+ # original _winapi choice was buying and that downstream projects such as
42
+ # SyncPlay and jellyfin-mpv-shim adopted this library for.
43
+ _kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
44
+
45
+ _GENERIC_READ = 0x80000000
46
+ _GENERIC_WRITE = 0x40000000
47
+ _OPEN_EXISTING = 3
48
+ _FILE_FLAG_OVERLAPPED = 0x40000000
49
+ _INVALID_HANDLE_VALUE = ctypes.c_void_p(-1).value
50
+ _INFINITE = 0xFFFFFFFF
51
+ _WAIT_OBJECT_0 = 0
52
+ _ERROR_FILE_NOT_FOUND = 2
53
+ _ERROR_PATH_NOT_FOUND = 3
54
+ _ERROR_BROKEN_PIPE = 109
55
+ # Also "the other end is gone", and reached when the peer disconnects
56
+ # rather than exits. Without them a normal disconnect logs an ERROR and a
57
+ # traceback. Measured on real Windows, including against the old
58
+ # PipeConnection build, which logged it too -- it only raised EOFError for
59
+ # 109 -- so this is an improvement on the previous behaviour rather than a
60
+ # regression being repaired. Real MPV exiting gives 109, which is why no
61
+ # CI leg ever showed it.
62
+ _ERROR_NO_DATA = 232
63
+ _ERROR_PIPE_NOT_CONNECTED = 233
64
+ _ERROR_MORE_DATA = 234
65
+ _ERROR_OPERATION_ABORTED = 995
66
+ _ERROR_IO_PENDING = 997
67
+
68
+ #: Every way the far end can be gone. All of them are a clean end of
69
+ #: stream, not an error to log.
70
+ _PEER_GONE = (_ERROR_BROKEN_PIPE, _ERROR_NO_DATA, _ERROR_PIPE_NOT_CONNECTED)
71
+
72
+ class _OVERLAPPED(ctypes.Structure):
73
+ _fields_ = [("Internal", ctypes.c_void_p),
74
+ ("InternalHigh", ctypes.c_void_p),
75
+ ("Offset", _w.DWORD),
76
+ ("OffsetHigh", _w.DWORD),
77
+ ("hEvent", _w.HANDLE)]
78
+
79
+ # Declared explicitly: without argtypes ctypes truncates pointers to int
80
+ # on 64-bit, which fails in ways that look like random handle errors.
81
+ _kernel32.CreateFileW.argtypes = [_w.LPCWSTR, _w.DWORD, _w.DWORD,
82
+ ctypes.c_void_p, _w.DWORD, _w.DWORD,
83
+ _w.HANDLE]
84
+ _kernel32.CreateFileW.restype = _w.HANDLE
85
+ _kernel32.CreateEventW.argtypes = [ctypes.c_void_p, _w.BOOL, _w.BOOL,
86
+ _w.LPCWSTR]
87
+ _kernel32.CreateEventW.restype = _w.HANDLE
88
+ _kernel32.SetEvent.argtypes = [_w.HANDLE]
89
+ _kernel32.SetEvent.restype = _w.BOOL
90
+ _kernel32.ResetEvent.argtypes = [_w.HANDLE]
91
+ _kernel32.ResetEvent.restype = _w.BOOL
92
+ _kernel32.ReadFile.argtypes = [_w.HANDLE, ctypes.c_void_p, _w.DWORD,
93
+ _w.LPDWORD, ctypes.c_void_p]
94
+ _kernel32.ReadFile.restype = _w.BOOL
95
+ _kernel32.WriteFile.argtypes = [_w.HANDLE, ctypes.c_void_p, _w.DWORD,
96
+ _w.LPDWORD, ctypes.c_void_p]
97
+ _kernel32.WriteFile.restype = _w.BOOL
98
+ _kernel32.GetOverlappedResult.argtypes = [_w.HANDLE, ctypes.c_void_p,
99
+ _w.LPDWORD, _w.BOOL]
100
+ _kernel32.GetOverlappedResult.restype = _w.BOOL
101
+ _kernel32.CancelIoEx.argtypes = [_w.HANDLE, ctypes.c_void_p]
102
+ _kernel32.CancelIoEx.restype = _w.BOOL
103
+ _kernel32.WaitForMultipleObjects.argtypes = [_w.DWORD, ctypes.c_void_p,
104
+ _w.BOOL, _w.DWORD]
105
+ _kernel32.WaitForMultipleObjects.restype = _w.DWORD
106
+ _kernel32.CloseHandle.argtypes = [_w.HANDLE]
107
+ _kernel32.CloseHandle.restype = _w.BOOL
108
+ _kernel32.WaitNamedPipeW.argtypes = [_w.LPCWSTR, _w.DWORD]
109
+ _kernel32.WaitNamedPipeW.restype = _w.BOOL
17
110
 
18
111
  TIMEOUT = 120
19
112
 
@@ -123,62 +216,220 @@ class WindowsSocket(threading.Thread):
123
216
  *callback(json_data)* is the function for recieving events.
124
217
  *quit_callback* is called when the socket connection dies.
125
218
  """
219
+ # First, so Thread's own attributes are in place before ours rather
220
+ # than landing on top of them. Thread grew a `self._handle` of its own
221
+ # in 3.13, so the pipe handle is `_pipe`: named `_handle` and assigned
222
+ # before this call, Thread silently replaced it with a
223
+ # _thread._ThreadHandle and every read died in ctypes argument
224
+ # conversion. Anything added below needs a name Thread does not use.
225
+ threading.Thread.__init__(self)
226
+ self.daemon = True
227
+
126
228
  ipc_socket = "\\\\.\\pipe\\" + ipc_socket
127
229
  self.callback = callback
128
230
  self.quit_callback = quit_callback
129
231
  self._stopping = False
130
-
131
- access = _winapi.GENERIC_READ | _winapi.GENERIC_WRITE
232
+ self._closed = False
233
+ # Every send is held under this lock, so holding it is proof that no
234
+ # write is in flight and the pipe is safe to close. The reader is the
235
+ # exception and must stay one -- see run().
236
+ self._io_lock = threading.RLock()
237
+ # Guards the handle *values* against being used after _close cleared
238
+ # them. Held only for the moment it takes to signal or close, never
239
+ # across a wait.
240
+ self._handle_lock = threading.RLock()
241
+
242
+ access = _GENERIC_READ | _GENERIC_WRITE
132
243
  limit = 5 # Connection may fail at first. Try 5 times.
133
244
  for _ in range(limit):
134
- try:
135
- pipe_handle = _winapi.CreateFile(
136
- ipc_socket, access, 0, _winapi.NULL, _winapi.OPEN_EXISTING,
137
- _winapi.FILE_FLAG_OVERLAPPED, _winapi.NULL
138
- )
245
+ # Still five attempts, and still load-bearing: _ipc_endpoint_ready
246
+ # does not run on the start_mpv=False attach path, and it answers
247
+ # True for a pipe whose instances are all busy, which CreateFile
248
+ # then refuses with ERROR_PIPE_BUSY.
249
+ handle = _kernel32.CreateFileW(
250
+ ipc_socket, access, 0, None, _OPEN_EXISTING,
251
+ _FILE_FLAG_OVERLAPPED, None)
252
+ if handle != _INVALID_HANDLE_VALUE:
139
253
  break
140
- except OSError:
141
- time.sleep(1)
254
+ time.sleep(1)
142
255
  else:
256
+ # Deliberately not an OSError: downstream and the failure tests
257
+ # both pin that this is what the Windows attach path raises.
143
258
  raise MPVError("Cannot connect to pipe.")
144
- self.socket = PipeConnection(pipe_handle)
259
+ self._pipe = handle
260
+
261
+ # Manual-reset, so a stop stays visible to the reader and to any send
262
+ # waiting alongside it rather than waking exactly one of them.
263
+ self._stop_event = _kernel32.CreateEventW(None, True, False, None)
264
+ self._read_event = _kernel32.CreateEventW(None, True, False, None)
265
+ self._write_event = _kernel32.CreateEventW(None, True, False, None)
266
+ if not all((self._stop_event, self._read_event, self._write_event)):
267
+ self._close()
268
+ raise MPVError("Cannot create pipe events.")
145
269
 
146
270
  if self.callback is None:
147
271
  self.callback = lambda data: None
148
272
 
149
- threading.Thread.__init__(self)
150
- self.daemon = True
273
+ def _transfer(self, func, event, buf, size):
274
+ """Run one overlapped operation, waiting for it or for *stop*.
275
+
276
+ Returns the bytes transferred, or None if the pipe ended or *stop* cut
277
+ the operation short.
278
+
279
+ The GetOverlappedResult(wait=True) below is the point of this whole
280
+ class. It does not return until the kernel has finished with *buf* and
281
+ the OVERLAPPED -- including when we cancelled it -- so neither is ever
282
+ released while an I/O could still write into it.
283
+ """
284
+ ov = _OVERLAPPED()
285
+ ov.hEvent = event
286
+ _kernel32.ResetEvent(event)
287
+ moved = _w.DWORD(0)
288
+
289
+ # The guarded region starts *before* the call, and assumes the
290
+ # operation may be outstanding until proven otherwise. An
291
+ # asynchronous exception -- a KeyboardInterrupt, which lands on the
292
+ # main thread, which is where send() runs -- can arrive between the
293
+ # call returning ERROR_IO_PENDING and any flag recording that, so a
294
+ # flag set after the call is wrong in exactly the direction that
295
+ # matters: it would let the frame, and its buffer, go while the kernel
296
+ # still owns them. Assuming the worst costs nothing when nothing was
297
+ # started, because a fresh OVERLAPPED has Internal = STATUS_SUCCESS
298
+ # and GetOverlappedResult then returns immediately (measured).
299
+ outstanding = True
300
+ try:
301
+ if func(self._pipe, buf, size, ctypes.byref(moved),
302
+ ctypes.byref(ov)):
303
+ outstanding = False
304
+ return moved.value
305
+
306
+ err = ctypes.get_last_error()
307
+ if err != _ERROR_IO_PENDING:
308
+ outstanding = False
309
+ if err in _PEER_GONE:
310
+ return None
311
+ if err == _ERROR_MORE_DATA:
312
+ # A completed read that filled the buffer, not a failure.
313
+ # The completion path below already treats it that way;
314
+ # raising here would have desynchronised the two.
315
+ return moved.value
316
+ raise ctypes.WinError(err)
317
+
318
+ handles = (_w.HANDLE * 2)(event, self._stop_event)
319
+ waitres = _kernel32.WaitForMultipleObjects(2, handles, False,
320
+ _INFINITE)
321
+ if waitres != _WAIT_OBJECT_0:
322
+ # stop() wants us gone, or the wait itself failed. Either way
323
+ # we will not be consuming this operation. Read the wait's
324
+ # error *first*: CancelIoEx overwrites the thread's last error,
325
+ # so reading it afterwards reports the cancel's success and
326
+ # loses the real cause on the path hardest to reproduce.
327
+ waitfail = ctypes.get_last_error()
328
+ _kernel32.CancelIoEx(self._pipe, ctypes.byref(ov))
329
+ if waitres != _WAIT_OBJECT_0 + 1:
330
+ raise ctypes.WinError(waitfail)
331
+ except BaseException:
332
+ if outstanding:
333
+ _kernel32.CancelIoEx(self._pipe, ctypes.byref(ov))
334
+ raise
335
+ finally:
336
+ # Cancelling only *asks*. This is the wait that makes it true.
337
+ if outstanding:
338
+ completed = _kernel32.GetOverlappedResult(
339
+ self._pipe, ctypes.byref(ov), ctypes.byref(moved), True)
340
+ failure = 0 if completed else ctypes.get_last_error()
341
+
342
+ if not completed:
343
+ if failure in _PEER_GONE or failure == _ERROR_OPERATION_ABORTED:
344
+ return None
345
+ if failure != _ERROR_MORE_DATA:
346
+ raise ctypes.WinError(failure)
347
+ return moved.value
348
+
349
+ def _close(self):
350
+ """Close the handles, once, with no I/O outstanding."""
351
+ # _io_lock first: holding it means no send is mid-write. _handle_lock
352
+ # second, to exclude a concurrent stop() signalling an event we are
353
+ # about to close. Nothing ever takes these in the other order, and
354
+ # _handle_lock is never held across a wait, so stop() cannot block.
355
+ with self._io_lock, self._handle_lock:
356
+ if self._closed:
357
+ return
358
+ self._closed = True
359
+ for name in ("_pipe", "_stop_event", "_read_event",
360
+ "_write_event"):
361
+ handle = getattr(self, name, None)
362
+ if handle:
363
+ _kernel32.CloseHandle(handle)
364
+ # Cleared, not just closed: terminate() is routinely called
365
+ # twice, and a closed handle value can be reused by the OS.
366
+ setattr(self, name, None)
151
367
 
152
368
  def stop(self, join=True):
153
369
  """Terminate the thread."""
154
370
  self._stopping = True
155
- if self.socket is not None:
156
- try:
157
- self.socket.close()
158
- except OSError:
159
- pass # Ignore socket close failure.
160
- if join:
371
+ # Deliberately does NOT close the handle. Closing it from here with
372
+ # the reader's overlapped read still pending is the corruption this
373
+ # class was rewritten to remove; instead we wake the reader and let it
374
+ # close the handle it owns, once its read has provably finished.
375
+ with self._handle_lock:
376
+ if self._stop_event:
377
+ _kernel32.SetEvent(self._stop_event)
378
+ # Two ways join() raises, and both would skip the _close() below and
379
+ # leak all four handles. is_alive() is True for the calling thread, so
380
+ # it never prevented the self-join it looked like it prevented -- the
381
+ # reader reaches here whenever a user's quit_callback calls
382
+ # terminate(). And `ident is None` means start() was never called,
383
+ # where join() raises too.
384
+ if (join and self.ident is not None
385
+ and self is not threading.current_thread()):
161
386
  self.join()
387
+ if not self.is_alive():
388
+ # The reader has finished, or never ran. Either way nothing can
389
+ # have I/O outstanding, so the handle is ours to close.
390
+ self._close()
162
391
 
163
392
  def send(self, data):
164
393
  """Send *data* to the pipe, encoded as JSON."""
165
- try:
166
- self.socket.send_bytes(json.dumps(data).encode('utf-8') + b'\n')
167
- except OSError as ex:
168
- if len(ex.args) == 1 and ex.args[0] == "handle is closed":
394
+ blob = json.dumps(data).encode('utf-8') + b'\n'
395
+ with self._io_lock:
396
+ if self._closed:
169
397
  raise BrokenPipeError("handle is closed")
170
- raise ex
398
+ written = self._transfer(_kernel32.WriteFile, self._write_event,
399
+ blob, len(blob))
400
+ if written is None:
401
+ raise BrokenPipeError("handle is closed")
171
402
 
172
403
  def run(self):
173
404
  """Process pipe events. Do not run this directly. Use *start*."""
405
+ buf = ctypes.create_string_buffer(2048)
174
406
  data = b''
175
407
  try:
176
- while True:
177
- current_data = self.socket.recv_bytes(2048)
178
- if current_data == b'':
408
+ while not self._stopping:
409
+ # The stop event is only observed when a read *pends*, and a
410
+ # read that completes synchronously returns before ever
411
+ # reaching the wait. A peer with data always available could
412
+ # therefore keep stop() waiting in join(). Not reproducible --
413
+ # 124k messages of flooding still let the reader park -- but
414
+ # the flag costs nothing and closes the argument.
415
+ #
416
+ # Emphatically NOT under _io_lock: this read is parked
417
+ # whenever MPV has nothing to say, which is almost always, and
418
+ # holding the lock across it would block every send. The lock
419
+ # exists to keep a send and the close apart; the reader needs
420
+ # no such guard because it closes the handle itself, in the
421
+ # finally below, only once its own read has returned.
422
+ nread = self._transfer(_kernel32.ReadFile, self._read_event,
423
+ buf, len(buf))
424
+ if nread is None: # the peer is gone, or stop()
179
425
  break
426
+ if nread == 0:
427
+ # A zero-length message, not the end: mpv's pipe is
428
+ # message-mode and it answers an async command with an
429
+ # empty write. Treating it as EOF closed the connection.
430
+ continue
180
431
 
181
- data += current_data
432
+ data += buf.raw[:nread]
182
433
  if data[-1] != 10:
183
434
  continue
184
435
 
@@ -189,15 +440,14 @@ class WindowsSocket(threading.Thread):
189
440
  json_data = json.loads(item)
190
441
  self.callback(json_data)
191
442
  data = b''
192
- except EOFError:
193
- if self.quit_callback:
194
- self.quit_callback()
195
- except Exception as ex:
443
+ except Exception:
196
444
  # Only log if not intentionally stopping
197
445
  if not self._stopping:
198
446
  log.error("Pipe connection died.", exc_info=1)
199
- if self.quit_callback:
200
- self.quit_callback()
447
+ finally:
448
+ self._close()
449
+ if self.quit_callback:
450
+ self.quit_callback()
201
451
 
202
452
  class UnixSocket(threading.Thread):
203
453
  """
@@ -229,28 +479,41 @@ class UnixSocket(threading.Thread):
229
479
  def stop(self, join=True):
230
480
  """Terminate the thread."""
231
481
  self._stopping = True
232
- if self.socket is not None:
482
+ # Bind once. Two threads reach here -- the caller's terminate() and
483
+ # the reader's quit_callback -> terminate(join=False) -- and making
484
+ # stop() wait for MPV widened that overlap, because the wait ends on
485
+ # the same MPV exit that wakes the reader. Re-reading self.socket
486
+ # after the None check let the loser call shutdown/close on None,
487
+ # which is an AttributeError and so is *not* caught below.
488
+ sock, self.socket = self.socket, None
489
+ if sock is not None:
233
490
  try:
234
- self.socket.shutdown(socket.SHUT_WR)
235
- self.socket.close()
236
- self.socket = None
491
+ sock.shutdown(socket.SHUT_WR)
492
+ sock.close()
237
493
  except OSError:
238
494
  pass # Ignore socket close failure.
239
- if join:
495
+ # Not a bare `if join`: the reader reaches here whenever a user's
496
+ # quit_callback calls terminate(), and joining yourself raises -- which
497
+ # escaped run() and leaked the event handler. `ident is None` is the
498
+ # never-started case, where join() raises as well.
499
+ if (join and self.ident is not None
500
+ and self is not threading.current_thread()):
240
501
  self.join()
241
502
 
242
503
  def send(self, data):
243
504
  """Send *data* to the socket, encoded as JSON."""
244
- if self.socket is None:
505
+ sock = self.socket # bind once; see stop()
506
+ if sock is None:
245
507
  raise BrokenPipeError("socket is closed")
246
- self.socket.send(json.dumps(data).encode('utf-8') + b'\n')
508
+ sock.send(json.dumps(data).encode('utf-8') + b'\n')
247
509
 
248
510
  def run(self):
249
511
  """Process socket events. Do not run this directly. Use *start*."""
250
512
  data = b''
513
+ sock = self.socket # bind once; see stop()
251
514
  try:
252
515
  while True:
253
- current_data = self.socket.recv(1024)
516
+ current_data = sock.recv(1024)
254
517
  if current_data == b'':
255
518
  break
256
519
 
@@ -282,26 +545,31 @@ def _ipc_endpoint_ready(ipc_socket):
282
545
  """
283
546
  if os.name != 'nt':
284
547
  return os.path.exists(ipc_socket)
285
- try:
286
- _winapi.WaitNamedPipe(ipc_socket, 0)
548
+ if _kernel32.WaitNamedPipeW(ipc_socket, 0):
287
549
  return True
288
- except FileNotFoundError:
550
+ # Both codes, because the old _winapi version raised FileNotFoundError and
551
+ # CPython maps ERROR_PATH_NOT_FOUND to ENOENT as well. Treating 3 as
552
+ # "present but busy" would report a successful start and then burn
553
+ # WindowsSocket's five CreateFileW retries before failing with the wrong
554
+ # exception for the caller's retry loop.
555
+ if ctypes.get_last_error() in (_ERROR_FILE_NOT_FOUND, _ERROR_PATH_NOT_FOUND):
289
556
  return False
290
- except OSError:
291
- # Pipe exists but all instances are momentarily busy — it is present.
292
- return True
557
+ # Pipe exists but all instances are momentarily busy — it is present.
558
+ return True
293
559
 
294
560
 
295
561
  class MPVProcess:
296
562
  """
297
563
  Manages an MPV process, ensuring the socket or pipe is available. (Internal)
298
564
  """
299
- def __init__(self, ipc_socket, mpv_location=None, **kwargs):
565
+ def __init__(self, ipc_socket, mpv_location=None, discard_output=False, **kwargs):
300
566
  """
301
567
  Create and start the MPV process. Will block until socket/pipe is available.
302
568
 
303
569
  *ipc_socket* is the path to the Unix/Linux socket or name of the Windows pipe.
304
570
  *mpv_location* is the path to mpv. If left unset it tries the one in the PATH.
571
+ *discard_output* sends MPV's stdout and stderr to devnull instead of inheriting
572
+ the caller's. (Default: False)
305
573
 
306
574
  All other arguments are forwarded to MPV as command-line arguments.
307
575
  """
@@ -338,7 +606,14 @@ class MPVProcess:
338
606
  args.extend("--{0}={1}".format(v[0].replace("_", "-"), self._mpv_fmt(v[1]))
339
607
  for v in arg_pairs)
340
608
  self.argv = args
341
- self.process = subprocess.Popen(args)
609
+ # close_fds only covers fds above 2, so MPV inherits our stdout and
610
+ # stderr. It never writes to them under --terminal=no, which is why
611
+ # this is invisible until something waits for EOF on the other end of
612
+ # that pipe: an MPV that outlives us holds it open and that reader
613
+ # blocks forever. Opt-in rather than default because callers who do
614
+ # want MPV's output on their terminal have had it for years.
615
+ stdio = subprocess.DEVNULL if discard_output else None
616
+ self.process = subprocess.Popen(args, stdout=stdio, stderr=stdio)
342
617
  ipc_exists = False
343
618
  for _ in range(100): # Give MPV 10 seconds to start.
344
619
  time.sleep(0.1)
@@ -351,11 +626,16 @@ class MPVProcess:
351
626
  log.error("MPV failed with returncode {0}.".format(self.process.returncode))
352
627
  break
353
628
  else:
354
- self.process.terminate()
629
+ # stop(), not a bare terminate(): a failed start orphans MPV
630
+ # exactly like a failed teardown did, and MPV.__init__ retries
631
+ # this up to start_retries times -- so a machine where MPV wedges
632
+ # without opening its pipe used to leave five live processes,
633
+ # each still holding the caller's stdout.
634
+ self._stop_quietly()
355
635
  raise MPVProcessError("MPV start timed out.", argv=args)
356
636
 
357
637
  if not ipc_exists or self.process.returncode is not None:
358
- self.process.terminate()
638
+ self._stop_quietly()
359
639
  # Message unchanged: it has been this string for years and is not
360
640
  # ours to break. What is new is the argv riding along, which is
361
641
  # what lets the caller ask MPV why.
@@ -379,11 +659,72 @@ class MPVProcess:
379
659
  else:
380
660
  return data
381
661
 
382
- def stop(self):
383
- """Terminate the process."""
662
+ def _stop_quietly(self):
663
+ """stop(), for the paths that are already raising something better.
664
+
665
+ A failed unlink here would replace MPVProcessError -- and an OSError
666
+ escapes MPV.__init__'s ``except MPVError`` retry loop, so the caller
667
+ would lose both the retries and the argv and returncode that the
668
+ error carries.
669
+ """
670
+ try:
671
+ self.stop()
672
+ except OSError:
673
+ log.debug("Cleanup after a failed MPV start did not complete.",
674
+ exc_info=1)
675
+
676
+ def stop(self, timeout=5):
677
+ """Terminate the process, and do not return while it may still be alive.
678
+
679
+ *timeout* is the seconds to wait for a polite exit before killing, and
680
+ again for the kill to land.
681
+ """
682
+ # terminate() only *asks*. Returning here while MPV is still running
683
+ # left the caller no supported way to wait for it -- and a caller that
684
+ # exits at that moment orphans an MPV still holding its stdout. Both
685
+ # waits are bounded so a wedged MPV cannot hang teardown.
384
686
  self.process.terminate()
385
- if os.name != 'nt' and os.path.exists(self.ipc_socket):
386
- os.remove(self.ipc_socket)
687
+ try:
688
+ self.process.wait(timeout=timeout)
689
+ except subprocess.TimeoutExpired:
690
+ log.warning("MPV ignored terminate for {0}s; killing it.".format(timeout))
691
+ self.process.kill()
692
+ try:
693
+ self.process.wait(timeout=timeout)
694
+ except subprocess.TimeoutExpired:
695
+ log.error("MPV survived kill; giving up on reaping it.")
696
+ if os.name != 'nt':
697
+ # try/except rather than exists()-then-remove, because two threads
698
+ # reach here: the caller's terminate(), and the reader's
699
+ # quit_callback -> terminate(join=False) when MPV's exit closes
700
+ # the socket. Waiting above made them *overlap by construction* --
701
+ # the wait ends when MPV dies, which is the same event that wakes
702
+ # the reader -- so the check-then-act window is reliably lost.
703
+ try:
704
+ os.remove(self.ipc_socket)
705
+ except FileNotFoundError:
706
+ pass # The other thread got there first, which is all we wanted.
707
+
708
+ #: Stands in a sync command's result slot when the connection closed first.
709
+ _CLOSED = object()
710
+
711
+ def _call_user(what, func, *args):
712
+ """Call user code from a thread this library owns, logging anything it
713
+ raises -- SystemExit included: a caller's bug must not end the reader,
714
+ the close sweep or the drain. Not for EventHandler.run, which a
715
+ BaseException still ends (see there)."""
716
+ try:
717
+ func(*args)
718
+ except BaseException:
719
+ log.error("%s raised.", what, exc_info=1)
720
+
721
+ def _reply_outcome(data):
722
+ """``(error, result)`` for one IPC reply."""
723
+ if data["error"] == "success":
724
+ return None, data.get("data")
725
+ if data["error"] == "property unavailable":
726
+ return None, None
727
+ return MPVError(data["error"]), None
387
728
 
388
729
  class MPVInter:
389
730
  """
@@ -394,6 +735,7 @@ class MPVInter:
394
735
 
395
736
  *ipc_socket* is the path to the Unix/Linux socket or name of the Windows pipe.
396
737
  *callback(event_name, data)* is the function for recieving events.
738
+ It runs on the reader thread; an exception from it is logged.
397
739
  *quit_callback* is called when the socket connection to MPV dies.
398
740
  """
399
741
  Socket = UnixSocket
@@ -405,25 +747,64 @@ class MPVInter:
405
747
  if self.callback is None:
406
748
  self.callback = lambda event, data: None
407
749
 
408
- self.socket = Socket(ipc_socket, self.event_callback, self.quit_callback)
409
- self.socket.start()
410
750
  self.command_id = 1
411
751
  self.rid_lock = threading.Lock()
412
752
  self.socket_lock = threading.Lock()
413
753
  self.cid_result = {}
414
754
  self.cid_wait = {}
755
+ # Before the reader starts: it can deliver a reply, or die, at once.
756
+ self.cid_async = {}
757
+ self.async_lock = threading.Lock()
758
+ self.closed = False
759
+
760
+ self.socket = Socket(ipc_socket, self.event_callback, self._on_quit)
761
+ self.socket.start()
415
762
 
416
763
  def stop(self, join=True):
417
764
  """Terminate the underlying connection."""
418
765
  self.socket.stop(join)
766
+ # The reader's _on_quit usually gets here first. This one does not
767
+ # depend on it, so nothing is left pending once stop() returns.
768
+ self._fail_async()
769
+
770
+ def _on_quit(self):
771
+ self._fail_async()
772
+ if self.quit_callback:
773
+ self.quit_callback()
774
+
775
+ def _fail_async(self):
776
+ """Answer every pending command, async or not: nothing else ever will."""
777
+ with self.async_lock:
778
+ self.closed = True
779
+ pending, self.cid_async = self.cid_async, {}
780
+ for command_id, event in list(self.cid_wait.items()):
781
+ # setdefault: a reply that landed just before still wins.
782
+ self.cid_result.setdefault(command_id, _CLOSED)
783
+ event.set()
784
+ for on_reply in pending.values():
785
+ _call_user("command_async reply handler", on_reply,
786
+ BrokenPipeError("MPV connection closed."), None)
419
787
 
420
788
  def event_callback(self, data):
421
789
  """Internal callback for recieving events from MPV."""
422
790
  if "request_id" in data:
423
- self.cid_result[data["request_id"]] = data
424
- self.cid_wait[data["request_id"]].set()
791
+ request_id = data["request_id"]
792
+ with self.async_lock:
793
+ on_reply = self.cid_async.pop(request_id, None)
794
+ if on_reply is not None:
795
+ _call_user("command_async reply handler", on_reply,
796
+ *_reply_outcome(data))
797
+ return
798
+ # Not indexed: a reply nobody waits for (mpv answers id 0 to a
799
+ # request without one) would raise here, on the reader thread,
800
+ # and end the read loop as if the connection had died.
801
+ event = self.cid_wait.get(request_id)
802
+ if event is not None:
803
+ self.cid_result[request_id] = data
804
+ event.set()
425
805
  elif "event" in data:
426
- self.callback(data["event"], data)
806
+ _call_user("MPVInter event callback", self.callback,
807
+ data["event"], data)
427
808
 
428
809
  def command(self, command, *args):
429
810
  """
@@ -432,7 +813,8 @@ class MPVInter:
432
813
  *command* is the name of the MPV command
433
814
 
434
815
  All further arguments are forwarded to the MPV command.
435
- Throws TimeoutError if timeout of 120 seconds is reached.
816
+ Throws TimeoutError if timeout of 120 seconds is reached, and
817
+ BrokenPipeError as soon as the connection closes before the reply.
436
818
  """
437
819
  self.rid_lock.acquire()
438
820
  command_id = self.command_id
@@ -440,7 +822,12 @@ class MPVInter:
440
822
  self.rid_lock.release()
441
823
 
442
824
  event = threading.Event()
443
- self.cid_wait[command_id] = event
825
+ # Under the lock _fail_async takes, so a close either finds this
826
+ # waiter or is seen here first; neither way waits out TIMEOUT.
827
+ with self.async_lock:
828
+ if self.closed:
829
+ raise BrokenPipeError("MPV connection closed.")
830
+ self.cid_wait[command_id] = event
444
831
 
445
832
  command_list = [command]
446
833
  command_list.extend(args)
@@ -452,23 +839,59 @@ class MPVInter:
452
839
 
453
840
  has_event = event.wait(timeout=TIMEOUT)
454
841
  if has_event:
455
- data = self.cid_result[command_id]
456
- del self.cid_result[command_id]
842
+ data = self.cid_result.pop(command_id)
457
843
  del self.cid_wait[command_id]
458
- if data["error"] != "success":
459
- if data["error"] == "property unavailable":
460
- return None
461
- raise MPVError(data["error"])
462
- else:
463
- return data.get("data")
844
+ if data is _CLOSED:
845
+ raise BrokenPipeError("MPV connection closed.")
846
+ error, result = _reply_outcome(data)
847
+ if error is not None:
848
+ raise error
849
+ return result
464
850
  else:
465
851
  raise TimeoutError("No response from MPV.")
466
852
 
853
+ def command_async(self, on_reply, command, *args):
854
+ """
855
+ Issue a command to MPV without waiting for it to finish.
856
+
857
+ *on_reply(error, result)* is called exactly once: with MPV's answer on
858
+ the transport's reader thread; with BrokenPipeError on the thread
859
+ that closes the connection, or on the calling thread if it is already
860
+ closed; or with the send's own error on the calling thread. An
861
+ exception from it is logged when the reader or the close calls it,
862
+ and raised to the caller otherwise. There is no timeout.
863
+ """
864
+ with self.rid_lock:
865
+ command_id = self.command_id
866
+ self.command_id += 1
867
+
868
+ with self.async_lock:
869
+ closed = self.closed
870
+ if not closed:
871
+ self.cid_async[command_id] = on_reply
872
+ if closed:
873
+ on_reply(BrokenPipeError("MPV connection closed."), None)
874
+ return
875
+
876
+ command_list = [command]
877
+ command_list.extend(args)
878
+ try:
879
+ with self.socket_lock:
880
+ self.socket.send({"command": command_list,
881
+ "request_id": command_id, "async": True})
882
+ except Exception as error:
883
+ with self.async_lock:
884
+ owned = self.cid_async.pop(command_id, None)
885
+ if owned is not None: # else _fail_async has already answered
886
+ on_reply(error, None)
887
+
467
888
  class EventHandler(threading.Thread):
468
889
  """Event handling thread. (Internal)"""
469
890
  def __init__(self):
470
891
  """Create an instance of the thread."""
471
892
  self.queue = queue.Queue()
893
+ self.stopped = False
894
+ self.stop_lock = threading.Lock()
472
895
  threading.Thread.__init__(self)
473
896
  self.daemon = True
474
897
 
@@ -479,24 +902,62 @@ class EventHandler(threading.Thread):
479
902
  *func* is the function to call
480
903
 
481
904
  All further arguments are forwarded to *func*.
905
+
906
+ Returns False, without queueing it, once the thread has stopped --
907
+ by stop(), or because a task raised SystemExit or the like.
482
908
  """
483
- self.queue.put((func, args))
909
+ with self.stop_lock:
910
+ if self.stopped:
911
+ return False
912
+ self.queue.put((func, args))
913
+ return True
484
914
 
485
915
  def stop(self, join=True):
486
916
  """Terminate the thread."""
487
- self.queue.put("quit")
488
- self.join(join)
917
+ with self.stop_lock:
918
+ self.stopped = True
919
+ self.queue.put("quit")
920
+ # A callback that calls terminate() is on this thread. The guard is
921
+ # the transports' (see UnixSocket.stop); `join` stays the timeout.
922
+ if self.ident is not None and self is not threading.current_thread():
923
+ self.join(join)
489
924
 
490
925
  def run(self):
491
926
  """Process socket events. Do not run this directly. Use *start*."""
927
+ try:
928
+ while True:
929
+ event = self.queue.get()
930
+ if event == "quit":
931
+ break
932
+ try:
933
+ event[0](*event[1])
934
+ except Exception:
935
+ log.error("EventHandler caught exception from %s.", event, exc_info=1)
936
+ except BaseException as ex:
937
+ log.warning("A callback raised %r, ending the event thread: "
938
+ "property observers, key bindings and events will no "
939
+ "longer be delivered; command_async callbacks now run "
940
+ "on whichever thread delivers their outcome.", ex)
941
+ # Not _call_user, deliberately: a callback's SystemExit ends this
942
+ # thread, as it has since 2020.
943
+ raise
944
+ finally:
945
+ self._drain()
946
+
947
+ def _drain(self):
948
+ """A callback's SystemExit (or similar) ends this thread, as it always
949
+ has. Refuse work from here on, so put_task says so, and run what was
950
+ already queued: a command_async future may be waiting in it."""
951
+ with self.stop_lock:
952
+ self.stopped = True
492
953
  while True:
493
- event = self.queue.get()
494
- if event == "quit":
495
- break
496
954
  try:
497
- event[0](*event[1])
498
- except Exception:
499
- log.error("EventHandler caught exception from {0}.".format(event), exc_info=1)
955
+ event = self.queue.get_nowait()
956
+ except queue.Empty:
957
+ return
958
+ if event == "quit":
959
+ continue
960
+ _call_user("EventHandler task", event[0], *event[1])
500
961
 
501
962
  class MPV:
502
963
  """
@@ -510,7 +971,7 @@ class MPV:
510
971
  """
511
972
  def __init__(self, start_mpv=True, ipc_socket=None, mpv_location=None,
512
973
  log_handler=None, loglevel=None, quit_callback=None, start_retries=5, start_retry_delay_ms=1000,
513
- diagnose_start_failures=True, **kwargs):
974
+ diagnose_start_failures=True, discard_output=False, **kwargs):
514
975
  """
515
976
  Create the interface to MPV and process instance.
516
977
 
@@ -519,11 +980,16 @@ class MPV:
519
980
  *mpv_location* is the location of MPV for *start_mpv*. (Default: Use MPV in PATH)
520
981
  *log_handler(level, prefix, text)* is an optional handler for log events. (Default: Disabled)
521
982
  *loglevel* is the level for log messages. Levels are fatal, error, warn, info, v, debug, trace. (Default: Disabled)
522
- *quit_callback* is called when the socket connection to MPV dies.
983
+ *quit_callback* is called on the reader thread when the socket connection to MPV dies.
984
+ An exception from it is logged, and MPV is still terminated.
523
985
  *diagnose_start_failures* re-runs MPV once, on the first failed start, to find out
524
986
  why it refused to start. If MPV names an option it does not have, MPVProcessError is
525
987
  raised immediately with *bad_option* set instead of spending every retry on something
526
988
  that cannot succeed. Set it to False for the pre-1.3 behaviour. (Default: True)
989
+ *discard_output* sends MPV's stdout and stderr to devnull instead of letting it
990
+ inherit the caller's. Set it if anything waits for EOF on your stdout, since an
991
+ MPV that outlives *terminate* would otherwise hold that pipe open. Note this is
992
+ not MPV's own --quiet, which is still forwarded as an MPV option. (Default: False)
527
993
 
528
994
  All other arguments are forwarded to MPV as command-line arguments if *start_mpv* is used.
529
995
  """
@@ -550,7 +1016,8 @@ class MPV:
550
1016
  log_output = None
551
1017
  for i in range(start_retries):
552
1018
  try:
553
- self.mpv_process = MPVProcess(ipc_socket, mpv_location, **kwargs)
1019
+ self.mpv_process = MPVProcess(ipc_socket, mpv_location,
1020
+ discard_output=discard_output, **kwargs)
554
1021
  break
555
1022
  except MPVError as error:
556
1023
  last_error = error
@@ -639,7 +1106,7 @@ class MPV:
639
1106
  Internal handler for quit events.
640
1107
  """
641
1108
  if self.quit_callback:
642
- self.quit_callback()
1109
+ _call_user("quit_callback", self.quit_callback)
643
1110
  self.terminate(join=False)
644
1111
 
645
1112
  def bind_event(self, name, callback):
@@ -749,6 +1216,11 @@ class MPV:
749
1216
  Waits for the value of a property to change.
750
1217
 
751
1218
  *name* is the name of the property.
1219
+
1220
+ Never call it from inside a callback here: the change is delivered on
1221
+ the event thread, which the wait would be blocking, so it never ends.
1222
+ It also never returns once a callback has ended the event thread,
1223
+ since the change is then never delivered.
752
1224
  """
753
1225
  event = threading.Event()
754
1226
  first_event = True
@@ -782,6 +1254,14 @@ class MPV:
782
1254
  def terminate(self, join=True):
783
1255
  """Terminate the connection to MPV and process (if *start_mpv* is used)."""
784
1256
  if self.mpv_process:
1257
+ # Unconditionally, regardless of *join*. That flag only ever
1258
+ # reaches our own threads -- and imperfectly: the transports join
1259
+ # properly, while EventHandler.stop passes it as Thread.join's
1260
+ # *timeout*, so join=True waits at most a second there and does
1261
+ # not confirm the thread exited. Either way it says nothing about
1262
+ # MPV, and _quit_callback passes False only because a thread
1263
+ # cannot join itself. Letting it skip this wait would hand the
1264
+ # orphaned-MPV bug back to anyone who passed it.
785
1265
  self.mpv_process.stop()
786
1266
  if self.mpv_inter:
787
1267
  self.mpv_inter.stop(join)
@@ -799,6 +1279,80 @@ class MPV:
799
1279
  """
800
1280
  return self.mpv_inter.command(command, *args)
801
1281
 
1282
+ def command_async(self, command, *args, callback=None):
1283
+ """
1284
+ Send a command to MPV without waiting for it to finish, for one that
1285
+ can take a long time (``sub-add`` of a slow URL). Unlike *command*,
1286
+ it holds neither the caller nor the connection: other commands are
1287
+ answered while it runs, and it has no timeout.
1288
+
1289
+ Returns a ``concurrent.futures.Future``. With no *callback* it holds
1290
+ MPV's result or the error, as *command* would have returned or raised.
1291
+ *callback(error, result)* is called first, with *error* None on
1292
+ success, and the future then holds what it returns or raises.
1293
+
1294
+ The callback runs on the event thread, like every other callback
1295
+ here. If an earlier callback ended that thread by raising SystemExit
1296
+ or the like, a warning is logged, and property observers, key
1297
+ bindings and events are no longer delivered at all; command_async
1298
+ callbacks still run, on whichever thread delivers their outcome
1299
+ instead -- the connection's reader while MPV is connected, or the
1300
+ thread that closes the connection -- and a SystemExit from one there
1301
+ is logged, not raised. With the event thread gone, a call made once
1302
+ the connection has closed (by *terminate* or by MPV exiting), or
1303
+ whose send fails, runs its callback on the calling thread before
1304
+ command_async returns, and a SystemExit from it is raised to that
1305
+ caller. A logging handler that raises SystemExit is not caught
1306
+ anywhere: as in the standard library, it ends the thread that logged,
1307
+ and in the close that leaves the futures after it unresolved.
1308
+
1309
+ The future resolves when MPV answers or, if the connection closes
1310
+ first, with BrokenPipeError -- meaning no answer will come, not that
1311
+ the command did not run. Until then it is pending: a command MPV is
1312
+ still running has no timeout.
1313
+
1314
+ Never block on the future from inside a callback here (a property
1315
+ observer, a key binding, an event, another command_async callback).
1316
+ Those run on the event thread, the thread that would resolve it, so
1317
+ the wait never ends. python-mpv behaves the same.
1318
+
1319
+ Once the event thread is gone, a callback must not block at all. On
1320
+ the reader, a sync *command* waits out TIMEOUT with nothing else read
1321
+ meanwhile, and a wait on a future never ends -- nor does a
1322
+ *terminate* called from another thread. python-mpv fails here too:
1323
+ once a callback's SystemExit stops its event loop, no callback or
1324
+ future is delivered at all.
1325
+
1326
+ The future cannot be cancelled: MPV's IPC protocol cannot abort a
1327
+ command. Needs MPV 0.33 or later; an older MPV ignores the request to
1328
+ run in the background and runs the command as *command* does, blocking
1329
+ every other command on the connection until it finishes.
1330
+ """
1331
+ future = Future()
1332
+ future.set_running_or_notify_cancel()
1333
+
1334
+ def finish(error, result):
1335
+ try:
1336
+ if callback is not None:
1337
+ future.set_result(callback(error, result))
1338
+ elif error is not None:
1339
+ future.set_exception(error)
1340
+ else:
1341
+ future.set_result(result)
1342
+ except BaseException as ex:
1343
+ future.set_exception(ex)
1344
+ if not isinstance(ex, Exception):
1345
+ raise # which thread it reaches, and what happens: see the docstring
1346
+ log.error("command_async callback for {0} raised.".format(
1347
+ command), exc_info=1)
1348
+
1349
+ def on_reply(error, result):
1350
+ if not self.event_handler.put_task(finish, error, result):
1351
+ finish(error, result)
1352
+
1353
+ self.mpv_inter.command_async(on_reply, command, *args)
1354
+ return future
1355
+
802
1356
  def __getattr__(self, name):
803
1357
  if name in self.properties:
804
1358
  return self.command("get_property", name.replace("_", "-"))
@@ -1,6 +0,0 @@
1
- python_mpv_jsonipc.py,sha256=vsGhKYoGeAl4uw652aZZsMF2weee_i8o7sJV9Ssp80g,31762
2
- python_mpv_jsonipc-1.3.0.dist-info/licenses/LICENSE.md,sha256=7GQBUOA3dNG1Jk-yaPOCncNnRVURCRMev23QpZ1ew-s,10495
3
- python_mpv_jsonipc-1.3.0.dist-info/METADATA,sha256=1_LpKl1fCquLmZBb0qYczUSbHynCP6FLGdt92Z5jV4U,2277
4
- python_mpv_jsonipc-1.3.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
5
- python_mpv_jsonipc-1.3.0.dist-info/top_level.txt,sha256=nGk4yjpVHKmIrL39XdGD2PhCeWF0oMjwey0nsNOd2TM,19
6
- python_mpv_jsonipc-1.3.0.dist-info/RECORD,,