From fa3b0fdad1f4ed4367f0085a301186fa6f9be51c Mon Sep 17 00:00:00 2001 From: jaseg Date: Sat, 22 Jul 2023 16:03:55 +0200 Subject: [PATCH 01/25] Version 1.0.4 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index a72cd8d..03c8c4f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ py-modules = ['mpv'] [project] name = "mpv" -version = "v1.0.3" +version = "v1.0.4" description = "A python interface to the mpv media player" readme = "README.rst" authors = [{name = "jaseg", email = "mpv@jaseg.de"}] From 0bfd64d1f1923ca1039ca34f3ff9f8830bc47102 Mon Sep 17 00:00:00 2001 From: naglis <827324+naglis@users.noreply.github.com> Date: Tue, 22 Aug 2023 16:09:02 +0300 Subject: [PATCH 02/25] Fix typo in README --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index fdf220e..3876dd0 100644 --- a/README.rst +++ b/README.rst @@ -70,7 +70,7 @@ 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 From 93cf60e109b592fcca07f98864d05e5503c59d05 Mon Sep 17 00:00:00 2001 From: jaseg Date: Sat, 18 Nov 2023 11:44:55 +0100 Subject: [PATCH 03/25] Add libmpv-2.dll to windows library search list --- mpv.py | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/mpv.py b/mpv.py index 27fed4f..ac024b7 100644 --- a/mpv.py +++ b/mpv.py @@ -35,12 +35,9 @@ 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') + 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 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"].') + 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: @@ -52,10 +49,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() From b42f79a4f1be505d95be7fd13333ff96fa624aa4 Mon Sep 17 00:00:00 2001 From: jaseg Date: Sat, 18 Nov 2023 11:57:02 +0100 Subject: [PATCH 04/25] Make release script properly update version in mpv.py --- do_release.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/do_release.sh b/do_release.sh index fb50fcb..b1ae0f3 100755 --- a/do_release.sh +++ b/do_release.sh @@ -17,7 +17,7 @@ if [ -n "$(git diff --name-only --cached)" ]; then fi 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 +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 git -c user.signingkey=E36F75307F0A0EC2D145FF5CED7A208EEEC76F2D -c user.email=python-mpv@jaseg.de tag -s "v$VER" -m "Version $VER" From c52a07e86279f8a892aee4b2adae2a03481f7bce Mon Sep 17 00:00:00 2001 From: jaseg Date: Sat, 18 Nov 2023 11:57:40 +0100 Subject: [PATCH 05/25] Version 1.0.5 --- mpv.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/mpv.py b/mpv.py index ac024b7..b2f6868 100644 --- a/mpv.py +++ b/mpv.py @@ -17,7 +17,7 @@ # # 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.3' +__version__ = '1.0.5' from ctypes import * import ctypes.util diff --git a/pyproject.toml b/pyproject.toml index 03c8c4f..49de9d8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ py-modules = ['mpv'] [project] name = "mpv" -version = "v1.0.4" +version = "v1.0.5" description = "A python interface to the mpv media player" readme = "README.rst" authors = [{name = "jaseg", email = "mpv@jaseg.de"}] From 141ec7d372c8d536c208c39bb89132d69aa1b36c Mon Sep 17 00:00:00 2001 From: jaseg Date: Mon, 22 Jan 2024 11:48:47 +0100 Subject: [PATCH 06/25] mpv.py: Add play_context convenience function --- mpv.py | 37 +++++++++++++++++++++++++++++++++++++ tests/test_mpv.py | 20 ++++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/mpv.py b/mpv.py index b2f6868..19f3581 100644 --- a/mpv.py +++ b/mpv.py @@ -1944,6 +1944,43 @@ class MPV(object): 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() + + frame = sys._getframe() + stream_name = f'__python_mpv_play_generator_{hash(frame)}' + EOF = frame # Get some unique object as EOF marker + @self.python_stream(stream_name) + 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(f'python://{stream_name}') + yield write + q.put(EOF) + 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). diff --git a/tests/test_mpv.py b/tests/test_mpv.py index 74999ad..9cc688d 100755 --- a/tests/test_mpv.py +++ b/tests/test_mpv.py @@ -643,6 +643,26 @@ 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() class TestLifecycle(unittest.TestCase): From 4e76f01ecc4c55a8dfd923a44bebd671907f944b Mon Sep 17 00:00:00 2001 From: jaseg Date: Mon, 22 Jan 2024 11:52:42 +0100 Subject: [PATCH 07/25] mpv.py: Add play_bytes convenience function --- mpv.py | 11 +++++++++++ tests/test_mpv.py | 18 ++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/mpv.py b/mpv.py index 19f3581..1efdeb1 100644 --- a/mpv.py +++ b/mpv.py @@ -1981,6 +1981,17 @@ class MPV(object): yield write q.put(EOF) + def play_bytes(self, data): + frame = sys._getframe() + stream_name = f'__python_mpv_play_generator_{hash(frame)}' + + @self.python_stream(stream_name) + def reader(): + yield data + reader.unregister() # unregister itself + + self.play(f'python://{stream_name}') + 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). diff --git a/tests/test_mpv.py b/tests/test_mpv.py index 9cc688d..0935a32 100755 --- a/tests/test_mpv.py +++ b/tests/test_mpv.py @@ -664,6 +664,24 @@ class TestStreams(unittest.TestCase): 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): From b6ab5a4ab00ee4fff35e50963732bcf87dd5329f Mon Sep 17 00:00:00 2001 From: jaseg Date: Mon, 22 Jan 2024 11:55:03 +0100 Subject: [PATCH 08/25] play_bytes: Add docstring. --- mpv.py | 1 + 1 file changed, 1 insertion(+) diff --git a/mpv.py b/mpv.py index 1efdeb1..a4ea245 100644 --- a/mpv.py +++ b/mpv.py @@ -1982,6 +1982,7 @@ class MPV(object): q.put(EOF) def play_bytes(self, data): + """ Play the given bytes object as a single file. """ frame = sys._getframe() stream_name = f'__python_mpv_play_generator_{hash(frame)}' From d26f801cecc7a87b88ab2687228ce3c540d8e998 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Mon, 29 Jan 2024 16:52:42 +0100 Subject: [PATCH 09/25] Update CI pipeline (#271) * Trigger on pull request * run on ubuntu-latest * run on 3.11 + 3.12 also * upgrade actions * upgrade libmpv * use libmpv-v3 on Windows * disable fail-fast behavior * replace pytest with unittest Using pytest, we run into access violation errors on Windows. They disappear when we use the unittest module. --- .github/workflows/tests.yml | 30 +++++++++++++++++++----------- tests/requirements.txt | 1 - 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 8a0d066..52eb072 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -4,6 +4,8 @@ name: 'Tests' on: push: branches: [ '**' ] + pull_request: + branches: [ '**' ] defaults: @@ -13,19 +15,20 @@ defaults: jobs: test-linux: - runs-on: ubuntu-22.04 + runs-on: ubuntu-latest name: 'Linux - Python' strategy: matrix: - python-version: [ '3.8', '3.9', '3.10' ] + 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@v3 + - uses: actions/checkout@v4 - name: 'Install Python' - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: 'Update Packages' @@ -57,30 +60,35 @@ jobs: function execute() { echo -e "\033[0;34m$*\033[0m"; "$@"; } execute source venv/bin/activate - execute xvfb-run python -m pytest + 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' ] + python-version: [ '3.8', '3.9', '3.10', '3.11', '3.12' ] + fail-fast: false steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: 'Install Python' - uses: actions/setup-python@v4 + 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-20220619-git-c1a46ec.7z" + 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 mpv-2.dll tests + execute mv libmpv-2.dll tests - name: 'Setup Test Environment' run: | function execute() { echo -e "\033[0;34m$*\033[0m"; "$@"; } @@ -95,4 +103,4 @@ jobs: function execute() { echo -e "\033[0;34m$*\033[0m"; "$@"; } execute source venv/Scripts/activate - execute python -m pytest + execute python -m unittest diff --git a/tests/requirements.txt b/tests/requirements.txt index ded363c..eabe145 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -1,2 +1 @@ PyVirtualDisplay>=3.0 -pytest>=7.1.2 From d96eaf7e64f1063ae16f88abfaa14c2997b3291d Mon Sep 17 00:00:00 2001 From: jaseg Date: Sat, 20 Apr 2024 12:48:58 +0200 Subject: [PATCH 10/25] Fix loadfile for mpv v0.38.0 mpv v0.38.0 added an argument to the loadfile command. Unfortunately the parsing logic isn't very smart, and now mis-interprets the old argument format, and breaks literally everything written against older versions that used the `options` kv dict. This commit adds a kludge that uses the right variant depending on the mpv version. --- mpv.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/mpv.py b/mpv.py index a4ea245..9297004 100644 --- a/mpv.py +++ b/mpv.py @@ -893,6 +893,8 @@ 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): @@ -1324,9 +1326,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).""" From 16093073b058cd2bc67ba3de857f2b4f187c32e1 Mon Sep 17 00:00:00 2001 From: jaseg Date: Sat, 20 Apr 2024 12:51:15 +0200 Subject: [PATCH 11/25] Version 1.0.6 --- mpv.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/mpv.py b/mpv.py index 9297004..9856dc2 100644 --- a/mpv.py +++ b/mpv.py @@ -17,7 +17,7 @@ # # 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.5' +__version__ = '1.0.6' from ctypes import * import ctypes.util diff --git a/pyproject.toml b/pyproject.toml index 49de9d8..b15441f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ py-modules = ['mpv'] [project] name = "mpv" -version = "v1.0.5" +version = "v1.0.6" description = "A python interface to the mpv media player" readme = "README.rst" authors = [{name = "jaseg", email = "mpv@jaseg.de"}] From 4dcfba9d40d99ec95a02fdce3349a29f10fb8917 Mon Sep 17 00:00:00 2001 From: jaseg <136313+jaseg@users.noreply.github.com> Date: Thu, 16 May 2024 13:35:47 +0200 Subject: [PATCH 12/25] README: Clarify Python version support --- README.rst | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/README.rst b/README.rst index 3876dd0..1341efc 100644 --- a/README.rst +++ b/README.rst @@ -32,12 +32,10 @@ On Windows you can place libmpv anywhere in your ``%PATH%`` (e.g. next to ``pyth into ctypes, which is different to the one Windows uses internally. Consult `this stackoverflow post `__ for details. -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. +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 From 5bb298ad11b7ad7dbeeddfd87546c0d34b28397c Mon Sep 17 00:00:00 2001 From: jaseg <136313+jaseg@users.noreply.github.com> Date: Thu, 16 May 2024 13:36:49 +0200 Subject: [PATCH 13/25] pyproject.toml: Update python version classifiers, bump min to 3.9 --- pyproject.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b15441f..5aff76a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,7 @@ 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.7" +requires-python = ">=3.9" 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' ] From f1621b629df60c29945392b5739ac97f33e31bdf Mon Sep 17 00:00:00 2001 From: jaseg Date: Fri, 21 Jun 2024 16:14:37 +0200 Subject: [PATCH 14/25] Fix race condition in property observer code leading to futures.InvalidStateError Previously, prepare_and_wait_for_property was slightly confused on the lifetime of that future. This closes #282 --- mpv.py | 33 +++++++++++++++++++-------------- tests/test_mpv.py | 22 +++++++++++++++++++++- 2 files changed, 40 insertions(+), 15 deletions(-) diff --git a/mpv.py b/mpv.py index 9856dc2..75567c1 100644 --- a/mpv.py +++ b/mpv.py @@ -1036,30 +1036,38 @@ 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) @@ -1821,9 +1829,6 @@ 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 diff --git a/tests/test_mpv.py b/tests/test_mpv.py index 0935a32..6a962f0 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 +from concurrent.futures import Future, InvalidStateError os.environ["PATH"] = os.path.dirname(__file__) + os.pathsep + os.environ["PATH"] @@ -915,6 +915,25 @@ 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.play('tests/test.webm') + 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 @@ -966,3 +985,4 @@ 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'])]) + From e1ae4f7da6d1858ab5af7d003c24fd6ca1f8fed5 Mon Sep 17 00:00:00 2001 From: jaseg Date: Fri, 21 Jun 2024 16:23:54 +0200 Subject: [PATCH 15/25] Version 1.0.7 --- mpv.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/mpv.py b/mpv.py index 75567c1..8941aed 100644 --- a/mpv.py +++ b/mpv.py @@ -17,7 +17,7 @@ # # 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.6' +__version__ = '1.0.7' from ctypes import * import ctypes.util diff --git a/pyproject.toml b/pyproject.toml index 5aff76a..dbea61f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ py-modules = ['mpv'] [project] name = "mpv" -version = "v1.0.6" +version = "v1.0.7" description = "A python interface to the mpv media player" readme = "README.rst" authors = [{name = "jaseg", email = "mpv@jaseg.de"}] From f4086d4bb44603dcbaac51a162b280f760cbde2c Mon Sep 17 00:00:00 2001 From: jaseg Date: Mon, 15 Jul 2024 14:52:59 +0200 Subject: [PATCH 16/25] Add API to set dict-valued properties --- mpv.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/mpv.py b/mpv.py index 8941aed..94eeb85 100644 --- a/mpv.py +++ b/mpv.py @@ -2064,7 +2064,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: From 775fc4a868fa8a84b6e1830a42cba4847a72e961 Mon Sep 17 00:00:00 2001 From: jaseg Date: Tue, 16 Jul 2024 11:10:36 +0200 Subject: [PATCH 17/25] Add test for dict-valued properties --- tests/test_mpv.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/test_mpv.py b/tests/test_mpv.py index 6a962f0..a59ade1 100755 --- a/tests/test_mpv.py +++ b/tests/test_mpv.py @@ -210,6 +210,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) From ef3f47c3ec8e276d4e25fd9ec2a7f06afd7df1ea Mon Sep 17 00:00:00 2001 From: jaseg Date: Wed, 14 Aug 2024 10:48:37 +0200 Subject: [PATCH 18/25] Fix quit and quit_watch_later commands --- mpv.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/mpv.py b/mpv.py index 94eeb85..fa6666f 100644 --- a/mpv.py +++ b/mpv.py @@ -1375,11 +1375,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).""" From 3d09f5199e73dd010b22e81709452cc0117d73e7 Mon Sep 17 00:00:00 2001 From: jaseg Date: Wed, 14 Aug 2024 10:55:51 +0200 Subject: [PATCH 19/25] Windows: Improve DLL loading error messages --- mpv.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/mpv.py b/mpv.py index fa6666f..d784db5 100644 --- a/mpv.py +++ b/mpv.py @@ -38,7 +38,15 @@ if os.name == 'nt': 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) + # 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 + try: + backend = CDLL(dll, 0x00001000 | 0x00000100) + except Exception as e: + if not os.path.isabs(dll): + 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 From 16cd0b338e50419713b2d7bd8fea956cb593c303 Mon Sep 17 00:00:00 2001 From: jaseg Date: Wed, 14 Aug 2024 11:05:01 +0200 Subject: [PATCH 20/25] Windows: Look for mpv.dll next to mpv.py --- README.rst | 5 +++-- mpv.py | 26 +++++++++++++++++++------- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/README.rst b/README.rst index 1341efc..db57f22 100644 --- a/README.rst +++ b/README.rst @@ -29,8 +29,9 @@ 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.9 ............. diff --git a/mpv.py b/mpv.py index d784db5..cd843a2 100644 --- a/mpv.py +++ b/mpv.py @@ -2,7 +2,7 @@ # vim: ts=4 sw=4 et # # Python MPV library module -# Copyright (C) 2017-2022 Sebastian Götte +# Copyright (C) 2017-2024 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 @@ -24,6 +24,7 @@ import ctypes.util import threading import queue import os +import os.path import sys from warnings import warn from functools import partial, wraps @@ -35,19 +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('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"].') - # 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 + 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): + 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) From e6e9313af38dc4e6f650ac893b712eaa058b0b5a Mon Sep 17 00:00:00 2001 From: jaseg Date: Sun, 25 Aug 2024 13:45:13 +0200 Subject: [PATCH 21/25] Tests: Fix race condition in test_wait_for_property_concurrency --- tests/test_mpv.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_mpv.py b/tests/test_mpv.py index a59ade1..9c42332 100755 --- a/tests/test_mpv.py +++ b/tests/test_mpv.py @@ -49,7 +49,8 @@ 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): @@ -922,11 +923,10 @@ 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.play('tests/test.webm') + player.loadfile('tests/test.webm', loop='inf') for player in players: player.wait_for_property('seekable') for player in players: From 1bc7e2525e166e963bf9449c07a1c13135cf85f6 Mon Sep 17 00:00:00 2001 From: jaseg Date: Sat, 21 Dec 2024 14:31:14 +0100 Subject: [PATCH 22/25] Use callback id() instead of frame hash() to identify anonymous python streams Frame hashes are not unique since the frame isn't kept around for the life time of the stream. Fixes #292. --- mpv.py | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/mpv.py b/mpv.py index cd843a2..03b7828 100644 --- a/mpv.py +++ b/mpv.py @@ -1956,6 +1956,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. @@ -1972,16 +1976,25 @@ 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 @@ -2003,10 +2016,8 @@ class MPV(object): """ q = queue.Queue() - frame = sys._getframe() - stream_name = f'__python_mpv_play_generator_{hash(frame)}' - EOF = frame # Get some unique object as EOF marker - @self.python_stream(stream_name) + EOF = object() # Get some unique object as EOF marker + @self.python_stream() def reader(): while (chunk := q.get()) is not EOF: if chunk: @@ -2017,21 +2028,19 @@ class MPV(object): q.put(chunk) # Start playback before yielding, the first call to reader() will block until write is called at least once. - self.play(f'python://{stream_name}') + self.play(reader.stream_uri) yield write q.put(EOF) def play_bytes(self, data): """ Play the given bytes object as a single file. """ - frame = sys._getframe() - stream_name = f'__python_mpv_play_generator_{hash(frame)}' - @self.python_stream(stream_name) + @self.python_stream() def reader(): yield data reader.unregister() # unregister itself - self.play(f'python://{stream_name}') + 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 From 12850b34bd3b64704f8abd30341a647a73719267 Mon Sep 17 00:00:00 2001 From: jaseg Date: Sat, 21 Dec 2024 14:47:38 +0100 Subject: [PATCH 23/25] Add support for libmpv's new args to key binding handlers This changes the API, check your code if you use key bindings. --- mpv.py | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/mpv.py b/mpv.py index 03b7828..c227b33 100644 --- a/mpv.py +++ b/mpv.py @@ -1698,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:: @@ -1720,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 @@ -1729,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``). @@ -1785,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.""" From 0c33c933dd05f05d7e11ca199e2faa44e1d583a4 Mon Sep 17 00:00:00 2001 From: jaseg Date: Fri, 25 Apr 2025 11:48:30 +0200 Subject: [PATCH 24/25] Version 1.0.8 --- mpv.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/mpv.py b/mpv.py index c227b33..1d3f469 100644 --- a/mpv.py +++ b/mpv.py @@ -17,7 +17,7 @@ # # 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.7' +__version__ = '1.0.8' from ctypes import * import ctypes.util diff --git a/pyproject.toml b/pyproject.toml index dbea61f..f68661e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ py-modules = ['mpv'] [project] name = "mpv" -version = "v1.0.7" +version = "v1.0.8" description = "A python interface to the mpv media player" readme = "README.rst" authors = [{name = "jaseg", email = "mpv@jaseg.de"}] From b26b72e183bcbcf968dfaa192c819acf38580713 Mon Sep 17 00:00:00 2001 From: jaseg Date: Fri, 25 Apr 2025 11:54:22 +0200 Subject: [PATCH 25/25] Fix typo in MPV.osd_overlay Closes #293 --- mpv.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mpv.py b/mpv.py index 1d3f469..f9bf561 100644 --- a/mpv.py +++ b/mpv.py @@ -1529,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):