diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 52eb072..8a0d066 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -4,8 +4,6 @@ name: 'Tests' on: push: branches: [ '**' ] - pull_request: - branches: [ '**' ] defaults: @@ -15,20 +13,19 @@ defaults: jobs: test-linux: - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 name: 'Linux - Python' strategy: matrix: - python-version: [ '3.8', '3.9', '3.10', '3.11', '3.12' ] - fail-fast: false + python-version: [ '3.8', '3.9', '3.10' ] env: DISPLAY: :0 PY_MPV_SKIP_TESTS: >- test_wait_for_property_event_overflow steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v3 - name: 'Install Python' - uses: actions/setup-python@v5 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - name: 'Update Packages' @@ -60,35 +57,30 @@ jobs: function execute() { echo -e "\033[0;34m$*\033[0m"; "$@"; } execute source venv/bin/activate - execute xvfb-run python -m unittest + execute xvfb-run python -m pytest 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 + python-version: [ '3.8', '3.9', '3.10' ] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v3 - name: 'Install Python' - uses: actions/setup-python@v5 + uses: actions/setup-python@v4 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" + ARTIFACT="mpv-dev-x86_64-20220619-git-c1a46ec.7z" 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 + execute mv mpv-2.dll tests - name: 'Setup Test Environment' run: | function execute() { echo -e "\033[0;34m$*\033[0m"; "$@"; } @@ -103,4 +95,4 @@ jobs: function execute() { echo -e "\033[0;34m$*\033[0m"; "$@"; } execute source venv/Scripts/activate - execute python -m unittest + execute python -m pytest diff --git a/README.rst b/README.rst index db57f22..3876dd0 100644 --- a/README.rst +++ b/README.rst @@ -29,14 +29,15 @@ 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. You can modify `%PATH%` before importing python-mpv -to modify where python-mpv looks for the DLL. Consult `this stackoverflow post `__ -for details. +into ctypes, which is different to the one Windows uses internally. Consult `this stackoverflow post +`__ for details. -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. +Python >= 3.7 (officially) +.......................... +The ``main`` branch officially only supports recent python releases (3.5 onwards), but there is the somewhat outdated +but functional `py2compat branch`_ providing Python 2 compatibility. +.. _`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 diff --git a/mpv.py b/mpv.py index f9bf561..b2f6868 100644 --- a/mpv.py +++ b/mpv.py @@ -2,7 +2,7 @@ # vim: ts=4 sw=4 et # # Python MPV library module -# Copyright (C) 2017-2024 Sebastian Götte +# Copyright (C) 2017-2022 Sebastian Götte # # 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 @@ -17,14 +17,13 @@ # # 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' +__version__ = '1.0.5' 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 @@ -36,30 +35,11 @@ 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. - 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 + dll = ctypes.util.find_library('mpv-2.dll') or ctypes.util.find_library('libmpv-2.dll') or ctypes.util.find_library('mpv-1.dll') + if dll is None: + 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"].') + backend = CDLL(dll) fs_enc = 'utf-8' - else: import locale lc, enc = locale.getlocale(locale.LC_NUMERIC) @@ -913,8 +893,6 @@ 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): @@ -1056,38 +1034,30 @@ 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: + except InvalidStateError: 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 - 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 + rv = cond(getattr(self, name.replace('-', '_'))) + if level_sensitive and rv: + result.set_result(rv) + else: + self.check_core_alive() + result.result(timeout) finally: err_unregister() self.unobserve_property(name, observer) @@ -1354,16 +1324,9 @@ 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', index=None, **options): + def loadfile(self, filename, mode='replace', **options): """Mapped mpv loadfile command, see man mpv(1).""" - 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)) + 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).""" @@ -1395,17 +1358,11 @@ class MPV(object): def quit(self, code=None): """Mapped mpv quit command, see man mpv(1).""" - if code is not None: - self.command('quit', code) - else: - self.command('quit') + self.command('quit', code) def quit_watch_later(self, code=None): """Mapped mpv quit_watch_later command, see man mpv(1).""" - if code is not None: - self.command('quit_watch_later', code) - else: - self.command('quit_watch_later') + self.command('quit_watch_later', code) def stop(self, keep_playlist=False): """Mapped mpv stop command, see man mpv(1).""" @@ -1529,7 +1486,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): @@ -1698,10 +1655,9 @@ 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', repetition=False): + def on_key_press(self, keydef, mode='force'): """Function decorator to register a simplified key binding. The callback is called whenever the key given is - *pressed*. When the ``repetition=True`` is passed, the callback is called again repeatedly while the key is held - down. + *pressed*. To unregister the callback function, you can call its ``unregister_mpv_key_bindings`` attribute:: @@ -1721,8 +1677,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') or (repetition and state[0] == 'r'): + def wrapper(state='p-', name=None, char=None): + if state[0] in ('d', 'p'): fun() return wrapper return register @@ -1730,11 +1686,8 @@ 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, 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 callback function signature is ``fun(key_state, key_name)`` where ``key_state`` is either ``'U'`` for "key + up" or ``'D'`` for "key down". 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``). @@ -1789,12 +1742,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, scale=None, arg=None, *_): + def _handle_key_binding_message(self, binding_name, key_state, key_name=None, key_char=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, scale, arg) + self._key_binding_handlers[binding_name](key_state, key_name, key_char) def unregister_key_binding(self, keydef): """Unregister a key binding by keydef.""" @@ -1859,6 +1812,9 @@ class MPV(object): 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 @@ -1960,10 +1916,6 @@ 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. @@ -1980,72 +1932,18 @@ 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). @@ -2103,10 +2001,7 @@ class MPV(object): def _set_property(self, name, value): self.check_core_alive() ename = name.encode('utf-8') - 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)): + if isinstance(value, (list, set, dict)): _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 index f68661e..49de9d8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,12 +7,12 @@ py-modules = ['mpv'] [project] name = "mpv" -version = "v1.0.8" +version = "v1.0.5" 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" +requires-python = ">=3.7" keywords = ['mpv', 'library', 'video', 'audio', 'player', 'display', 'multimedia'] classifiers = [ 'Development Status :: 5 - Production/Stable', @@ -23,10 +23,10 @@ classifiers = [ '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', - 'Programming Language :: Python :: 3.11', - 'Programming Language :: Python :: 3.12', 'Topic :: Multimedia :: Sound/Audio :: Players', 'Topic :: Multimedia :: Video :: Display' ] diff --git a/tests/requirements.txt b/tests/requirements.txt index eabe145..ded363c 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -1 +1,2 @@ PyVirtualDisplay>=3.0 +pytest>=7.1.2 diff --git a/tests/test_mpv.py b/tests/test_mpv.py index 9c42332..74999ad 100755 --- a/tests/test_mpv.py +++ b/tests/test_mpv.py @@ -23,7 +23,7 @@ from contextlib import contextmanager import os.path import os import time -from concurrent.futures import Future, InvalidStateError +from concurrent.futures import Future os.environ["PATH"] = os.path.dirname(__file__) + os.pathsep + os.environ["PATH"] @@ -49,8 +49,7 @@ 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.strip()), flush=True) - return do_print + print('{:.3f} [{}] {}: {}'.format(td, level, prefix, text), flush=True) class MpvTestCase(unittest.TestCase): @@ -211,11 +210,6 @@ 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) @@ -649,44 +643,6 @@ class TestStreams(unittest.TestCase): 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): @@ -921,24 +877,6 @@ class CommandTests(MpvTestCase): 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 @@ -990,4 +928,3 @@ class RegressionTests(MpvTestCase): m.slang = 'ru' m.terminate() # needed for synchronization of event thread handler.assert_has_calls([mock.call('slang', ['jp']), mock.call('slang', ['ru'])]) -