diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..52eb072 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,106 @@ +name: 'Tests' + + +on: + push: + branches: [ '**' ] + pull_request: + branches: [ '**' ] + + +defaults: + run: + shell: bash + + +jobs: + test-linux: + runs-on: ubuntu-latest + name: 'Linux - Python' + strategy: + matrix: + python-version: [ '3.8', '3.9', '3.10', '3.11', '3.12' ] + fail-fast: false + env: + DISPLAY: :0 + PY_MPV_SKIP_TESTS: >- + test_wait_for_property_event_overflow + steps: + - uses: actions/checkout@v4 + - name: 'Install Python' + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: 'Update Packages' + run: | + function execute() { echo -e "\033[0;34m$*\033[0m"; "$@"; } + + execute sudo apt update -y + execute sudo apt upgrade -y + - name: 'Install Dependencies' + run: | + function execute() { echo -e "\033[0;34m$*\033[0m"; "$@"; } + + execute sudo apt install -y libmpv1 xvfb + - name: 'Start Xvfb' + run: | + echo -e "\033[0;34msudo /usr/bin/Xvfb $DISPLAY -screen 0 1920x1080x24 &\033[0m"; + sudo /usr/bin/Xvfb $DISPLAY -screen 0 1920x1080x24 & + - name: 'Setup Test Environment' + run: | + function execute() { echo -e "\033[0;34m$*\033[0m"; "$@"; } + + execute python -m venv venv + execute source venv/bin/activate + execute python -m pip install --upgrade pip + execute python -m pip install wheel + execute python -m pip install -r tests/requirements.txt + - name: 'Run Python Tests' + run: | + function execute() { echo -e "\033[0;34m$*\033[0m"; "$@"; } + + execute source venv/bin/activate + execute xvfb-run python -m unittest + + test-windows: + runs-on: windows-latest + name: 'Windows - Python' + strategy: + matrix: + python-version: [ '3.8', '3.9', '3.10', '3.11', '3.12' ] + fail-fast: false + steps: + - uses: actions/checkout@v4 + - name: 'Install Python' + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: 'Provide libmpv' + run: | + function execute() { echo -e "\033[0;34m$*\033[0m"; "$@"; } + + ARTIFACT="mpv-dev-x86_64-v3-20240121-git-a39f9b6.7z" + SHA1="0764a4b899a7ebb1476e5b491897c5e2eed8a07f" + URL="https://sourceforge.net/projects/mpv-player-windows/files/libmpv/$ARTIFACT" + + execute curl -L -O "$URL" + echo -e "\033[0;34mecho -n $SHA1 $ARTIFACT > $ARTIFACT.sha1\033[0m" + echo -n "$SHA1 $ARTIFACT" > "$ARTIFACT.sha1" + execute sha1sum --check "$ARTIFACT.sha1" + execute 7z x "$ARTIFACT" + execute mv libmpv-2.dll tests + - name: 'Setup Test Environment' + run: | + function execute() { echo -e "\033[0;34m$*\033[0m"; "$@"; } + + execute python -m venv venv + execute source venv/Scripts/activate + execute python -m pip install --upgrade pip + execute python -m pip install wheel + execute python -m pip install -r tests/requirements.txt + - name: 'Run Python Tests' + run: | + function execute() { echo -e "\033[0;34m$*\033[0m"; "$@"; } + + execute source venv/Scripts/activate + execute python -m unittest diff --git a/README.rst b/README.rst index 5009cf8..db57f22 100644 --- a/README.rst +++ b/README.rst @@ -11,7 +11,7 @@ Installation .. code:: bash - pip install python-mpv + pip install mpv ...though you can also realistically just copy `mpv.py`_ into your project as it's all nicely contained in one file. @@ -29,15 +29,14 @@ submit a `pull request`_ on github. On Windows you can place libmpv anywhere in your ``%PATH%`` (e.g. next to ``python.exe``) or next to this module's ``mpv.py``. Before falling back to looking in the mpv module's directory, python-mpv uses the DLL search order built -into ctypes, which is different to the one Windows uses internally. Consult `this stackoverflow post -`__ for details. +into ctypes, which is different to the one Windows uses internally. You can modify `%PATH%` before importing python-mpv +to modify where python-mpv looks for the DLL. Consult `this stackoverflow post `__ +for details. -Python >= 3.7 (officially) -.......................... -The ``master`` branch officially only supports recent python releases (3.5 onwards), but there is the somewhat outdated -but functional `py2compat branch`_ providing Python 2 compatibility. +Python >= 3.9 +............. +We only support python stable releases from the last couple of years. We only test the current stable python release. If you find a compatibility issue with an older python version that still has upstream support (that is less than about four years old), feel free to open an issue_ and we'll have a look. -.. _`py2compat branch`: https://github.com/jaseg/python-mpv/tree/py2compat .. _`issue`: https://github.com/jaseg/python-mpv/issues .. _`pull request`: https://github.com/jaseg/python-mpv/pulls @@ -64,13 +63,13 @@ Usage player.play('https://youtu.be/DOmdB7D-pUU') player.wait_for_playback() -python-mpv mostly exposes mpv's built-in API to python, adding only some porcelain on top. Most "`input commands `_" are mapped to methods of the MPV class. Check out these methods and their docstrings in `the source `__ for things you can do. Additional controls and status information are exposed through `MPV properties `_. These can be accessed like ``player.metadata``, ``player.fullscreen`` and ``player.loop_playlist``. +python-mpv mostly exposes mpv's built-in API to python, adding only some porcelain on top. Most "`input commands `_" are mapped to methods of the MPV class. Check out these methods and their docstrings in `the source `__ for things you can do. Additional controls and status information are exposed through `MPV properties `_. These can be accessed like ``player.metadata``, ``player.fullscreen`` and ``player.loop_playlist``. Threading ~~~~~~~~~ The ``mpv`` module starts one thread for event handling, since MPV sends events that must be processed quickly. The -event queue has a fixed maxmimum size and some operations can cause a large number of events to be sent. +event queue has a fixed maximum size and some operations can cause a large number of events to be sent. If you want to handle threading yourself, you can pass ``start_event_thread=False`` to the ``MPV`` constructor and manually call the ``MPV`` object's ``_loop`` function. If you have some strong need to not use threads and use some @@ -241,7 +240,8 @@ The easiest way to load custom subtitles from a file is to pass the ``--sub-file import mpv player = mpv.MPV() - player.play('test.webm', sub_file='test.srt') + player.loadfile('test.webm', sub_file='test.srt') + player.wait_for_playback() Note that you can also pass many other options to ``loadfile``. See the mpv docs for details. @@ -395,7 +395,7 @@ python-mpv inherits the underlying libmpv's license, which can be either GPLv2 o For details, see `the mpv copyright page`_. .. _`PEP 8`: https://www.python.org/dev/peps/pep-0008/ -.. _`mpv.py`: https://raw.githubusercontent.com/jaseg/python-mpv/master/mpv.py +.. _`mpv.py`: https://raw.githubusercontent.com/jaseg/python-mpv/main/mpv.py .. _cosven: https://github.com/cosven .. _Robozman: https://gitlab.com/robozman .. _dfaker: https://github.com/dfaker diff --git a/do_release.sh b/do_release.sh index d5cdea4..b1ae0f3 100755 --- a/do_release.sh +++ b/do_release.sh @@ -3,6 +3,12 @@ [ $# -eq 1 ] || exit 2 VER="$1" + +echo "$VER" | grep '^[0-9]\+\.[0-9]\+\.[0-9]\+$' || { + echo "Call this script as ./do_release.sh [version] where version has format 1.2.3, without \"v\" prefix." + exit 2 +} + echo "Creating version $VER" if [ -n "$(git diff --name-only --cached)" ]; then @@ -10,8 +16,9 @@ if [ -n "$(git diff --name-only --cached)" ]; then exit 2 fi -sed -i "s/^\\(\\s*version\\s*=\\s*['\"]\\)[^'\"]*\\(['\"]\\s*,\\s*\\)$/\\1"$VER"\\2/" setup.py -git add setup.py +sed -i "s/^\\(\\s*version\\s*=\\s*['\"]\\)[^'\"]*\\(['\"]\\s*\\)$/\\1v"$VER"\\2/" pyproject.toml +sed -i "s/^\\(\\s*__version__\\s*=\\s*['\"]\\)[^'\"]*\\(['\"]\\s*\\)$/\\1"$VER"\\2/" mpv.py +git add pyproject.toml mpv.py git commit -m "Version $VER" --no-edit -env QUBES_GPG_DOMAIN=gpg git -c gpg.program=qubes-gpg-client -c user.signingkey=E36F75307F0A0EC2D145FF5CED7A208EEEC76F2D -c user.email=python-mpv@jaseg.de tag -s "v$VER" -m "Version $VER" +git -c user.signingkey=E36F75307F0A0EC2D145FF5CED7A208EEEC76F2D -c user.email=python-mpv@jaseg.de tag -s "v$VER" -m "Version $VER" git push --tags origin diff --git a/mpv.py b/mpv.py index 2fb18dc..f9bf561 100644 --- a/mpv.py +++ b/mpv.py @@ -2,23 +2,29 @@ # vim: ts=4 sw=4 et # # Python MPV library module -# Copyright (C) 2017-2022 Sebastian Götte +# Copyright (C) 2017-2024 Sebastian Götte # -# This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public -# License as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later -# version. +# python-mpv inherits the underlying libmpv's license, which can be either GPLv2 or later (default) or LGPLv2.1 or +# later. For details, see the mpv copyright page here: https://github.com/mpv-player/mpv/blob/master/Copyright # -# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied -# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. +# You may copy, modify, and redistribute this file under the terms of the GNU General Public License version 2 (or, at +# your option, any later version), or the GNU Lesser General Public License as published by the Free Software +# Foundation; either version 2.1 of the License, or (at your option) any later version. # -# You should have received a copy of the GNU General Public License along with this program; if not, write to the Free -# Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# This library is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied +# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License and the GNU +# Lesser General Public License for more details. # +# You can find copies of the GPLv2 and LGPLv2.1 licenses in the project repository's LICENSE.GPL and LICENSE.LGPL files. + +__version__ = '1.0.8' from ctypes import * import ctypes.util import threading +import queue import os +import os.path import sys from warnings import warn from functools import partial, wraps @@ -30,14 +36,30 @@ import traceback if os.name == 'nt': # Note: mpv-2.dll with API version 2 corresponds to mpv v0.35.0. Most things should work with the fallback, too. - dll = ctypes.util.find_library('mpv-2.dll') or ctypes.util.find_library('mpv-1.dll') - if dll is None: - raise OSError('Cannot find mpv-1.dll or mpv-2.dll in your system %PATH%. One way to deal with this is to ship ' - 'the dll with your script and put the directory your script is in into %PATH% before ' - '"import mpv": os.environ["PATH"] = os.path.dirname(__file__) + os.pathsep + os.environ["PATH"] ' - 'If mpv-1.dll is located elsewhere, you can add that path to os.environ["PATH"].') - backend = CDLL(dll) + names = ['mpv-2.dll', 'libmpv-2.dll', 'mpv-1.dll'] + for name in names: + dll = ctypes.util.find_library(name) + if dll: + break + else: + for name in names: + dll = os.path.join(os.path.dirname(__file__), name) + if os.path.isfile(dll): + break + else: + raise OSError('Cannot find mpv-1.dll, mpv-2.dll or libmpv-2.dll in your system %PATH%. One way to deal with this is to ship the dll with your script and put the directory your script is in into %PATH% before "import mpv": os.environ["PATH"] = os.path.dirname(__file__) + os.pathsep + os.environ["PATH"] If mpv-1.dll is located elsewhere, you can add that path to os.environ["PATH"].') + + try: + # flags argument: LOAD_LIBRARY_SEARCH_DEFAULT_DIRS | LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR + # cf. https://learn.microsoft.com/en-us/windows/win32/api/libloaderapi/nf-libloaderapi-loadlibraryexa + backend = CDLL(dll, 0x00001000 | 0x00000100) + except Exception as e: + if not os.path.isabs(dll): # can only be find_library, not the "look next to mpv.py" thing + raise OSError(f'ctypes.find_library found mpv.dll at {dll}, but ctypes.CDLL could not load it. It looks like find_library found mpv.dll under a relative path entry in %PATH%. Please make sure all paths in %PATH% are absolute. Instead of trying to load mpv.dll from the current working directory, put it somewhere next to your script and add that path to %PATH% using os.environ["PATH"] = os.path.dirname(__file__) + os.pathsep + os.environ["PATH"]') from e + else: + raise OSError(f'ctypes.find_library found mpv.dll at {dll}, but ctypes.CDLL could not load it.') from e fs_enc = 'utf-8' + else: import locale lc, enc = locale.getlocale(locale.LC_NUMERIC) @@ -47,10 +69,7 @@ else: sofile = ctypes.util.find_library('mpv') if sofile is None: - raise OSError("Cannot find libmpv in the usual places. Depending on your distro, you may try installing an " - "mpv-devel or mpv-libs package. If you have libmpv around but this script can't find it, consult " - "the documentation for ctypes.util.find_library which this script uses to look up the library " - "filename.") + raise OSError("Cannot find libmpv in the usual places. Depending on your distro, you may try installing an mpv-devel or mpv-libs package. If you have libmpv around but this script can't find it, consult the documentation for ctypes.util.find_library which this script uses to look up the library filename.") backend = CDLL(sofile) fs_enc = sys.getfilesystemencoding() @@ -292,7 +311,7 @@ class MpvEventID(c_int): FILE_LOADED, CLIENT_MESSAGE, VIDEO_RECONFIG, AUDIO_RECONFIG, SEEK, PLAYBACK_RESTART, PROPERTY_CHANGE) def __repr__(self): - return f'' + return f'' @classmethod def from_str(kls, s): @@ -438,9 +457,14 @@ class MpvEventLogMessage(Structure): return lazy_decoder(self._text) class MpvEventEndFile(Structure): - _fields_ = [('reason', c_int), - ('error', c_int)] - + _fields_ = [ + ('reason', c_int), + ('error', c_int), + ('playlist_entry_id', c_ulonglong), + ('playlist_insert_id', c_ulonglong), + ('playlist_insert_num_entries', c_int), + ] + EOF = 0 RESTARTED = 1 ABORTED = 2 @@ -537,6 +561,11 @@ def _mpv_client_api_version(): ver = backend.mpv_client_api_version() return ver>>16, ver&0xFFFF +MPV_VERSION = _mpv_client_api_version() +if MPV_VERSION < (1, 108): + ver = '.'.join(str(num) for num in MPV_VERSION) + raise RuntimeError(f"python-mpv requires libmpv with an API version of 1.108 or higher (libmpv >= 0.33), but you have an older version ({ver}).") + backend.mpv_free.argtypes = [c_void_p] _mpv_free = backend.mpv_free @@ -873,6 +902,7 @@ class MPV(object): self.register_stream_protocol('python', self._python_stream_open) self._python_streams = {} self._python_stream_catchall = None + self._exception_futures = set() self.overlay_ids = set() self.overlays = {} if loglevel is not None or log_handler is not None: @@ -883,6 +913,22 @@ class MPV(object): self._event_thread.start() else: self._event_thread = None + if (m := re.search(r'(\d+)\.(\d+)\.(\d+)', self.mpv_version)): + self.mpv_version_tuple = tuple(map(int, m.groups())) + + @contextmanager + def _enqueue_exceptions(self): + try: + yield + except Exception as e: + for fut in self._exception_futures: + try: + fut.set_exception(e) + break + except InvalidStateError: + pass + else: + warn(f'Unhandled exception on python-mpv event loop: {e}\n{traceback.format_exc()}', RuntimeWarning) def _loop(self): for event in _event_generator(self._event_handle): @@ -894,45 +940,51 @@ class MPV(object): self._core_shutdown = True for callback in self._event_callbacks: - callback(event) + with self._enqueue_exceptions(): + callback(event) if eid == MpvEventID.PROPERTY_CHANGE: pc = event.data name, value, _fmt = pc.name, pc.value, pc.format for handler in self._property_handlers[name]: - handler(name, value) + with self._enqueue_exceptions(): + handler(name, value) if eid == MpvEventID.LOG_MESSAGE and self._log_handler is not None: ev = event.data - self._log_handler(ev.level, ev.prefix, ev.text) + with self._enqueue_exceptions(): + self._log_handler(ev.level, ev.prefix, ev.text) if eid == MpvEventID.CLIENT_MESSAGE: # {'event': {'args': ['key-binding', 'foo', 'u-', 'g']}, 'reply_userdata': 0, 'error': 0, 'event_id': 16} target, *args = event.data.args target = target.decode("utf-8") if target in self._message_handlers: - self._message_handlers[target](*args) + with self._enqueue_exceptions(): + self._message_handlers[target](*args) if eid == MpvEventID.COMMAND_REPLY: key = event.reply_userdata callback = self._command_reply_callbacks.pop(key, None) if callback: - callback(ErrorCode.exception_for_ec(event.error), event.data) + with self._enqueue_exceptions(): + callback(ErrorCode.exception_for_ec(event.error), event.data) if eid == MpvEventID.QUEUE_OVERFLOW: # cache list, since error handlers will unregister themselves for cb in list(self._command_reply_callbacks.values()): - cb(EventOverflowError('libmpv event queue has flown over because events have not been processed fast enough'), None) + with self._enqueue_exceptions(): + cb(EventOverflowError('libmpv event queue has flown over because events have not been processed fast enough'), None) if eid == MpvEventID.SHUTDOWN: _mpv_destroy(self._event_handle) for cb in list(self._command_reply_callbacks.values()): - cb(ShutdownError('libmpv core has been shutdown'), None) + with self._enqueue_exceptions(): + cb(ShutdownError('libmpv core has been shutdown'), None) return except Exception as e: - print('Exception inside python-mpv event loop:', file=sys.stderr) - traceback.print_exc() + warn(f'Unhandled {e} inside python-mpv event loop!\n{traceback.format_exc()}', RuntimeWarning) @property def core_shutdown(self): @@ -946,35 +998,35 @@ class MPV(object): if self._core_shutdown: raise ShutdownError('libmpv core has been shutdown') - def wait_until_paused(self, timeout=None): + def wait_until_paused(self, timeout=None, catch_errors=True): """Waits until playback of the current title is paused or done. Raises a ShutdownError if the core is shutdown while waiting.""" - self.wait_for_property('core-idle', timeout=timeout) + self.wait_for_property('core-idle', timeout=timeout, catch_errors=catch_errors) - def wait_for_playback(self, timeout=None): + def wait_for_playback(self, timeout=None, catch_errors=True): """Waits until playback of the current title is finished. Raises a ShutdownError if the core is shutdown while waiting. """ - self.wait_for_event('end_file', timeout=timeout) + self.wait_for_event('end_file', timeout=timeout, catch_errors=catch_errors) - def wait_until_playing(self, timeout=None): + def wait_until_playing(self, timeout=None, catch_errors=True): """Waits until playback of the current title has started. Raises a ShutdownError if the core is shutdown while waiting.""" - self.wait_for_property('core-idle', lambda idle: not idle, timeout=timeout) + self.wait_for_property('core-idle', lambda idle: not idle, timeout=timeout, catch_errors=catch_errors) - def wait_for_property(self, name, cond=lambda val: val, level_sensitive=True, timeout=None): + def wait_for_property(self, name, cond=lambda val: val, level_sensitive=True, timeout=None, catch_errors=True): """Waits until ``cond`` evaluates to a truthy value on the named property. This can be used to wait for properties such as ``idle_active`` indicating the player is done with regular playback and just idling around. Raises a ShutdownError when the core is shutdown while waiting. """ - with self.prepare_and_wait_for_property(name, cond, level_sensitive, timeout=timeout) as result: + with self.prepare_and_wait_for_property(name, cond, level_sensitive, timeout=timeout, catch_errors=catch_errors) as result: pass return result.result() - def wait_for_shutdown(self, timeout=None): + def wait_for_shutdown(self, timeout=None, catch_errors=True): '''Wait for core to shutdown (e.g. through quit() or terminate()).''' try: - self.wait_for_event(None, timeout=timeout) + self.wait_for_event(None, timeout=timeout, catch_errors=catch_errors) except ShutdownError: return @@ -992,7 +1044,7 @@ class MPV(object): return shutdown_handler.unregister_mpv_events @contextmanager - def prepare_and_wait_for_property(self, name, cond=lambda val: val, level_sensitive=True, timeout=None): + def prepare_and_wait_for_property(self, name, cond=lambda val: val, level_sensitive=True, timeout=None, catch_errors=True): """Context manager that waits until ``cond`` evaluates to a truthy value on the named property. See prepare_and_wait_for_event for usage. Raises a ShutdownError when the core is shutdown while waiting. Re-raises any errors inside ``cond``. @@ -1004,42 +1056,54 @@ class MPV(object): rv = cond(val) if rv: result.set_result(rv) + + except InvalidStateError: + pass + except Exception as e: try: result.set_exception(e) - except InvalidStateError: + except: pass - except InvalidStateError: - pass - self.observe_property(name, observer) - err_unregister = self._set_error_handler(result) try: result.set_running_or_notify_cancel() + + self.observe_property(name, observer) + err_unregister = self._set_error_handler(result) + if catch_errors: + self._exception_futures.add(result) + yield result - rv = cond(getattr(self, name.replace('-', '_'))) - if level_sensitive and rv: - result.set_result(rv) + if level_sensitive: + rv = cond(getattr(self, name.replace('-', '_'))) + if rv: + result.set_result(rv) + return + + self.check_core_alive() + result.result(timeout) + + except InvalidStateError: + pass - else: - self.check_core_alive() - result.result(timeout) finally: err_unregister() self.unobserve_property(name, observer) + self._exception_futures.discard(result) - def wait_for_event(self, *event_types, cond=lambda evt: True, timeout=None): + def wait_for_event(self, *event_types, cond=lambda evt: True, timeout=None, catch_errors=True): """Waits for the indicated event(s). If cond is given, waits until cond(event) is true. Raises a ShutdownError if the core is shutdown while waiting. This also happens when 'shutdown' is in event_types. Re-raises any error inside ``cond``. """ - with self.prepare_and_wait_for_event(*event_types, cond=cond, timeout=timeout) as result: + with self.prepare_and_wait_for_event(*event_types, cond=cond, timeout=timeout, catch_errors=catch_errors) as result: pass return result.result() @contextmanager - def prepare_and_wait_for_event(self, *event_types, cond=lambda evt: True, timeout=None): + def prepare_and_wait_for_event(self, *event_types, cond=lambda evt: True, timeout=None, catch_errors=True): """Context manager that waits for the indicated event(s) like wait_for_event after running. If cond is given, waits until cond(event) is true. Raises a ShutdownError if the core is shutdown while waiting. This also happens when 'shutdown' is in event_types. Re-raises any error inside ``cond``. @@ -1057,7 +1121,6 @@ class MPV(object): @self.event_callback(*event_types) def target_handler(evt): - try: rv = cond(evt) if rv: @@ -1074,13 +1137,18 @@ class MPV(object): try: result.set_running_or_notify_cancel() + if catch_errors: + self._exception_futures.add(result) + yield result + self.check_core_alive() result.result(timeout) finally: err_unregister() target_handler.unregister_mpv_events() + self._exception_futures.discard(result) def __del__(self): if self.handle: @@ -1286,9 +1354,16 @@ class MPV(object): def _encode_options(options): return ','.join('{}={}'.format(_py_to_mpv(str(key)), str(val)) for key, val in options.items()) - def loadfile(self, filename, mode='replace', **options): + def loadfile(self, filename, mode='replace', index=None, **options): """Mapped mpv loadfile command, see man mpv(1).""" - self.command('loadfile', filename.encode(fs_enc), mode, MPV._encode_options(options)) + if self.mpv_version_tuple >= (0, 38, 0): + if index is None: + index = -1 + self.command('loadfile', filename.encode(fs_enc), mode, index, MPV._encode_options(options)) + else: + if index is not None: + warn(f'The index argument to the loadfile command is only supported on mpv >= 0.38.0') + self.command('loadfile', filename.encode(fs_enc), mode, MPV._encode_options(options)) def loadlist(self, playlist, mode='replace'): """Mapped mpv loadlist command, see man mpv(1).""" @@ -1320,11 +1395,17 @@ class MPV(object): def quit(self, code=None): """Mapped mpv quit command, see man mpv(1).""" - self.command('quit', code) + if code is not None: + self.command('quit', code) + else: + self.command('quit') def quit_watch_later(self, code=None): """Mapped mpv quit_watch_later command, see man mpv(1).""" - self.command('quit_watch_later', code) + if code is not None: + self.command('quit_watch_later', code) + else: + self.command('quit_watch_later') def stop(self, keep_playlist=False): """Mapped mpv stop command, see man mpv(1).""" @@ -1385,7 +1466,7 @@ class MPV(object): """Mapped mpv print-text command, see man mpv(1).""" self.command('print-text', text) - def show_text(self, string, duration='-1', level=None): + def show_text(self, string, duration='-1', level=0): """Mapped mpv show_text command, see man mpv(1).""" self.command('show_text', string, duration, level) @@ -1409,7 +1490,7 @@ class MPV(object): """Mapped mpv discnav command, see man mpv(1).""" self.command('discnav', command) - def mouse(x, y, button=None, mode='single'): + def mouse(self, x, y, button=None, mode='single'): """Mapped mpv mouse command, see man mpv(1).""" if button is None: self.command('mouse', x, y, mode) @@ -1448,7 +1529,7 @@ class MPV(object): self.command('overlay_remove', overlay_id) def osd_overlay(self, overlay_id, data, res_x=0, res_y=720, z=0, hidden=False): - self.command('osd_overlay', id=overlay_id, data=data, res_x=res_x, res_y=res_Y, z=z, hidden=hidden, + self.command('osd_overlay', id=overlay_id, data=data, res_x=res_x, res_y=res_y, z=z, hidden=hidden, format='ass-events') def osd_overlay_remove(self, overlay_id): @@ -1478,13 +1559,13 @@ class MPV(object): function decorator if no handler is given. To unregister the observer, call either of ``mpv.unobserve_property(name, handler)``, - ``mpv.unobserve_all_properties(handler)`` or the handler's ``unregister_mpv_properties`` attribute:: + ``mpv.unobserve_all_properties(handler)`` or the handler's ``unobserve_mpv_properties`` attribute:: - @player.observe_property('volume') - def my_handler(new_volume, *): - print("It's loud!", volume) + @player.property_observer('volume') + def my_handler(property_name, new_volume): + print("It's loud!", new_volume) - my_handler.unregister_mpv_properties() + my_handler.unobserve_mpv_properties() exit_handler is a function taking no arguments that is called when the underlying mpv handle is terminated (e.g. from calling MPV.terminate() or issuing a "quit" input command). @@ -1617,9 +1698,10 @@ class MPV(object): def _binding_name(callback_or_cmd): return 'py_kb_{:016x}'.format(hash(callback_or_cmd)&0xffffffffffffffff) - def on_key_press(self, keydef, mode='force'): + def on_key_press(self, keydef, mode='force', repetition=False): """Function decorator to register a simplified key binding. The callback is called whenever the key given is - *pressed*. + *pressed*. When the ``repetition=True`` is passed, the callback is called again repeatedly while the key is held + down. To unregister the callback function, you can call its ``unregister_mpv_key_bindings`` attribute:: @@ -1639,8 +1721,8 @@ class MPV(object): def register(fun): @self.key_binding(keydef, mode) @wraps(fun) - def wrapper(state='p-', name=None, char=None): - if state[0] in ('d', 'p'): + def wrapper(state='p-', name=None, char=None, *_): + if state[0] in ('d', 'p') or (repetition and state[0] == 'r'): fun() return wrapper return register @@ -1648,8 +1730,11 @@ class MPV(object): def key_binding(self, keydef, mode='force'): """Function decorator to register a low-level key binding. - The callback function signature is ``fun(key_state, key_name)`` where ``key_state`` is either ``'U'`` for "key - up" or ``'D'`` for "key down". + The callback function signature is ``fun(key_state, key_name, key_char, scale, arg)``. + + The key_state contains up to three chars, corresponding to the regex ``[udr]([m-][c-]?)?``. ``[udr]`` means + "key up", "key down", or "repetition" for when the key is held down. "m" indicates mouse events, and "c" + indicates key up events resulting from a logical cancellation. For details check out the mpv man page. The keydef format is: ``[Shift+][Ctrl+][Alt+][Meta+]`` where ```` is either the literal character the key produces (ASCII or Unicode character), or a symbolic name (as printed by ``mpv --input-keylist``). @@ -1704,12 +1789,12 @@ class MPV(object): raise TypeError('register_key_binding expects either an str with an mpv command or a python callable.') self.command('enable-section', binding_name, 'allow-hide-cursor+allow-vo-dragging') - def _handle_key_binding_message(self, binding_name, key_state, key_name=None, key_char=None): + def _handle_key_binding_message(self, binding_name, key_state, key_name=None, key_char=None, scale=None, arg=None, *_): binding_name = binding_name.decode('utf-8') key_state = key_state.decode('utf-8') key_name = key_name.decode('utf-8') if key_name is not None else None key_char = key_char.decode('utf-8') if key_char is not None else None - self._key_binding_handlers[binding_name](key_state, key_name, key_char) + self._key_binding_handlers[binding_name](key_state, key_name, key_char, scale, arg) def unregister_key_binding(self, keydef): """Unregister a key binding by keydef.""" @@ -1765,32 +1850,65 @@ class MPV(object): frontend = open_fn(uri.decode('utf-8')) except ValueError: return ErrorCode.LOADING_FAILED - - def read_backend(_userdata, buf, bufsize): - data = frontend.read(bufsize) - for i in range(len(data)): - buf[i] = data[i] - return len(data) + except Exception as e: + for fut in self._exception_futures: + try: + fut.set_exception(e) + break + except InvalidStateError: + pass + else: + warnings.warn(f'Unhandled exception {e} inside stream open callback for URI {uri}\n{traceback.format_exc()}') + return ErrorCode.LOADING_FAILED cb_info.contents.cookie = None + + def read_backend(_userdata, buf, bufsize): + with self._enqueue_exceptions(): + data = frontend.read(bufsize) + for i in range(len(data)): + buf[i] = data[i] + return len(data) + return -1 read = cb_info.contents.read = StreamReadFn(read_backend) - close = cb_info.contents.close = StreamCloseFn(lambda _userdata: frontend.close()) + + def close_backend(_userdata): + with self._enqueue_exceptions(): + del self._stream_protocol_frontends[proto][uri] + if hasattr(frontend, 'close'): + frontend.close() + close = cb_info.contents.close = StreamCloseFn(close_backend) seek, size, cancel = None, None, None - if hasattr(frontend, 'seek'): - seek = cb_info.contents.seek = StreamSeekFn(lambda _userdata, offx: frontend.seek(offx)) - if hasattr(frontend, 'size') and frontend.size is not None: - size = cb_info.contents.size = StreamSizeFn(lambda _userdata: frontend.size) - if hasattr(frontend, 'cancel'): - cancel = cb_info.contents.cancel = StreamCancelFn(lambda _userdata: frontend.cancel()) - # keep frontend and callbacks in memory forever (TODO) + if hasattr(frontend, 'seek'): + def seek_backend(_userdata, offx): + with self._enqueue_exceptions(): + return frontend.seek(offx) + return ErrorCode.GENERIC + seek = cb_info.contents.seek = StreamSeekFn(seek_backend) + + if hasattr(frontend, 'size') and frontend.size is not None: + def size_backend(_userdata): + with self._enqueue_exceptions(): + return frontend.size + return 0 + size = cb_info.contents.size = StreamSizeFn(size_backend) + + if hasattr(frontend, 'cancel'): + def cancel_backend(_userdata): + with self._enqueue_exceptions(): + frontend.cancel() + cancel = cb_info.contents.cancel = StreamCancelFn(cancel_backend) + + # keep frontend and callbacks in memory until closed frontend._registered_callbacks = [read, close, seek, size, cancel] self._stream_protocol_frontends[proto][uri] = frontend return 0 if proto in self._stream_protocol_cbs: raise KeyError('Stream protocol already registered') + # keep backend in memory forever self._stream_protocol_cbs[proto] = [open_backend] _mpv_stream_cb_add_ro(self.handle, proto.encode('utf-8'), c_void_p(), open_backend) @@ -1842,6 +1960,10 @@ class MPV(object): Any given name can only be registered once. The catch-all can also only be registered once. To unregister a stream, call the .unregister function set on the callback. + If name is None (the default), a name and corresponding python:// URI are automatically generated. You can + access the name through the .stream_name property set on the callback, and the stream URI for passing into + mpv.play(...) through the .stream_uri property. + The generator signals EOF by returning, manually raising StopIteration or by yielding b'', an empty bytes object. @@ -1858,18 +1980,72 @@ class MPV(object): reader.unregister() """ def register(cb): + nonlocal name + if name is None: + name = f'__python_mpv_anonymous_python_stream_{id(cb)}__' + if name in self._python_streams: raise KeyError('Python stream name "{}" is already registered'.format(name)) + self._python_streams[name] = (cb, size) def unregister(): if name not in self._python_streams or\ self._python_streams[name][0] is not cb: # This is just a basic sanity check raise RuntimeError('Python stream has already been unregistered') del self._python_streams[name] + cb.unregister = unregister + cb.stream_name = name + cb.stream_uri = f'python://{name}' return cb + return register + @contextmanager + def play_context(self): + """ Context manager for streaming bytes straight into libmpv. + + This is a convenience wrapper around python_stream. play_context returns a write method, which you can use in + the body of the context manager to feed libmpv bytes. All bytes you feed in with write() in the body of a single + call of this context manager are treated as one single file. A queue is used internally, so this function is + thread-safe. The queue is unlimited, so it cannot block and is safe to call from async code. You can use this + function to stream chunked data, e.g. from the network. + + Use it like this: + + with m.play_context() as write: + with open(TESTVID, 'rb') as f: + while (chunk := f.read(65536)): # Get some chunks of bytes + write(chunk) + """ + q = queue.Queue() + + EOF = object() # Get some unique object as EOF marker + @self.python_stream() + def reader(): + while (chunk := q.get()) is not EOF: + if chunk: + yield chunk + reader.unregister() + + def write(chunk): + q.put(chunk) + + # Start playback before yielding, the first call to reader() will block until write is called at least once. + self.play(reader.stream_uri) + yield write + q.put(EOF) + + def play_bytes(self, data): + """ Play the given bytes object as a single file. """ + + @self.python_stream() + def reader(): + yield data + reader.unregister() # unregister itself + + self.play(reader.stream_uri) + def python_stream_catchall(self, cb): """ Register a catch-all python stream to be called when no name matches can be found. Use this decorator on a function that takes a name argument and returns a (generator, size) tuple (with size being None if unknown). @@ -1927,7 +2103,10 @@ class MPV(object): def _set_property(self, name, value): self.check_core_alive() ename = name.encode('utf-8') - if isinstance(value, (list, set, dict)): + if isinstance(value, dict): + _1, _2, _3, pointer = _make_node_str_map(value) + _mpv_set_property(self.handle, ename, MpvFormat.NODE, pointer) + elif isinstance(value, (list, set)): _1, _2, _3, pointer = _make_node_str_list(value) _mpv_set_property(self.handle, ename, MpvFormat.NODE, pointer) else: diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..f68661e --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,39 @@ +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[tool.setuptools] +py-modules = ['mpv'] + +[project] +name = "mpv" +version = "v1.0.8" +description = "A python interface to the mpv media player" +readme = "README.rst" +authors = [{name = "jaseg", email = "mpv@jaseg.de"}] +license = {text = "GPLv2+ or LGPLv2.1+"} +requires-python = ">=3.9" +keywords = ['mpv', 'library', 'video', 'audio', 'player', 'display', 'multimedia'] +classifiers = [ + 'Development Status :: 5 - Production/Stable', + 'Environment :: X11 Applications', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: GNU General Public License v2 or later (GPLv2+)', + 'License :: OSI Approved :: GNU Lesser General Public License v2 or later (LGPLv2+)', + 'Natural Language :: English', + 'Operating System :: POSIX', + 'Programming Language :: C', + 'Programming Language :: Python :: 3.9', + 'Programming Language :: Python :: 3.10', + 'Programming Language :: Python :: 3.11', + 'Programming Language :: Python :: 3.12', + 'Topic :: Multimedia :: Sound/Audio :: Players', + 'Topic :: Multimedia :: Video :: Display' +] + +[project.urls] +homepage = "https://github.com/jaseg/python-mpv" + +[project.optional-dependencies] +screenshot_raw = ["Pillow"] +test = ['PyVirtualDisplay'] diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 5aef279..0000000 --- a/setup.cfg +++ /dev/null @@ -1,2 +0,0 @@ -[metadata] -description-file = README.rst diff --git a/setup.py b/setup.py deleted file mode 100755 index 0b1c6a0..0000000 --- a/setup.py +++ /dev/null @@ -1,40 +0,0 @@ -#!/usr/bin/env python3 - -from setuptools import setup -from pathlib import Path - -setup( - name = 'python-mpv', - version = '1.0.1', - py_modules = ['mpv'], - description = 'A python interface to the mpv media player', - long_description = (Path(__file__).parent / 'README.rst').read_text(), - long_description_content_type = 'text/x-rst', - url = 'https://github.com/jaseg/python-mpv', - author = 'jaseg', - author_email = 'github@jaseg.net', - license = 'GPLv3+', - extras_require = { - 'screenshot_raw': ['Pillow'] - }, - tests_require = ['xvfbwrapper'], - test_suite = 'tests', - keywords = ['mpv', 'library', 'video', 'audio', 'player', 'display', - 'multimedia'], - python_requires='>=3.7', - classifiers = [ - 'Development Status :: 5 - Production/Stable', - 'Environment :: X11 Applications', - 'Intended Audience :: Developers', - 'License :: OSI Approved :: GNU General Public License v2 or later (GPLv2+)', - 'License :: OSI Approved :: GNU Lesser General Public License v2 or later (LGPLv2+)', - 'Natural Language :: English', - 'Operating System :: POSIX', - 'Programming Language :: C', - 'Programming Language :: Python :: 3.7', - 'Programming Language :: Python :: 3.8', - 'Programming Language :: Python :: 3.9', - 'Programming Language :: Python :: 3.10', - 'Topic :: Multimedia :: Sound/Audio :: Players', - 'Topic :: Multimedia :: Video :: Display'] -) diff --git a/tests/requirements.txt b/tests/requirements.txt new file mode 100644 index 0000000..eabe145 --- /dev/null +++ b/tests/requirements.txt @@ -0,0 +1 @@ +PyVirtualDisplay>=3.0 diff --git a/tests/test_mpv.py b/tests/test_mpv.py index 3fecfbd..9c42332 100755 --- a/tests/test_mpv.py +++ b/tests/test_mpv.py @@ -18,19 +18,12 @@ import unittest from unittest import mock -import math import threading from contextlib import contextmanager -from functools import wraps -import gc import os.path import os -import sys import time -import io -import platform -import ctypes -from concurrent.futures import Future +from concurrent.futures import Future, InvalidStateError os.environ["PATH"] = os.path.dirname(__file__) + os.pathsep + os.environ["PATH"] @@ -38,27 +31,31 @@ import mpv if os.name == 'nt': - Xvfb = mock.Mock() + Display = mock.Mock() testvo='gpu' else: - from xvfbwrapper import Xvfb + from pyvirtualdisplay import Display testvo='x11' TESTVID = os.path.join(os.path.dirname(__file__), 'test.webm') TESTSRT = os.path.join(os.path.dirname(__file__), 'sub_test.srt') MPV_ERRORS = [ l(ec) for ec, l in mpv.ErrorCode.EXCEPTION_DICT.items() if l ] +SKIP_TESTS = os.environ.get('PY_MPV_SKIP_TESTS', '').split() + def timed_print(): start_time = time.time() def do_print(level, prefix, text): td = time.time() - start_time - print('{:.3f} [{}] {}: {}'.format(td, level, prefix, text), flush=True) + print('{:.3f} [{}] {}: {}'.format(td, level, prefix, text.strip()), flush=True) + return do_print + class MpvTestCase(unittest.TestCase): def setUp(self): - self.disp = Xvfb() + self.disp = Display() self.disp.start() self.m = mpv.MPV(vo=testvo, loglevel='debug', log_handler=timed_print()) @@ -66,6 +63,7 @@ class MpvTestCase(unittest.TestCase): self.m.terminate() self.disp.stop() + class TestProperties(MpvTestCase): @contextmanager def swallow_mpv_errors(self, exception_exceptions=[]): @@ -213,6 +211,11 @@ class TestProperties(MpvTestCase): # See comment in test_property_decoding_invalid_utf8 self.m.osd.alang + def test_dict_valued_property(self): + nasty_stuff = '\xe2\x80\x8e Mozilla/5.0 Foobar \xe2\x80\x8e \xe2\x80\x81' + self.m.ytdl_raw_options = {'user-agent': nasty_stuff} + self.assertEqual(self.m.ytdl_raw_options, {'user-agent': nasty_stuff}) + def test_option_read(self): self.m.loop = 'inf' self.m.play(TESTVID) @@ -295,6 +298,7 @@ class ObservePropertyTest(MpvTestCase): mock.call('slang', ['ru'])], any_order=True) + class KeyBindingTest(MpvTestCase): def test_register_direct_cmd(self): self.m.register_key_binding('a', 'playlist-clear') @@ -441,11 +445,12 @@ class KeyBindingTest(MpvTestCase): handler1.assert_has_calls([]) handler2.assert_has_calls([ mock.call() ]) + class TestStreams(unittest.TestCase): def test_python_stream(self): handler = mock.Mock() - disp = Xvfb() + disp = Display() disp.start() m = mpv.MPV(vo=testvo) def cb(evt): @@ -503,7 +508,7 @@ class TestStreams(unittest.TestCase): stream_mock.seek = mock.Mock(return_value=0) stream_mock.read = mock.Mock(return_value=b'') - disp = Xvfb() + disp = Display() disp.start() m = mpv.MPV(vo=testvo, video=False) def cb(evt): @@ -536,6 +541,154 @@ class TestStreams(unittest.TestCase): m.terminate() disp.stop() + def test_stream_open_exception(self): + disp = Display() + disp.start() + m = mpv.MPV(vo=testvo, video=False) + + @m.register_stream_protocol('raiseerror') + def open_fn(uri): + raise SystemError() + + waiting = threading.Semaphore() + result = Future() + def run(): + result.set_running_or_notify_cancel() + try: + waiting.release() + m.wait_for_playback() + result.set_result(False) + except SystemError: + result.set_result(True) + except Exception: + result.set_result(False) + + t = threading.Thread(target=run, daemon=True) + t.start() + + with waiting: + time.sleep(0.2) + m.play('raiseerror://foo') + + m.wait_for_playback(catch_errors=False) + try: + assert result.result() + finally: + m.terminate() + disp.stop() + + def test_python_stream_exception(self): + disp = Display() + disp.start() + m = mpv.MPV(vo=testvo) + + @m.python_stream('foo') + def foo_gen(): + with open(TESTVID, 'rb') as f: + yield f.read(100) + raise SystemError() + + waiting = threading.Semaphore() + result = Future() + def run(): + result.set_running_or_notify_cancel() + try: + waiting.release() + m.wait_for_playback() + result.set_result(False) + except SystemError: + result.set_result(True) + except Exception: + result.set_result(False) + + t = threading.Thread(target=run, daemon=True) + t.start() + + with waiting: + time.sleep(0.2) + m.play('python://foo') + + m.wait_for_playback(catch_errors=False) + try: + assert result.result() + finally: + m.terminate() + disp.stop() + + def test_stream_open_forward(self): + disp = Display() + disp.start() + m = mpv.MPV(vo=testvo, video=False) + + @m.register_stream_protocol('raiseerror') + def open_fn(uri): + raise ValueError() + + waiting = threading.Semaphore() + result = Future() + def run(): + result.set_running_or_notify_cancel() + try: + waiting.release() + m.wait_for_playback() + result.set_result(True) + except Exception: + result.set_result(False) + + t = threading.Thread(target=run, daemon=True) + t.start() + + with waiting: + time.sleep(0.2) + m.play('raiseerror://foo') + + m.wait_for_playback(catch_errors=False) + try: + assert result.result() + finally: + m.terminate() + disp.stop() + + def test_play_context(self): + handler = mock.Mock() + + disp = Display() + disp.start() + m = mpv.MPV(vo=testvo) + def cb(evt): + handler(evt.as_dict(decoder=mpv.lazy_decoder)) + m.register_event_callback(cb) + + with m.play_context() as write: + with open(TESTVID, 'rb') as f: + write(f.read(100)) + write(f.read(1000)) + write(f.read()) + + m.wait_for_playback() + handler.assert_any_call({'event': 'end-file', 'reason': 'eof', 'playlist_entry_id': 1}) + m.terminate() + disp.stop() + + def test_play_bytes(self): + handler = mock.Mock() + + disp = Display() + disp.start() + m = mpv.MPV(vo=testvo) + def cb(evt): + handler(evt.as_dict(decoder=mpv.lazy_decoder)) + m.register_event_callback(cb) + + with open(TESTVID, 'rb') as f: + m.play_bytes(f.read()) + + m.wait_for_playback() + handler.assert_any_call({'event': 'end-file', 'reason': 'eof', 'playlist_entry_id': 1}) + m.terminate() + disp.stop() + + class TestLifecycle(unittest.TestCase): def test_create_destroy(self): thread_names = lambda: [ t.name for t in threading.enumerate() ] @@ -577,13 +730,13 @@ class TestLifecycle(unittest.TestCase): mock.call({'event': 'start-file', 'playlist_entry_id': 1}), mock.call({'event': 'end-file', 'reason': 'error', 'playlist_entry_id': 1, 'file_error': 'no audio or video data played'}) ], any_order=True) + time.sleep(1) handler.reset_mock() - m.terminate() handler.assert_not_called() def test_wait_for_property_negative(self): - self.disp = Xvfb() + self.disp = Display() self.disp.start() m = mpv.MPV(vo=testvo) m.play(TESTVID) @@ -600,12 +753,13 @@ class TestLifecycle(unittest.TestCase): t.start() time.sleep(1) m.terminate() + time.sleep(1) t.join() self.disp.stop() assert result.result() def test_wait_for_property_positive(self): - self.disp = Xvfb() + self.disp = Display() self.disp.start() handler = mock.Mock() m = mpv.MPV(vo=testvo) @@ -620,13 +774,13 @@ class TestLifecycle(unittest.TestCase): m.mute = True t.join() m.terminate() + time.sleep(1) handler.assert_called() self.disp.stop() def test_wait_for_event(self): - self.disp = Xvfb() + self.disp = Display() self.disp.start() - handler = mock.Mock() m = mpv.MPV(vo=testvo) m.play(TESTVID) result = Future() @@ -647,9 +801,8 @@ class TestLifecycle(unittest.TestCase): assert result.result() def test_wait_for_property_shutdown(self): - self.disp = Xvfb() + self.disp = Display() self.disp.start() - handler = mock.Mock() m = mpv.MPV(vo=testvo) m.play(TESTVID) with self.assertRaises(mpv.ShutdownError): @@ -657,12 +810,13 @@ class TestLifecycle(unittest.TestCase): # handle with m.prepare_and_wait_for_property('mute', level_sensitive=False): m.terminate() + time.sleep(1) self.disp.stop() - def test_wait_for_prooperty_event_overflow(self): - self.disp = Xvfb() + @unittest.skipIf('test_wait_for_property_event_overflow' in SKIP_TESTS, reason="kills X-Server first") + def test_wait_for_property_event_overflow(self): + self.disp = Display() self.disp.start() - handler = mock.Mock() m = mpv.MPV(vo=testvo) m.play(TESTVID) with self.assertRaises(mpv.EventOverflowError): @@ -677,12 +831,12 @@ class TestLifecycle(unittest.TestCase): m.command_async('script-message', 'foo', 'bar') except: pass + m.terminate() + time.sleep(1) self.disp.stop() - - def test_wait_for_event_shutdown(self): - self.disp = Xvfb() + self.disp = Display() self.disp.start() m = mpv.MPV(vo=testvo) m.play(TESTVID) @@ -692,9 +846,8 @@ class TestLifecycle(unittest.TestCase): self.disp.stop() def test_wait_for_shutdown(self): - self.disp = Xvfb() + self.disp = Display() self.disp.start() - handler = mock.Mock() m = mpv.MPV(vo=testvo) m.play(TESTVID) with self.assertRaises(mpv.ShutdownError): @@ -705,7 +858,7 @@ class TestLifecycle(unittest.TestCase): def test_log_handler(self): handler = mock.Mock() - self.disp = Xvfb() + self.disp = Display() self.disp.start() m = mpv.MPV(vo=testvo, log_handler=handler) m.play(TESTVID) @@ -766,9 +919,26 @@ class CommandTests(MpvTestCase): callback.assert_any_call(None, None) - class RegressionTests(MpvTestCase): + def test_wait_for_property_concurrency(self): + players = [mpv.MPV(vo=testvo, loglevel='debug', log_handler=timed_print()) for i in range(2)] + try: + for _ in range(150): + for player in players: + player.loadfile('tests/test.webm', loop='inf') + for player in players: + player.wait_for_property('seekable') + for player in players: + player.seek(0, reference='absolute', precision='exact') + + except InvalidStateError: + self.fail('InvalidStateError thrown from wait_for_property') + + finally: + for player in players: + player.terminate() + def test_unobserve_property_runtime_error(self): """ Ensure a `RuntimeError` is not thrown within @@ -821,6 +991,3 @@ class RegressionTests(MpvTestCase): m.terminate() # needed for synchronization of event thread handler.assert_has_calls([mock.call('slang', ['jp']), mock.call('slang', ['ru'])]) - -if __name__ == '__main__': - unittest.main()