diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 0000000..6716b6e --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,57 @@ +# Python CircleCI 2.0 configuration file +# +# Check https://circleci.com/docs/2.0/language-python/ for more details +version: 2 + +shared: &shared + working_directory: ~/repo + steps: + - checkout + + - run: + name: Install dependencies + command: | + sudo apt-get install -y dbus libdbus-1-dev libdbus-glib-1-dev + sudo apt-get install python3-venv # Necessary on py2.7 circleci image + python3 -m venv venv + . venv/bin/activate + pip install tox + pip install codecov + + - run: + name: Run tests + command: | + . venv/bin/activate + # If we don't specify the python version then the default + # 3.5 version that is part of all images is run which causes + # issues. + TOX_ENV="$(echo ${PYTHON_VERSION} | sed -E 's/([[:digit:]]).([[:digit:]])+.([[:digit:]])+/py\1\2/')" + tox -e "$TOX_ENV" + codecov + +jobs: + py2.7: + <<: *shared + docker: + - image: circleci/python:2.7 + py3.5: + <<: *shared + docker: + - image: circleci/python:3.5 + py3.6: + <<: *shared + docker: + - image: circleci/python:3.6 + py3.7: + <<: *shared + docker: + - image: circleci/python:3.7 + +workflows: + version: 2 + test: + jobs: + - py2.7 + - py3.5 + - py3.6 + - py3.7 diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md deleted file mode 100644 index ac2420f..0000000 --- a/.github/CONTRIBUTING.md +++ /dev/null @@ -1,9 +0,0 @@ -# Rules -* **Thou shalt not break the build**. -* **Thou shalt not break the tests**. -* **Thou shalt write tests for new functionality**. -* **Thou shalt rebase onto `develop` instead of merging**. -* Don't reduce test coverage, ideally improve it. -* Add doc comments if your code is to be used by client code. - -Thou shalt receive cookies for your welcomed contribution :D diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md index 8e52e79..2590052 100644 --- a/.github/ISSUE_TEMPLATE.md +++ b/.github/ISSUE_TEMPLATE.md @@ -1,4 +1,9 @@ ## README + +**READ BELOW THE FOLD BEFORE REPORTING AN ISSUE** + +------ + If you're looking for **help** on how to use the library head over to the [Raspberry Pi forums](https://www.raspberrypi.org/forums/viewforum.php?f=32&sid=f1a4513e3e137272da39dbb11089e077) @@ -6,7 +11,15 @@ If you're reporting an issue or requesting a feature, read on! Pick one of the following templates depending on your issue type. ------ -Delete above and including this line. + +If you're going to report an issue, please pick one of the template below: + +* Issue report +* Feature request + +Delete the template you aren't using. + +**FINALLY, DELETE ALL THE ABOVE AND THIS LINE.** # Issue Report diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 2fd3a1d..fcd0e6a 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -19,10 +19,4 @@ other_pr_master | [link]() ## Steps to Test or Reproduce Outline the steps to test or reproduce the PR here. -```sh -git pull --prune -git checkout -bundle; script/server -``` - 1. diff --git a/.gitignore b/.gitignore index c189eb8..4dd7816 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,9 @@ __pycache__/ *.py[cod] +# pyenv +.python-version + # C extensions *.so @@ -57,3 +60,6 @@ docs/_build/ .idea **/build + +# Vim +*.swp diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 3cf8baf..0000000 --- a/.travis.yml +++ /dev/null @@ -1,28 +0,0 @@ -dist: trusty -sudo: false -language: python -python: - - 2.7_with_system_site_packages - # Ideally I'd like to test with 3.6 but since 3.4 is the latest in - # trusty we have to use that instead - - 3.4_with_system_site_packages - -cache: - - pip - - apt - -addons: - apt: - packages: - - python-dbus - - python3-dbus - -install: - - pip install -r requirements.txt - - pip install tox-travis - -script: - - make check - -after_success: - - bash <(curl -s https://codecov.io/bash) diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ec71f88 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,58 @@ +# 0.3.2 -> 0.3.3 +* Clean up process when start up fails (#196) + +# 0.3.1 -> 0.3.2 +* Remove unused imports (`mock`) causing errors (#157) + +# 0.3.0 -> 0.3.1 +* Remove `tests` package from distribution +* Support calling `quit` multiple times without raising exception + +# 0.2.5 -> 0.3.0 + +* Change `set_volume` to work with values between 0-10 instead of + millibels +* Fix `volume` to return actual volume rather than just 1.0 +* Fix `rate` to return actual rate rather than just 1.0 +* Support providing arguments as a `str` which is then split with `shlex.split`, + i.e. you don't have to provide a list of shell split args if you don't want to. +* Support `str` media file path in `OMXPlayer` constructor. +* Cleanup omxplayer process on exit + + +# 0.2.4 -> 0.2.5 + +* Correct `omxplayer.__version__` to return 0.2.5 instead of 0.2.3 (in 0.2.4) + +# 0.2.3 -> 0.2.4 + +* New methods: + * `aspect_ratio` + * `can_raise` + * `fullscreen` + * `has_track_list` + * `height` + * `hide_subtitles` + * `hide_video` + * `metadata` + * `next` + * `previous` + * `rate` + * `select_audio` + * `select_subtitle` + * `set_rate` + * `show_subtitles` + * `show_video` + * `supported_uri_schemes` + * `video_pos` + * `video_stream_count` + * `width` +* We no longer verify the source exists before playing to allow a broader range + of valid inputs +* Revamped docs, available at http://python-omxplayer-wrapper.readthedocs.io/ + +## Developer relevant changes + +* Added integration tests +* Refactored tests +* Unit testing on python 3.5 and 3.6 in travis diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst new file mode 100644 index 0000000..989334f --- /dev/null +++ b/CONTRIBUTING.rst @@ -0,0 +1,76 @@ +Contributing +============ + +Rules +----- + +- **Thou shalt not break the build**. +- **Thou shalt not break the tests**. +- **Thou shalt write tests for new functionality**. +- **Thou shalt rebase onto ``develop`` instead of merging**. +- Don't reduce test coverage, ideally improve it. +- Add doc comments if your code is to be used by client code. + +Thou shalt receive cookies for your welcomed contribution. + +Development +----------- + +1. Set up a virtual environment + + .. code:: console + + $ python3 -m venv .venv + $ .venv/bin/activate + +2. Install package in editable mode with dependencies + + .. code:: console + + $ pip install ".[test,docs]" -e . + +3. Run unit tests + + .. code:: console + + $ make test # run under current python version + + $ pip install tox pyenv tox-pyenv # Install pyenv tox to run under all python versions + $ git clone https://github.com/momo-lab/xxenv-latest.git "$(pyenv root)"/plugins/xxenv-latest + $ for v in 2.7 3.4 3.5 3.6 3.7; do pyenv la test install "$v"; done + $ pyenv versions --base > .python-version + $ make test-all # run under tox for all supported python versions + +4. Run integration tests (on Raspberry Pi) + + .. code:: console + + $ make test-integration + + +5. Build docs + + .. code:: console + + $ make doc + $ make doc-serve # run HTTP server to view docs + +6. Run examples + + .. code:: console + + $ cd examples + $ PYTHONPATH=.. python3 video_file.py + $ PYTHONPATH=.. python3 advanced_usage.py + + +On a Raspberry Pi +~~~~~~~~~~~~~~~~~ + +There's also an Ansible playbook in ``devenv`` which will set up a +raspberry pi with ``omxplayer-wrapper`` in develop mode (located at +``/usr/src/omxplayer-wrapper``) which can be used by running +``./devenv/deploy.sh`` + +This will install via symlinks so that you can continue to work on it +locally but import it from other python packages diff --git a/Makefile b/Makefile index ad58f2f..c2759cd 100644 --- a/Makefile +++ b/Makefile @@ -1,24 +1,36 @@ -PYTHON2:=python2 -PYTHON3:=python3 +PYTHON2 ?= python2 +PYTHON3 ?= python3 -.PHONY: check -check: test +.PHONY: init +init: + pip install ".[test,docs]" -e . .PHONY: test test: + pytest tests/unit --cov-branch --cov=omxplayer + +.PHONY: test-all +test-all: tox +.PHONY: test-integration +test-integration: + pytest tests/integration/test.py + .PHONY: dist -dist: test - $(PYTHON3) setup.py bdist_wheel --universal +dist: + $(PYTHON3) setup.py sdist bdist_wheel --universal -dist-upload: clean-dist dist +dist-upload: clean dist twine upload dist/* -clean-dist: - rm -rf build - rm -rf dist - .PHONY: doc doc: $(MAKE) -C docs html + +.PHONY: doc-serve +doc-serve: doc + cd docs/build/html && $(PYTHON3) -m http.server + +clean: + rm -rf dist build $(shell find . -iname '*.egg-info') diff --git a/README.md b/README.md deleted file mode 100644 index 2e9ed35..0000000 --- a/README.md +++ /dev/null @@ -1,100 +0,0 @@ -# Python OMXPlayer wrapper - -[![PyPI Version](https://img.shields.io/pypi/v/omxplayer-wrapper.svg?maxAge=2592000)](https://pypi.python.org/pypi/omxplayer-wrapper) -[![PyPI Python versions](https://img.shields.io/pypi/pyversions/omxplayer-wrapper.svg)](https://pypi.python.org/pypi/omxplayer-wrapper) -[![PyPI License](https://img.shields.io/pypi/l/omxplayer-wrapper.svg?maxAge=2592000)](https://pypi.python.org/pypi/omxplayer-wrapper) -[![Documentation -Status](https://readthedocs.org/projects/python-omxplayer-wrapper/badge/?version=latest)](https://readthedocs.org/projects/python-omxplayer-wrapper/?badge=latest) -[![Build Status](https://travis-ci.org/willprice/python-omxplayer-wrapper.svg?branch=develop)](https://travis-ci.org/willprice/python-omxplayer-wrapper) -[![Code Coverage](https://codecov.io/gh/willprice/python-omxplayer-wrapper/branch/develop/graph/badge.svg)](https://codecov.io/gh/willprice/python-omxplayer-wrapper) - - -> Control OMXPlayer from Python on the Raspberry Pi. - -## Install -Make sure dbus is installed: -```shell -$ sudo apt-get install python-dbus -``` - -For someone who just wants to use the package: -```shell -$ python setup.py install -``` - -If you're feeling helpful, and decide to help develop the package: -```shell -$ python setup.py develop -``` -There's also an Ansible playbook in `devenv` which will set up a raspberry pi -with omxplayer-wrapper in develop mode (located at -`/usr/src/omxplayer-wrapper`) which can be used by running `./devenv/deploy.sh` - -This will install via symlinks so that you can continue to work on it locally -but import it from other python packages - -## Hello world -```python -from omxplayer import OMXPlayer -from time import sleep - -file_path_or_url = 'path/to/file.mp4' - -# This will start an `omxplayer` process, this might -# fail the first time you run it, currently in the -# process of fixing this though. -player = OMXPlayer(file_path_or_url) - -player.pause() -sleep(5) -player.play() - -# Kill the `omxplayer` process gracefully. -player.quit() -``` - -Playing a stream from a URL (e.g. a live RTMP or RTSP stream) works the same as with a file path, just change the "source" string parameter given to `OMXPlayer` to a URL instead of a file path. -```python -from omxplayer import OMXPlayer -from time import sleep - -file_path_or_url = 'rtmp://192.168.0.1/live/test' - -player = OMXPlayer(file_path_or_url) - -player.pause() -sleep(5) -player.play() - -# Kill the `omxplayer` process gracefully. -player.quit() -``` - -Playing several instances of omxplayer simultaneously -```python -from omxplayer import OMXPlayer - -# Use default dbus name for first instance -player1 = OMXPlayer(file_path_or_url1, pause=True) -# Name of dbus for second instance is just an example -player2 = OMXPlayer(file_path_or_url2, dbus_name='org.mpris.MediaPlayer2.omxplayer1', pause=True) - -# Players are initially paused due to the 'pause' argument, but this is not necessary -# That way we can start them synchronously -player1.play() -player2.play() - -# Kill the `omxplayer` processes gracefully. -player1.quit() -player2.quit() -``` - -## Usage patterns -*Choppy streaming over a slow connection?* If you're connection isn't good -enough to support streaming, checkout `urllib2` to download the file locally -prior to playing. - - -## Docs -You can read the docs here: -[python-omxplayer-wrapper.rtfd.org](http://python-omxplayer-wrapper.rtfd.org) diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..6db9b51 --- /dev/null +++ b/README.rst @@ -0,0 +1,48 @@ +Python OMXPlayer wrapper +======================== + +|PyPI Version| |PyPI Python versions| |PyPI License| |Documentation Status| +|Build Status| |Code Coverage| |Say Thanks!| + +omxplayer-wrapper is a project to control `OMXPlayer +`_ from python over `dbus +`_. + + +Docs +---- + +You can read the docs at `python-omxplayer-wrapper.rtfd.org +`_ + +.. |PyPI Version| image:: https://img.shields.io/pypi/v/omxplayer-wrapper.svg?maxAge=2592000 + :target: https://pypi.python.org/pypi/omxplayer-wrapper +.. |PyPI Python versions| image:: https://img.shields.io/pypi/pyversions/omxplayer-wrapper.svg + :target: https://pypi.python.org/pypi/omxplayer-wrapper +.. |PyPI License| image:: https://img.shields.io/pypi/l/omxplayer-wrapper.svg?maxAge=2592000 + :target: https://pypi.python.org/pypi/omxplayer-wrapper +.. |Documentation Status| image:: https://readthedocs.org/projects/python-omxplayer-wrapper/badge?version=master + :target: http://python-omxplayer-wrapper.readthedocs.io/en/master?badge=master + :alt: Documentation Status +.. |Build Status| image:: https://circleci.com/gh/willprice/python-omxplayer-wrapper/tree/master.svg?style=shield + :target: https://circleci.com/gh/willprice/python-omxplayer-wrapper/tree/master +.. |Code Coverage| image:: https://codecov.io/gh/willprice/python-omxplayer-wrapper/branch/develop/graph/badge.svg + :target: https://codecov.io/gh/willprice/python-omxplayer-wrapper +.. |Say Thanks!| image:: https://img.shields.io/badge/Say%20Thanks-!-1EAEDB.svg + :target: https://saythanks.io/to/willprice + +FAQ +---- + +How do I create multiple players? +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +You need to make sure each player has a separate DBus name like so: + +.. code-block:: python + + player1 = OMXPlayer(file_1, + dbus_name='org.mpris.MediaPlayer2.omxplayer1') + player2 = OMXPlayer(file_2, + dbus_name='org.mpris.MediaPlayer2.omxplayer2') + diff --git a/STORIES.md b/STORIES.md deleted file mode 100644 index c4b64b1..0000000 --- a/STORIES.md +++ /dev/null @@ -1,6 +0,0 @@ -# User Stories: - -## Basic playback -As a _user of the library_, -Given the _library_ -I want to open `test.mp4` with the library and _play_ the file, and then _pause_ it after one second diff --git a/TODO.md b/TODO.md deleted file mode 100644 index 1edec71..0000000 --- a/TODO.md +++ /dev/null @@ -1,2 +0,0 @@ -## 17/08/2014 -- Implement from `ListAudio` onwards from https://github.com/popcornmix/omxplayer \ No newline at end of file diff --git a/docs/Makefile b/docs/Makefile index ca2adf7..f313ff6 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -15,9 +15,9 @@ endif # Internal variables. PAPEROPT_a4 = -D latex_paper_size=a4 PAPEROPT_letter = -D latex_paper_size=letter -ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source +ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . # the i18n builder cannot share the environment and doctrees with the others -I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source +I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext diff --git a/docs/source/conf.py b/docs/conf.py similarity index 86% rename from docs/source/conf.py rename to docs/conf.py index 7f7cf7d..d350293 100644 --- a/docs/source/conf.py +++ b/docs/conf.py @@ -16,37 +16,36 @@ import os from mock import MagicMock -# If extensions (or modules to document with autodoc) are in another directory, -# add these directories to sys.path here. If the directory is relative to the -# documentation root, use os.path.abspath to make it absolute, like shown here. -sys.path.insert(0, os.path.abspath('../../')) - - class Mock(MagicMock): @classmethod def __getattr__(cls, name): - return Mock() + return MagicMock() + +MOCK_MODULES = ["dbus", "dbus.types"] +sys.modules.update((module, Mock()) for module in MOCK_MODULES) -MOCK_MODULES = ['dbus'] -sys.modules.update((mod_name, Mock()) for mod_name in MOCK_MODULES) + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../'))) + +from omxplayer import __version__ # -- General configuration ------------------------------------------------ -# If your documentation needs a minimal Sphinx version, state it here. -#needs_sphinx = '1.0' + +# 1.3 is the lowest version that the napoleon module is bundled in sphinx +needs_sphinx = '1.3' # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ - 'sphinx.ext.autodoc', - 'sphinxcontrib.napoleon', - 'sphinx.ext.doctest', - 'sphinx.ext.intersphinx', - 'sphinx.ext.todo', - 'sphinx.ext.coverage', - 'sphinx.ext.ifconfig', - 'sphinx.ext.viewcode', + 'sphinx.ext.autodoc', # Automatically generate API docs from docstrings + 'sphinx.ext.napoleon', # For google-style API docstrings + 'sphinx.ext.coverage', # Get doc coverage of public methods + 'sphinx.ext.viewcode', # Automatically add links to src code from docs ] # Add any paths that contain templates here, relative to this directory. @@ -63,16 +62,16 @@ def __getattr__(cls, name): # General information about the project. project = u'omxplayer-wrapper' -copyright = u'2015, Will Price' +copyright = u'2020, Will Price' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # # The short X.Y version. -version = '0.2.0' +version = __version__ # The full version, including alpha/beta/rc tags. -release = version + 'alpha' +release = version # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. @@ -117,12 +116,20 @@ def __getattr__(cls, name): # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. -html_theme = 'default' +html_theme = "alabaster" # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. -#html_theme_options = {} +html_theme_options = { + 'github_user': 'willprice', + 'github_repo': 'python-omxplayer-wrapper', + 'github_type': 'star', + 'github_count': True, + 'github_banner': True, + 'description': 'A library for controlling omxplayer via dbus', + 'codecov_button': True +} # Add any paths that contain custom themes here, relative to this directory. #html_theme_path = [] @@ -162,7 +169,14 @@ def __getattr__(cls, name): #html_use_smartypants = True # Custom sidebar templates, maps document names to template names. -#html_sidebars = {} +html_sidebars = { + '*': [ + 'about.html', + 'navigation.html', + 'relations.html', + 'searchbox.html', + ] +} # Additional templates that should be rendered to pages, maps page names to # template names. @@ -181,7 +195,7 @@ def __getattr__(cls, name): #html_show_sourcelink = True # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. -#html_show_sphinx = True +html_show_sphinx = False # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. #html_show_copyright = True @@ -254,8 +268,7 @@ def __getattr__(cls, name): # -- Options for Texinfo output ------------------------------------------- - -# Grouping the document tree into Texinfo files. List of tuples +# sphinx.ext Grouping the document tree into Texinfo files. List of tuples # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ diff --git a/docs/examples.rst b/docs/examples.rst new file mode 100644 index 0000000..89db2fe --- /dev/null +++ b/docs/examples.rst @@ -0,0 +1,14 @@ +Examples: +--------- + +.. literalinclude:: ../examples/video_file.py + :language: python + :caption: Playing local video file + +.. literalinclude:: ../examples/rtsp_stream.py + :language: python + :caption: Playing RTSP stream + +.. literalinclude:: ../examples/advanced_usage.py + :language: python + :caption: Advanced usage diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..51696f1 --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,18 @@ +omxplayer-wrapper +================= + +.. include:: tagline.rst + +.. include:: installation.rst + +.. include:: examples.rst + + +Learning more: +-------------- + +API docs: + +.. toctree:: + + omxplayer.rst diff --git a/docs/installation.rst b/docs/installation.rst new file mode 100644 index 0000000..d9e6cd6 --- /dev/null +++ b/docs/installation.rst @@ -0,0 +1,26 @@ +Installation: +------------- + +You'll need the following dependencies: + +* `libdbus-1` +* `libdbus-1-dev` +* `libglib2.0-dev` + +OS pre-requisite installation + +.. code-block:: bash + + $ sudo apt-get update && sudo apt-get install -y libdbus-1{,-dev} libglib2.0-dev + +With `pipenv `_ + +.. code-block:: bash + + $ pipenv install omxplayer-wrapper + +With Pip + +.. code-block:: bash + + $ pip install omxplayer-wrapper diff --git a/docs/source/omxplayer.rst b/docs/omxplayer.rst similarity index 51% rename from docs/source/omxplayer.rst rename to docs/omxplayer.rst index 59b85b6..0a63ce1 100644 --- a/docs/source/omxplayer.rst +++ b/docs/omxplayer.rst @@ -1,38 +1,37 @@ -omxplayer package -================= +``omxplayer`` API Docs +====================== -Submodules ----------- -omxplayer.bus_finder module ---------------------------- +``omxplayer.player`` +-------------------- -.. automodule:: omxplayer.bus_finder +.. automodule:: omxplayer.player :members: :undoc-members: :show-inheritance: -omxplayer.dbus_connection module --------------------------------- -.. automodule:: omxplayer.dbus_connection +``omxplayer.bus_finder`` +------------------------ + +.. automodule:: omxplayer.bus_finder :members: :undoc-members: :show-inheritance: -omxplayer.player module ------------------------ -.. automodule:: omxplayer.player +``omxplayer.dbus_connection`` +----------------------------- + +.. automodule:: omxplayer.dbus_connection :members: :undoc-members: :show-inheritance: -Module contents ---------------- +``omxplayer.keys`` +----------------------------- -.. automodule:: omxplayer +.. automodule:: omxplayer.keys :members: :undoc-members: - :show-inheritance: diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 0000000..2963a76 --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,130 @@ +funcsigs==1.0.2 --hash=sha256:330cc27ccbf7f1e992e69fef78261dc7c6569012cf397db8d3de0234e6c937ca --hash=sha256:a7bb0f2cf3a3fd1ab2732cb49eba4252c2af4240442415b4abce3b87022a8f50 +imagesize==1.1.0 \ + --hash=sha256:3f349de3eb99145973fefb7dbe38554414e5c30abd0c8e4b970a7c9d09f3a1d8 \ + --hash=sha256:f3832918bc3c66617f92e35f5d70729187676313caa60c187eb0f28b8fe5e3b5 +snowballstemmer==1.2.1 --hash=sha256:9f3bcd3c401c3e862ec0ebe6d2c069ebc012ce142cce209c098ccb5b09136e89 --hash=sha256:919f26a68b2c17a7634da993d91339e288964f93c274f1343e3bbbe2096e1128 +alabaster==0.7.12 \ + --hash=sha256:446438bdcca0e05bd45ea2de1668c1d9b032e1a9154c2c259092d77031ddd359 \ + --hash=sha256:a661d72d58e6ea8a57f7a86e37d86716863ee5e92788398526d58b26a4e4dc02 +urllib3==1.24.2 \ + --hash=sha256:4c291ca23bbb55c76518905869ef34bdd5f0e46af7afe6861e8375643ffee1a0 \ + --hash=sha256:9a247273df709c4fedb38c711e44292304f73f39ab01beda9f6b9fc375669ac3 +markupsafe==1.1.1 \ + --hash=sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473 \ + --hash=sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161 \ + --hash=sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235 \ + --hash=sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5 \ + --hash=sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff \ + --hash=sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b \ + --hash=sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1 \ + --hash=sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e \ + --hash=sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183 \ + --hash=sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66 \ + --hash=sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1 \ + --hash=sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1 \ + --hash=sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e \ + --hash=sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b \ + --hash=sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905 \ + --hash=sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735 \ + --hash=sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d \ + --hash=sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e \ + --hash=sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d \ + --hash=sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c \ + --hash=sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21 \ + --hash=sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2 \ + --hash=sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5 \ + --hash=sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b \ + --hash=sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6 \ + --hash=sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f \ + --hash=sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f \ + --hash=sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7 +pygments==2.3.1 \ + --hash=sha256:e8218dd399a61674745138520d0d4cf2621d7e032439341bc3f647bff125818d \ + --hash=sha256:5ffada19f6203563680669ee7f53b64dabbeb100eb51b61996085e99c03b284a +jinja2==2.11.3 \ + --hash=sha256:03e47ad063331dd6a3f04a43eddca8a966a26ba0c5b7207a9a9e4e08f1b29419 \ + --hash=sha256:a6d58433de0ae800347cab1fa3043cebbabe8baa9d29e668f1c768cb87a333c6 +coverage==4.5.2 \ + --hash=sha256:a5c58664b23b248b16b96253880b2868fb34358911400a7ba39d7f6399935389 \ + --hash=sha256:b3b0c8f660fae65eac74fbf003f3103769b90012ae7a460863010539bb7a80da \ + --hash=sha256:8cb4febad0f0b26c6f62e1628f2053954ad2c555d67660f28dfb1b0496711952 \ + --hash=sha256:447c450a093766744ab53bf1e7063ec82866f27bcb4f4c907da25ad293bba7e3 \ + --hash=sha256:1b4276550b86caa60606bd3572b52769860a81a70754a54acc8ba789ce74d607 \ + --hash=sha256:6694d5573e7790a0e8d3d177d7a416ca5f5c150742ee703f3c18df76260de794 \ + --hash=sha256:510986f9a280cd05189b42eee2b69fecdf5bf9651d4cd315ea21d24a964a3c36 \ + --hash=sha256:09e47c529ff77bf042ecfe858fb55c3e3eb97aac2c87f0349ab5a7efd6b3939f \ + --hash=sha256:5535dda5739257effef56e49a1c51c71f1d37a6e5607bb25a5eee507c59580d1 \ + --hash=sha256:0a1f9b0eb3aa15c990c328535655847b3420231af299386cfe5efc98f9c250fe \ + --hash=sha256:0cc941b37b8c2ececfed341444a456912e740ecf515d560de58b9a76562d966d \ + --hash=sha256:da969da069a82bbb5300b59161d8d7c8d423bc4ccd3b410a9b4d8932aeefc14b \ + --hash=sha256:6831e1ac20ac52634da606b658b0b2712d26984999c9d93f0c6e59fe62ca741b \ + --hash=sha256:5f55028169ef85e1fa8e4b8b1b91c0b3b0fa3297c4fb22990d46ff01d22c2d6c \ + --hash=sha256:10e8af18d1315de936d67775d3a814cc81d0747a1a0312d84e27ae5610e313b0 \ + --hash=sha256:2b224052bfd801beb7478b03e8a66f3f25ea56ea488922e98903914ac9ac930b \ + --hash=sha256:77f0d9fa5e10d03aa4528436e33423bfa3718b86c646615f04616294c935f840 \ + --hash=sha256:5a7524042014642b39b1fcae85fb37556c200e64ec90824ae9ecf7b667ccfc14 \ + --hash=sha256:85a06c61598b14b015d4df233d249cd5abfa61084ef5b9f64a48e997fd829a82 \ + --hash=sha256:ed02c7539705696ecb7dc9d476d861f3904a8d2b7e894bd418994920935d36bb \ + --hash=sha256:aaa0f296e503cda4bc07566f592cd7a28779d433f3a23c48082af425d6d5a78f \ + --hash=sha256:1e8a2627c48266c7b813975335cfdea58c706fe36f607c97d9392e61502dc79d \ + --hash=sha256:46101fc20c6f6568561cdd15a54018bb42980954b79aa46da8ae6f008066a30e \ + --hash=sha256:ee5b8abc35b549012e03a7b1e86c09491457dba6c94112a2482b18589cc2bdb9 \ + --hash=sha256:c45297bbdbc8bb79b02cf41417d63352b70bcb76f1bbb1ee7d47b3e89e42f95d \ + --hash=sha256:d64b4340a0c488a9e79b66ec9f9d77d02b99b772c8b8afd46c1294c1d39ca478 \ + --hash=sha256:828ad813c7cdc2e71dcf141912c685bfe4b548c0e6d9540db6418b807c345ddd \ + --hash=sha256:d19bca47c8a01b92640c614a9147b081a1974f69168ecd494687c827109e8f42 \ + --hash=sha256:4710dc676bb4b779c4361b54eb308bc84d64a2fa3d78e5f7228921eccce5d815 \ + --hash=sha256:bab8e6d510d2ea0f1d14f12642e3f35cefa47a9b2e4c7cea1852b52bc9c49647 \ + --hash=sha256:ab235d9fe64833f12d1334d29b558aacedfbca2356dfb9691f2d0d38a8a7bfb4 \ + --hash=sha256:99bd767c49c775b79fdcd2eabff405f1063d9d959039c0bdd720527a7738748a \ + --hash=sha256:71afc1f5cd72ab97330126b566bbf4e8661aab7449f08895d21a5d08c6b051ff \ + --hash=sha256:06123b58a1410873e22134ca2d88bd36680479fe354955b3579fb8ff150e4d27 \ + --hash=sha256:7349c27128334f787ae63ab49d90bf6d47c7288c63a0a5dfaa319d4b4541dd2c \ + --hash=sha256:869ef4a19f6e4c6987e18b315721b8b971f7048e6eaea29c066854242b4e98d9 \ + --hash=sha256:859714036274a75e6e57c7bab0c47a4602d2a8cfaaa33bbdb68c8359b2ed4f5c \ + --hash=sha256:0d34245f824cc3140150ab7848d08b7e2ba67ada959d77619c986f2062e1f0e8 \ + --hash=sha256:977e2d9a646773cc7428cdd9a34b069d6ee254fadfb4d09b3f430e95472f3cf3 \ + --hash=sha256:3ad59c84c502cd134b0088ca9038d100e8fb5081bbd5ccca4863f3804d81f61d \ + --hash=sha256:258b21c5cafb0c3768861a6df3ab0cfb4d8b495eee5ec660e16f928bf7385390 +decorator==4.3.2 \ + --hash=sha256:cabb249f4710888a2fc0e13e9a16c343d932033718ff62e1e9bc93a9d3a9122b \ + --hash=sha256:33cd704aea07b4c28b3eb2c97d288a06918275dac0ecebdaf1bc8a48d98adb9e +sphinxcontrib-websupport==1.1.0 \ + --hash=sha256:68ca7ff70785cbe1e7bccc71a48b5b6d965d79ca50629606c7861a21b206d9dd \ + --hash=sha256:9de47f375baf1ea07cdb3436ff39d7a9c76042c10a769c52353ec46e4e8fc3b9 +evento==1.0.1 --hash=sha256:2644a88e4e0a395f0d372ce0b5627506cb29547788bdd97e8fb2240dfe341b13 +sphinx==1.8.4 \ + --hash=sha256:b53904fa7cb4b06a39409a492b949193a1b68cc7241a1a8ce9974f86f0d24287 \ + --hash=sha256:c1c00fc4f6e8b101a0d037065043460dffc2d507257f2f11acaed71fd2b0c83c +pbr==5.1.2 \ + --hash=sha256:a7953f66e1f82e4b061f43096a4bcc058f7d3d41de9b94ac871770e8bdd831a2 \ + --hash=sha256:d717573351cfe09f49df61906cd272abaa759b3e91744396b804965ff7bff38b +babel==2.6.0 \ + --hash=sha256:6778d85147d5d85345c14a26aada5e478ab04e39b078b0745ee6870c2b5cf669 \ + --hash=sha256:8cba50f48c529ca3fa18cf81fa9403be176d374ac4d60738b839122dfaaa3d23 +six==1.12.0 \ + --hash=sha256:3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c \ + --hash=sha256:d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73 +parameterized==0.7.0 \ + --hash=sha256:020343a281efcfe9b71b9028a91817f981202c14d72104b5a2fbe401dee25a18 \ + --hash=sha256:d8c8837fb677ed2d5a93b9e2308ce0da3aeb58cf513120d501e0b7af14da78d5 +docutils==0.14 --hash=sha256:7a4bd47eaf6596e1295ecb11361139febe29b084a87bf005bf899f9a42edc3c6 --hash=sha256:02aec4bd92ab067f6ff27a38a38a41173bf01bed8f89157768c1573f53e474a6 --hash=sha256:51e64ef2ebfb29cae1faa133b3710143496eca21c530f3f71424d77687764274 +pytz==2018.9 \ + --hash=sha256:32b0891edff07e28efe91284ed9c31e123d84bea3fd98e1f72be2508f43ef8d9 \ + --hash=sha256:d5f05e487007e29e03409f9398d074e158d920d36eb82eaf66fb1136b0c5374c +chardet==3.0.4 --hash=sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691 --hash=sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae +nose==1.3.7 --hash=sha256:dadcddc0aefbf99eea214e0f1232b94f2fa9bd98fa8353711dacb112bfcbbb2a --hash=sha256:9ff7c6cc443f8c51994b34a667bbcf45afd6d945be7477b52e97516fd17c53ac --hash=sha256:f1bffef9cbc82628f6e7d7b40d7e255aefaa1adb6a1b1d26c69a8b79e6208a98 +typing==3.6.6 \ + --hash=sha256:a4c8473ce11a65999c8f59cb093e70686b6c84c98df58c1dae9b3b196089858a \ + --hash=sha256:57dcf675a99b74d64dacf6fba08fb17cf7e3d5fdff53d4a30ea2a5e7e52543d4 \ + --hash=sha256:4027c5f6127a6267a435201981ba156de91ad0d1d98e9ddc2aa173453453492d +requests==2.21.0 \ + --hash=sha256:7bf2a778576d825600030a110f3c0e3e8edc51dfaafe1c146e39a2027784957b \ + --hash=sha256:502a824f31acdacb3a35b6690b5fbf0bc41d63a24a45c4004352b0242707598e +certifi==2018.11.29 \ + --hash=sha256:993f830721089fef441cdfeb4b2c8c9df86f0c63239f06bd025a76a7daddb033 \ + --hash=sha256:47f9c83ef4c0c621eaef743f133f09fa8a74a9b75f037e8624f83bd1b6626cb7 +idna==2.8 \ + --hash=sha256:ea8b7f6188e6fa117537c3df7da9fc686d485087abf6ac197f9c46432f7e4a3c \ + --hash=sha256:c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407 +mock==2.0.0 --hash=sha256:5ce3c71c5545b472da17b72268978914d0252980348636840bd34a00b5cc96c1 --hash=sha256:b158b6df76edd239b8208d481dc46b6afd45a846b7812ff0ce58971cf5bc8bba diff --git a/docs/source/index.rst b/docs/source/index.rst deleted file mode 100644 index ac08ee0..0000000 --- a/docs/source/index.rst +++ /dev/null @@ -1,25 +0,0 @@ -.. omxplayer-wrapper documentation master file, created by - sphinx-quickstart2 on Thu Apr 16 18:53:05 2015. - You can adapt this file completely to your liking, but it should at least - contain the root `toctree` directive. - -Welcome to omxplayer-wrapper's documentation! -============================================= -`omxplayer-wrapper` is a project to control `OMXPlayer` over the remote -procedure call (RPC) system `dbus`. In other words, control `OMXPlayer` -from python! - -Contents: - -.. toctree:: - :maxdepth: 2 - - - -Indices and tables -================== - -* :ref:`genindex` -* :ref:`modindex` -* :ref:`search` - diff --git a/docs/source/modules.rst b/docs/source/modules.rst deleted file mode 100644 index 01ba873..0000000 --- a/docs/source/modules.rst +++ /dev/null @@ -1,7 +0,0 @@ -omxplayer -========= - -.. toctree:: - :maxdepth: 4 - - omxplayer diff --git a/docs/tagline.rst b/docs/tagline.rst new file mode 100644 index 0000000..f35a264 --- /dev/null +++ b/docs/tagline.rst @@ -0,0 +1,3 @@ +omxplayer-wrapper is a project to control `OMXPlayer +`_ from python over `dbus +`_. diff --git a/examples/advanced_usage.py b/examples/advanced_usage.py new file mode 100755 index 0000000..fe65bf5 --- /dev/null +++ b/examples/advanced_usage.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 + +from omxplayer.player import OMXPlayer +from pathlib import Path +from time import sleep +import logging +logging.basicConfig(level=logging.INFO) + + +VIDEO_1_PATH = "../tests/media/test_media_1.mp4" +player_log = logging.getLogger("Player 1") + +player = OMXPlayer(VIDEO_1_PATH, + dbus_name='org.mpris.MediaPlayer2.omxplayer1') +player.playEvent += lambda _: player_log.info("Play") +player.pauseEvent += lambda _: player_log.info("Pause") +player.stopEvent += lambda _: player_log.info("Stop") + +# it takes about this long for omxplayer to warm up and start displaying a picture on a rpi3 +sleep(2.5) + +player.set_position(5) +player.pause() + + +sleep(2) + +player.set_aspect_mode('stretch') +player.set_video_pos(0, 0, 200, 200) +player.play() + +sleep(5) + +player.quit() diff --git a/examples/rtsp_stream.py b/examples/rtsp_stream.py new file mode 100755 index 0000000..25f4a10 --- /dev/null +++ b/examples/rtsp_stream.py @@ -0,0 +1,12 @@ +#!/usr/bin/env python3 + +from omxplayer.player import OMXPlayer +from time import sleep + +STREAM_URI = 'rtsp://184.72.239.149/vod/mp4:BigBuckBunny_175k.mov' + +player = OMXPlayer(STREAM_URI) + +sleep(8) + +player.quit() diff --git a/examples/video_file.py b/examples/video_file.py new file mode 100755 index 0000000..91633ac --- /dev/null +++ b/examples/video_file.py @@ -0,0 +1,13 @@ +#!/usr/bin/env python3 + +from omxplayer.player import OMXPlayer +from pathlib import Path +from time import sleep + +VIDEO_PATH = Path("../tests/media/test_media_1.mp4") + +player = OMXPlayer(VIDEO_PATH) + +sleep(5) + +player.quit() diff --git a/issues/71.py b/issues/71.py new file mode 100755 index 0000000..d03b479 --- /dev/null +++ b/issues/71.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python2 + +from omxplayer.player import OMXPlayer +import time +import logging +import logging.config +log = logging.getLogger(__name__) + +logging.basicConfig(level=logging.INFO) + + +def playMedia(path="../tests/media/test_media_1.mp4", duration=0, position=0): + player = OMXPlayer(path, args=["--no-osd"]) + player.set_aspect_mode("fill") + if position > 0: + player.set_position(position) + #player.duration() # this only works if this line is here + if duration == 0: + duration = player.duration() - position + player.play() + time.sleep(duration) + player.quit() + +log.info("Start playMedia") +playMedia(duration=6) +log.info("playMedia call complete") diff --git a/issues/76.py b/issues/76.py new file mode 100755 index 0000000..f118158 --- /dev/null +++ b/issues/76.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python2 +from omxplayer.player import OMXPlayer +import os +from time import sleep + +DEBUG = 1 + +def debug(*args): + if (DEBUG): + text = " ".join(list(map(str, args))) + print text + +def play_film(filename="demo.mp4", duration=0, position=0): + debug("\nfilename:", filename) + debug(" position:", position) + debug(" duration:", duration) + trim_from_end = 0.5 + #trim_from_end = 0 + player = OMXPlayer(filename) + player.set_position(0.0) + debug(" pre-play pos:", player.position()) + player.play() + # check and set position + full_length = player.duration() + # if the position is imposible, set it to 0 + if position <= 0 or position > full_length: + position = 0.0 + player.set_position(position) + # check and set duration + length_to_end = player.duration() + # if duration is imposible, set it to the length_to_end + if duration == 0 or duration > length_to_end: + wait = length_to_end - trim_from_end + else: + wait = duration + if wait < 0: + wait = 0 + debug(" full length: ", full_length) + debug(" length to end: ", length_to_end) + debug(" wait: ", wait) + sleep(wait) + debug(" post sleep pos:", player.position()) + player.pause() + player.quit() + debug(" post pause pos:", player.position()) + return True + +def main(): + src = "../tests/media/test_media_1.mp4" + assert os.path.exists(src) + play_film(src, position=5, duration=5) + + +main() diff --git a/omxplayer/__init__.py b/omxplayer/__init__.py index 6de7d69..6d38916 100644 --- a/omxplayer/__init__.py +++ b/omxplayer/__init__.py @@ -1 +1,3 @@ -from omxplayer.player import OMXPlayer \ No newline at end of file +from omxplayer.player import OMXPlayer +from .__version__ import __title__, __description__, __version__ +from .__version__ import __author__, __license__, __copyright__ diff --git a/omxplayer/__version__.py b/omxplayer/__version__.py new file mode 100644 index 0000000..308507f --- /dev/null +++ b/omxplayer/__version__.py @@ -0,0 +1,7 @@ +__title__ = 'omxplayer' +__description__ = 'A library for controlling omxplayer on the Raspberry Pi' +__author__ = 'Will Price' +__author_email__ = 'will.price94+dev@gmail.com' +__version__ = '0.3.3' +__license__ = 'LGPLv3+' +__copyright__ = 'Copyright 2020 Will Price' diff --git a/omxplayer/bus_finder.py b/omxplayer/bus_finder.py index 9834b37..be49074 100644 --- a/omxplayer/bus_finder.py +++ b/omxplayer/bus_finder.py @@ -1,9 +1,10 @@ import os.path import time from glob import glob -from logging import getLogger +import logging -logger = getLogger(__name__) +logger = logging.getLogger(__name__) +logger.addHandler(logging.NullHandler()) class BusFinder(object): diff --git a/omxplayer/dbus_connection.py b/omxplayer/dbus_connection.py index 5d3b9ca..e4abdb8 100644 --- a/omxplayer/dbus_connection.py +++ b/omxplayer/dbus_connection.py @@ -17,36 +17,44 @@ class DBusConnection(object): """ def __init__(self, bus_address, dbus_name=None): - self.root_interface = None - self.player_interface = None - self._address = bus_address - self._dbus_name = dbus_name - self._bus = self._create_connection() + if dbus_name: + self._dbus_name = dbus_name + else: + self._dbus_name = 'org.mpris.MediaPlayer2.omxplayer' + self._bus = dbus.bus.BusConnection(bus_address) self.proxy = self._create_proxy() - def _create_connection(self): - return dbus.bus.BusConnection(self._address) + self.root_interface = dbus.Interface(self.proxy, 'org.mpris.MediaPlayer2') + self.player_interface = dbus.Interface(self.proxy, 'org.mpris.MediaPlayer2.Player') + self.properties_interface = dbus.Interface(self.proxy, 'org.freedesktop.DBus.Properties') def _create_proxy(self): try: # introspection fails so it is disabled - dbus_name = 'org.mpris.MediaPlayer2.omxplayer' - if self._dbus_name: - dbus_name = self._dbus_name - proxy = self._bus.get_object(dbus_name, + proxy = self._bus.get_object(self._dbus_name, '/org/mpris/MediaPlayer2', introspect=False) - self._create_media_interfaces_on_proxy(proxy) return proxy except dbus.DBusException: raise DBusConnectionError('Could not get proxy object') - def _create_media_interfaces_on_proxy(self, proxy): - self.root_interface = self._interface(proxy, 'org.mpris.MediaPlayer2') - self.player_interface = self._interface(proxy, - 'org.mpris.MediaPlayer2.Player') - self.properties_interface = self._interface(proxy, - 'org.freedesktop.DBus.Properties') - def _interface(self, proxy, interface): - return dbus.Interface(proxy, interface) \ No newline at end of file + +# The python dbus bindings don't provide property access via the +# 'org.freedesktop.DBus.Properties' interface so we wrap the access of +# properties using +class DbusObject(object): + def __init__(self, object_proxy, property_manager, interface_name, methods, properties): + self._proxy = object_proxy + self._property_manager = property_manager + self._interface_name = interface_name + self._methods = methods + self._properties = properties + + def __getattr__(self, name): + if name in self._methods: + return self._proxy.__getattr__(name) + elif name in self._properties: + return self._property_manager.Get(self._interface_name, name) + else: + raise AttributeError("'{}' attribute not specified on this DBus object".format(name)) diff --git a/omxplayer/keys.py b/omxplayer/keys.py index f5f62de..33b3c06 100644 --- a/omxplayer/keys.py +++ b/omxplayer/keys.py @@ -1,33 +1,70 @@ # -*- coding: utf-8 -*- + +# Currently we have to add a fake doc string to all variables +# for sphinx to recognise them when using the automodule directive +# See: https://github.com/sphinx-doc/sphinx/issues/1980 + +#: DECREASE_SPEED = 1 +#: INCREASE_SPEED = 2 +#: REWIND = 3 +#: FAST_FORWARD = 4 +#: SHOW_INFO = 5 +#: PREVIOUS_AUDIO = 6 +#: NEXT_AUDIO = 7 +#: PREVIOUS_CHAPTER = 8 +#: NEXT_CHAPTER = 9 +#: PREVIOUS_SUBTITLE = 10 +#: NEXT_SUBTITLE = 11 +#: TOGGLE_SUBTITLE = 12 +#: DECREASE_SUBTITLE_DELAY = 13 +#: INCREASE_SUBTITLE_DELAY = 14 +#: EXIT = 15 +#: PAUSE = 16 +#: DECREASE_VOLUME = 17 +#: INCREASE_VOLUME = 18 +#: SEEK_BACK_SMALL = 19 +#: SEEK_FORWARD_SMALL = 20 +#: SEEK_BACK_LARGE = 21 +#: SEEK_FORWARD_LARGE = 22 +#: SEEK_RELATIVE = 25 +#: SEEK_ABSOLUTE = 26 +#: STEP = 23 +#: BLANK = 24 +#: MOVE_VIDEO = 27 +#: HIDE_VIDEO = 28 +#: UNHIDE_VIDEO = 29 +#: HIDE_SUBTITLES = 30 +#: SHOW_SUBTITLES = 31 +#: SET_ALPHA=32 diff --git a/omxplayer/player.py b/omxplayer/player.py index 10c218e..d90e0ae 100644 --- a/omxplayer/player.py +++ b/omxplayer/player.py @@ -4,18 +4,19 @@ import signal import logging import threading -import math +import atexit import sys -try: # python2 - from urlparse import urlsplit -except ImportError: # python3 - from urllib.parse import urlsplit -if sys.version_info > (3,): - long = int + +try: # python 3 + from pathlib import Path +except ImportError: # python2 + from pathlib2 import Path + from decorator import decorator from dbus import DBusException, Int64, String, ObjectPath +import dbus.types from omxplayer.bus_finder import BusFinder from omxplayer.dbus_connection import DBusConnection, \ @@ -23,18 +24,80 @@ from evento import Event -#### CONSTANTS #### + +# CONSTANTS + RETRY_DELAY = 0.05 -#### FILE GLOBAL OBJECTS #### +# FILE GLOBAL OBJECTS + logger = logging.getLogger(__name__) +logger.addHandler(logging.NullHandler()) + + +def _check_player_is_active(fn): + # wraps is a decorator that improves debugging wrapped methods + def wrapped(fn, self, *args, **kwargs): + logger.debug('Checking if process is still alive') + # poll determines whether the process has terminated, + # if it hasn't it returns None. + if self._process.poll() is None: + logger.debug('OMXPlayer is running, so execute %s' % + fn.__name__) + return fn(self, *args, **kwargs) + else: + raise OMXPlayerDeadError('Process is no longer alive, can\'t run command') + + return decorator(wrapped, fn) + + +def _from_dbus_type(fn): + def from_dbus_type(dbusVal): + def from_dbus_dict(dbusDict): + d = dict() + for dbusKey, dbusVal in dbusDict.items(): + d[from_dbus_type(dbusKey)] = from_dbus_type(dbusVal) + return d + + typeUnwrapper = { + dbus.types.Dictionary: from_dbus_dict, + dbus.types.Array: lambda x: list(map(from_dbus_type, x)), + dbus.types.Double: float, + dbus.types.Boolean: bool, + dbus.types.Byte: int, + dbus.types.Int16: int, + dbus.types.Int32: int, + dbus.types.Int64: int, + dbus.types.UInt32: int, + dbus.types.UInt64: int, + dbus.types.ByteArray: str, + dbus.types.ObjectPath: str, + dbus.types.Signature: str, + dbus.types.String: str + } + try: + return typeUnwrapper[type(dbusVal)](dbusVal) + except KeyError: + return dbusVal + + def wrapped(fn, self, *args, **kwargs): + return from_dbus_type(fn(self, *args, **kwargs)) + + return decorator(wrapped, fn) + + +# CLASSES -#### CLASSES #### class FileNotFoundError(Exception): pass + +class OMXPlayerDeadError(Exception): + pass + + class OMXPlayer(object): """ OMXPlayer controller @@ -43,25 +106,32 @@ class OMXPlayer(object): Args: source (str): Path to the file (as ~/Videos/my-video.mp4) or URL you wish to play - args (list): used to pass option parameters to omxplayer. see: https://github.com/popcornmix/omxplayer#synopsis + args (list/str): used to pass option parameters to omxplayer. see: https://github.com/popcornmix/omxplayer#synopsis Multiple argument example: >>> OMXPlayer('path.mp4', args=['--no-osd', '--no-keys', '-b']) - + >>> OMXPlayer('path.mp4', args='--no-osd --no-keys -b') + >>> OMXPlayer('path.mp4', dbus_name='org.mpris.MediaPlayer2.omxplayer2') """ def __init__(self, source, - args=[], + args=None, bus_address_finder=None, Connection=None, dbus_name=None, pause=False): logger.debug('Instantiating OMXPlayer') - self.args = args + if args is None: + self.args = [] + elif isinstance(args, str): + import shlex + self.args = shlex.split(args) + else: + self.args = list(map(str, args)) self._is_playing = True - self._source = source + self._source = Path(source) self._dbus_name = dbus_name self._Connection = Connection if Connection else DBusConnection self._bus_address_finder = bus_address_finder if bus_address_finder else BusFinder() @@ -72,6 +142,8 @@ def __init__(self, source, self.playEvent = Event() #: Event called on stop ``callback(player)`` self.stopEvent = Event() + #: Event called on exit ``callback(player, exit_status)`` + self.exitEvent = Event() #: Event called on seek ``callback(player, relative_position)`` self.seekEvent = Event() #: Event called on setting position ``callback(player, absolute_position)`` @@ -86,39 +158,65 @@ def _load_source(self, source): self.quit() self._process = self._setup_omxplayer_process(source) + self._rate = 1.0 + self._is_muted = False self._connection = self._setup_dbus_connection(self._Connection, self._bus_address_finder) def _run_omxplayer(self, source, devnull): - def on_exit(): + def on_exit(self, exit_status): logger.info("OMXPlayer process is dead, all DBus calls from here " "will fail") + self.exitEvent(self, exit_status) - def monitor(process, on_exit): + def monitor(self, process, on_exit): process.wait() - on_exit() + on_exit(self, process.returncode) + try: + source = str(source.resolve()) + except AttributeError: + pass command = ['omxplayer'] + self.args + [source] if self._dbus_name: command += ['--dbus_name', self._dbus_name] logger.debug("Opening omxplayer with the command: %s" % command) + # By running os.setsid in the fork-ed process we create a process group + # which is used to kill the subprocesses the `omxplayer` script + # (it is a bash script itself that calls omxplayer.bin) creates. Without + # doing this we end up in a scenario where we kill the shell script, but not + # the forked children of the shell script. + # See https://pymotw.com/2/subprocess/#process-groups-sessions for examples on this process = subprocess.Popen(command, stdin=devnull, stdout=devnull, preexec_fn=os.setsid) - self._process_monitor = threading.Thread(target=monitor, - args=(process, on_exit)) - self._process_monitor.start() - return process + try: + self._process_monitor = threading.Thread(target=monitor, + args=(self, process, on_exit)) + self._process_monitor.start() + return process + except: + # Make sure to not leave any dangling process on failure + self._terminate_process(process) + raise def _setup_omxplayer_process(self, source): - logger.debug('Setting up OMXPlayer process') - source_url = urlsplit(source) - if not source_url.scheme and not os.path.isfile(source): - raise FileNotFoundError("Could not find: {}".format(source)) - with open(os.devnull, 'w') as devnull: - process = self._run_omxplayer(source, devnull) - logger.debug('Process opened with PID %s' % process.pid) - return process + logger.debug('Setting up OMXPlayer process') + + with open(os.devnull, 'w') as devnull: + process = self._run_omxplayer(source, devnull) + logger.debug('Process opened with PID %s' % process.pid) + + atexit.register(self.quit) + return process + + def _terminate_process(self, process): + try: + process_group_id = os.getpgid(process.pid) + os.killpg(process_group_id, signal.SIGTERM) + logger.debug('SIGTERM Sent to pid: %s' % process_group_id) + except OSError: + logger.error('Could not find the process to kill') def _setup_dbus_connection(self, Connection, bus_address_finder): logger.debug('Trying to connect to OMXPlayer via DBus') @@ -139,20 +237,6 @@ def _setup_dbus_connection(self, Connection, bus_address_finder): """ Utilities """ - def _check_player_is_active(fn): - # wraps is a decorator that improves debugging wrapped methods - def wrapped(fun, self, *args, **kwargs): - logger.debug('Checking if process is still alive') - # poll determines whether the process has terminated, - # if it hasn't it returns None. - if self._process.poll() is None: - logger.debug('OMXPlayer is running, so execute %s' % - fn.__name__) - return fn(self, *args, **kwargs) - else: - logger.info('Process is no longer alive, can\'t run command') - - return decorator(wrapped, fn) def load(self, source, pause=False): """ @@ -163,191 +247,314 @@ def load(self, source, pause=False): source (string): Path to the file to play or URL """ self._source = source - self._load_source(source) - if pause: - time.sleep(0.5) # Wait for the DBus interface to be initialised - self.pause() + try: + self._load_source(source) + if pause: + time.sleep(0.5) # Wait for the DBus interface to be initialised + self.pause() + except: + # Make sure we do not leave any dangling process + if self._process: + self._terminate_process(self._process) + self._process = None + raise - """ ROOT INTERFACE METHODS """ + """ ROOT INTERFACE PROPERTIES """ @_check_player_is_active + @_from_dbus_type def can_quit(self): """ Returns: - bool: """ - return bool(self._get_root_interface().CanQuit()) + bool: whether the player can quit or not """ + return self._root_interface_property('CanQuit') + + @_check_player_is_active + @_from_dbus_type + def fullscreen(self): + """ + Returns: + bool: whether the player is fullscreen or not """ + return self._root_interface_property('Fullscreen') @_check_player_is_active + @_from_dbus_type def can_set_fullscreen(self): """ Returns: - bool: """ - return bool(self._get_root_interface().CanSetFullscreen()) + bool: whether the player can go fullscreen """ + return self._root_interface_property('CanSetFullscreen') @_check_player_is_active + @_from_dbus_type + def can_raise(self): + """ + Returns: + bool: whether the player can raise the display window atop of all other windows""" + return self._root_interface_property('CanRaise') + + @_check_player_is_active + @_from_dbus_type + def has_track_list(self): + """ + Returns: + bool: whether the player has a track list or not""" + return self._root_interface_property('HasTrackList') + + @_check_player_is_active + @_from_dbus_type def identity(self): """ - Get the ID of the media player + Returns: + str: Returns `omxplayer`, the name of the player + """ + return self._root_interface_property('Identity') + @_check_player_is_active + @_from_dbus_type + def supported_uri_schemes(self): + """ Returns: - bool: + str: list of supported URI schemes + Examples: + >>> player.supported_uri_schemes() + ["file", "http", "rtsp", "rtmp"] """ - return str(self._get_root_interface().Identity()) + return self._root_interface_property('SupportedUriSchemes') + + """ ROOT INTERFACE METHODS """ """ PLAYER INTERFACE PROPERTIES """ @_check_player_is_active + @_from_dbus_type def can_go_next(self): """ Returns: - bool: Whether the player can move to the next item in the playlist + bool: whether the player can move to the next item in the playlist """ - return bool(self._get_properties_interface().CanGoNext()) + return self._player_interface_property('CanGoNext') @_check_player_is_active + @_from_dbus_type def can_go_previous(self): """ Returns: - bool: Whether the player can move to the previous item in the - playlist + bool: whether the player can move to the previous item in the + playlist """ - return bool(self._get_properties_interface().CanGoPrevious()) + return self._player_interface_property('CanGoPrevious') @_check_player_is_active + @_from_dbus_type def can_seek(self): """ Returns: - bool: Whether the player can seek """ - return bool(self._get_properties_interface().CanSeek()) + bool: whether the player can seek """ + return self._player_interface_property('CanSeek') @_check_player_is_active + @_from_dbus_type def can_control(self): """ Returns: - bool: """ - return bool(self._get_properties_interface().CanControl()) + bool: whether the player can be controlled""" + return self._player_interface_property('CanControl') @_check_player_is_active + @_from_dbus_type def can_play(self): """ Returns: - bool: """ - return bool(self._get_properties_interface().CanPlay()) + bool: whether the player can play""" + return self._player_interface_property('CanPlay') @_check_player_is_active + @_from_dbus_type def can_pause(self): """ Returns: - bool: """ - return bool(self._get_properties_interface().CanPause()) + bool: whether the player can pause""" + return self._player_interface_property('CanPause') @_check_player_is_active + @_from_dbus_type def playback_status(self): """ Returns: - str: One of ("Playing" | "Paused" | "Stopped") + str: one of ("Playing" | "Paused" | "Stopped") """ - return str(self._get_properties_interface().PlaybackStatus()) + return self._player_interface_property('PlaybackStatus') @_check_player_is_active + @_from_dbus_type def volume(self): """ Returns: - volume (float): Volume in millibels + float: current player volume """ - vol = float(self._get_properties_interface().Volume()) - return 2000 * math.log(vol, 10) + if self._is_muted: + return 0 + return self._player_interface_property('Volume') @_check_player_is_active + @_from_dbus_type def set_volume(self, volume): """ Args: - volume (float): Volume in millibels + float: volume in the interval [0, 10] """ - return float(self._get_properties_interface().Volume( - 10**(volume/2000.0) - )) + # 0 isn't handled correctly so we have to set it to a very small value to achieve the same purpose + if volume == 0: + volume = 1e-10 + return self._player_interface_property('Volume', dbus.Double(volume)) @_check_player_is_active - def mute(self): + @_from_dbus_type + def _position_us(self): + """ + Returns: + int: position in microseconds """ - Turns mute on, if the audio is already muted, then this does not do - anything + return self._player_interface_property('Position') + def position(self): + """ Returns: - None: + int: position in seconds """ - self._get_properties_interface().Mute() + return self._position_us() / (1000.0 * 1000.0) @_check_player_is_active - def unmute(self): + @_from_dbus_type + def minimum_rate(self): + """ + Returns: + float: minimum playback rate (as proportion of normal rate) """ - Unmutes the video, if the audio is already unmuted, then this does - not do anything + return self._player_interface_property('MinimumRate') + @_check_player_is_active + @_from_dbus_type + def maximum_rate(self): + """ Returns: - None: + float: maximum playback rate (as proportion of normal rate) """ - self._get_properties_interface().Unmute() + return self._player_interface_property('MaximumRate') @_check_player_is_active - def position(self): + @_from_dbus_type + def rate(self): """ Returns: - float: The position in seconds + float: playback rate, 1 is the normal rate, 2 would be double speed. """ - return self._get_properties_interface().Position() / (1000 * 1000.0) + return self._rate @_check_player_is_active - def _duration_us(self): + @_from_dbus_type + def set_rate(self, rate): + """ + Set the playback rate of the video as a multiple of the default playback speed + + Examples: + >>> player.set_rate(2) + # Will play twice as fast as normal speed + >>> player.set_rate(0.5) + # Will play half speed + """ + self._rate = self._player_interface_property('Rate', dbus.Double(rate)) + return self._rate + + @_check_player_is_active + @_from_dbus_type + def metadata(self): """ Returns: - long: The duration in microseconds + dict: containing track information ('URI', 'length') + Examples: + >>> player.metadata() + { + 'mpris:length': 19691000, + 'xesam:url': 'file:///home/name/path/to/media/file.mp4' + } """ - return long(self._get_properties_interface().Duration()) + return self._player_interface_property('Metadata') + + """ PLAYER INTERFACE NON-STANDARD PROPERTIES """ @_check_player_is_active - def duration(self): + @_from_dbus_type + def aspect_ratio(self): """ Returns: - float: The duration in seconds + float: aspect ratio """ - return self._duration_us() / (1000 * 1000.0) + return self._player_interface_property('Aspect') @_check_player_is_active - def minimum_rate(self): + @_from_dbus_type + def video_stream_count(self): """ Returns: - str: The minimum playback rate + int: number of video streams """ - return float(self._get_properties_interface().MinimumRate()) + return self._player_interface_property('VideoStreamCount') @_check_player_is_active - def maximum_rate(self): + @_from_dbus_type + def width(self): + """ + Returns: + int: video width in px + """ + return self._player_interface_property('ResWidth') + + @_check_player_is_active + @_from_dbus_type + def height(self): """ Returns: - str: The maximum playback rate + int: video height in px """ - return float(self._get_properties_interface().MaximumRate()) + return self._player_interface_property('ResHeight') + + @_check_player_is_active + @_from_dbus_type + def _duration_us(self): + """ + Returns: + int: total length in microseconds + """ + return self._player_interface_property('Duration') + + @_check_player_is_active + def duration(self): + """ + Returns: + float: duration in seconds + """ + return self._duration_us() / (1000.0 * 1000.0) + """ PLAYER INTERFACE METHODS """ + @_check_player_is_active def pause(self): """ - Return: - None: + Pause playback """ - self._get_player_interface().Pause() + self._player_interface.Pause() self._is_playing = False self.pauseEvent(self) @_check_player_is_active def play_pause(self): """ - Return: - None: + Pause playback if currently playing, otherwise start playing if currently paused. """ - self._get_player_interface().PlayPause() + self._player_interface.PlayPause() self._is_playing = not self._is_playing if self._is_playing: self.playEvent(self) @@ -355,90 +562,207 @@ def play_pause(self): self.pauseEvent(self) @_check_player_is_active + @_from_dbus_type def stop(self): - self._get_player_interface().Stop() + """ + Stop the player, causing it to quit + """ + self._player_interface.Stop() self.stopEvent(self) @_check_player_is_active + @_from_dbus_type def seek(self, relative_position): """ + Seek the video by `relative_position` seconds + Args: relative_position (float): The position in seconds to seek to. """ - self._get_player_interface().Seek(Int64(relative_position)) + self._player_interface.Seek(Int64(1000.0 * 1000 * relative_position)) self.seekEvent(self, relative_position) @_check_player_is_active + @_from_dbus_type def set_position(self, position): """ + Set the video to playback position to `position` seconds from the start of the video + Args: position (float): The position in seconds. """ - self._get_player_interface().SetPosition(ObjectPath("/not/used"), Int64(position*1000*1000)) + self._player_interface.SetPosition(ObjectPath("/not/used"), Int64(position * 1000.0 * 1000)) self.positionEvent(self, position) @_check_player_is_active + @_from_dbus_type + def set_layer(self, layer): + """ + Set the layer of the Video (default 0). Higher layers are above lower layers + + Args: + layer (int): The Layer to switch to. + """ + self._player_interface.SetLayer(Int64(layer)) + + @_check_player_is_active + @_from_dbus_type def set_alpha(self, alpha): """ + Set the transparency of the video overlay + Args: alpha (float): The transparency (0..255) """ - self._get_player_interface().SetAlpha(ObjectPath('/not/used'), Int64(alpha)) + self._player_interface.SetAlpha(ObjectPath('/not/used'), Int64(alpha)) + + @_check_player_is_active + def mute(self): + """ + Mute audio. If already muted, then this does not do anything + """ + self._is_muted = True + self._player_interface.Mute() + + @_check_player_is_active + def unmute(self): + """ + Unmutes the video. If already unmuted, then this does not do anything + """ + self._is_muted = False + self._player_interface.Unmute() + @_check_player_is_active + @_from_dbus_type def set_aspect_mode(self, mode): """ + Set the aspect mode of the video + Args: mode (str): One of ("letterbox" | "fill" | "stretch") """ - self._get_player_interface().SetAspectMode(ObjectPath('/not/used'), String(mode)) + self._player_interface.SetAspectMode(ObjectPath('/not/used'), String(mode)) @_check_player_is_active + @_from_dbus_type def set_video_pos(self, x1, y1, x2, y2): """ + Set the video position on the screen + Args: - Image position (int, int, int, int): + x1 (int): Top left x coordinate (px) + y1 (int): Top left y coordinate (px) + x2 (int): Bottom right x coordinate (px) + y2 (int): Bottom right y coordinate (px) """ position = "%s %s %s %s" % (str(x1),str(y1),str(x2),str(y2)) - self._get_player_interface().VideoPos(ObjectPath('/not/used'), String(position)) + self._player_interface.VideoPos(ObjectPath('/not/used'), String(position)) @_check_player_is_active + def video_pos(self): + """ + Returns: + (int, int, int, int): Video spatial position (x1, y1, x2, y2) where (x1, y1) is top left, + and (x2, y2) is bottom right. All values in px. + """ + position_string = self._player_interface.VideoPos(ObjectPath('/not/used')) + return list(map(int, position_string.split(" "))) + + @_check_player_is_active + @_from_dbus_type def set_video_crop(self, x1, y1, x2, y2): """ Args: - Image position (int, int, int, int): + x1 (int): Top left x coordinate (px) + y1 (int): Top left y coordinate (px) + x2 (int): Bottom right x coordinate (px) + y2 (int): Bottom right y coordinate (px) """ crop = "%s %s %s %s" % (str(x1),str(y1),str(x2),str(y2)) - self._get_player_interface().SetVideoCropPos(ObjectPath('/not/used'), String(crop)) + self._player_interface.SetVideoCropPos(ObjectPath('/not/used'), String(crop)) @_check_player_is_active - def list_video(self): + def hide_video(self): """ - Returns: - [str]: A list of all known video streams, each item is in the - format: ``::::`` + Hides the video overlays """ - return map(str, self._get_player_interface().ListVideo()) + self._player_interface.HideVideo() @_check_player_is_active + def show_video(self): + """ + Shows the video (to undo a `hide_video`) + """ + self._player_interface.UnHideVideo() + + @_check_player_is_active + @_from_dbus_type def list_audio(self): """ Returns: [str]: A list of all known audio streams, each item is in the - format: ``::::`` + format: ``::::`` """ - return map(str, self._get_player_interface().ListAudio()) + return self._player_interface.ListAudio() @_check_player_is_active + @_from_dbus_type + def list_video(self): + """ + Returns: + [str]: A list of all known video streams, each item is in the + format: ``::::`` + """ + return self._player_interface.ListVideo() + + + @_check_player_is_active + @_from_dbus_type def list_subtitles(self): """ Returns: [str]: A list of all known subtitles, each item is in the - format: ``::::`` + format: ``::::`` """ - return map(str, self._get_player_interface().ListSubtitles()) + return self._player_interface.ListSubtitles() @_check_player_is_active + def select_subtitle(self, index): + """ + Enable a subtitle specified by the index it is listed in :class:`list_subtitles` + + Args: + index (int): index of subtitle listing returned by :class:`list_subtitles` + """ + return self._player_interface.SelectSubtitle(dbus.Int32(index)) + + @_check_player_is_active + def select_audio(self, index): + """ + Select audio stream specified by the index of the stream in :class:`list_audio` + + Args: + index (int): index of audio stream returned by :class:`list_audio` + """ + return self._player_interface.SelectAudio(dbus.Int32(index)) + + @_check_player_is_active + def show_subtitles(self): + """ + Shows subtitles after :class:`hide_subtitles` + """ + return self._player_interface.ShowSubtitles() + + @_check_player_is_active + def hide_subtitles(self): + """ + Hide subtitles + """ + return self._player_interface.HideSubtitles() + + @_check_player_is_active + @_from_dbus_type def action(self, code): """ Executes a keyboard command via a code @@ -446,13 +770,11 @@ def action(self, code): Args: code (int): The key code you wish to emulate refer to ``keys.py`` for the possible keys - - Returns: - None: """ - self._get_player_interface().Action(code) + self._player_interface.Action(code) @_check_player_is_active + @_from_dbus_type def is_playing(self): """ Returns: @@ -463,10 +785,10 @@ def is_playing(self): return self._is_playing @_check_player_is_active + @_from_dbus_type def play_sync(self): """ - Returns: - None: + Play the video and block whilst the video is playing """ self.play() logger.info("Playing synchronously") @@ -481,42 +803,85 @@ def play_sync(self): ) @_check_player_is_active + @_from_dbus_type def play(self): """ - Returns: - None: + Play the video asynchronously returning control immediately to the calling code """ if not self.is_playing(): self.play_pause() self._is_playing = True self.playEvent(self) - def _get_root_interface(self): + @_check_player_is_active + @_from_dbus_type + def next(self): + """ + Skip to the next chapter + + Returns: + bool: Whether the player skipped to the next chapter + """ + return self._player_interface.Next() + + @_check_player_is_active + @_from_dbus_type + def previous(self): + """ + Skip to the previous chapter + + Returns: + bool: Whether the player skipped to the previous chapter + """ + return self._player_interface.Previous() + + @property + def _root_interface(self): return self._connection.root_interface - def _get_player_interface(self): + @property + def _player_interface(self): return self._connection.player_interface - def _get_properties_interface(self): + @property + def _properties_interface(self): return self._connection.properties_interface - def quit(self): - try: - logger.debug('Quitting OMXPlayer') - process_group_id = os.getpgid(self._process.pid) - os.killpg(process_group_id, signal.SIGTERM) - logger.debug('SIGTERM Sent to pid: %s' % process_group_id) - self._process_monitor.join() - except OSError: - logger.error('Could not find the process to kill') + def _interface_property(self, interface, prop, val): + if val: + return self._properties_interface.Set(interface, prop, val) + else: + return self._properties_interface.Get(interface, prop) - self._process = None + def _root_interface_property(self, prop, val=None): + return self._interface_property(self._root_interface.dbus_interface, prop, val) + + def _player_interface_property(self, prop, val=None): + return self._interface_property(self._player_interface.dbus_interface, prop, val) + def quit(self): + """ + Quit the player, blocking until the process has died + """ + # Close dbus socket to avoid leaking unix socket. + if self._connection._bus is not None: + self._connection._bus.close() + logger.debug('BusConnection closed') + if self._process is None: + logger.debug('Quit was called after self._process had already been released') + return + + logger.debug('Quitting OMXPlayer') + self._terminate_process(self._process) + self._process_monitor.join() self._process = None @_check_player_is_active + @_from_dbus_type def get_source(self): """ + Get the source URI of the currently playing media + Returns: str: source currently playing """ @@ -524,6 +889,7 @@ def get_source(self): # For backward compatibility @_check_player_is_active + @_from_dbus_type def get_filename(self): """ Returns: @@ -535,6 +901,7 @@ def get_filename(self): return self.get_source() + # MediaPlayer2.Player types: # Track_Id: DBus ID of track # Plaback_Rate: Multiplier for playback speed (1 = normal speed) diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 8838442..0000000 --- a/requirements.txt +++ /dev/null @@ -1,20 +0,0 @@ -alabaster==0.7.10 -Babel==2.4.0 -coverage==4.3.4 -decorator==4.0.11 -docutils==0.13.1 -Jinja2==2.9.6 -MarkupSafe==1.0 -mock==2.0.0 -nose==1.3.7 -parameterized==0.6.1 -pockets==0.3.2 -Pygments==2.2.0 -pytz==2017.2 -six==1.10.0 -snowballstemmer==1.2.1 -Sphinx==1.5.5 -sphinx-rtd-theme==0.2.4 -sphinxcontrib-napoleon==0.6.1 -evento==1.0.1 -tox==2.5.0 diff --git a/setup.py b/setup.py old mode 100755 new mode 100644 index cfef39a..e6dbc16 --- a/setup.py +++ b/setup.py @@ -1,19 +1,50 @@ #!/usr/bin/env python2 from setuptools import setup, find_packages +import os.path + +here = os.path.dirname(os.path.abspath(__file__)) +about = {} +with open(os.path.join(here, 'omxplayer', '__version__.py'), 'r') as f: + exec(f.read(), about) + +with open(os.path.join(here, 'README.rst'), 'r') as f: + long_description = f.read() + +lib_deps = [ + 'dbus-python', + 'evento', + 'decorator', + 'pathlib2', +], + +test_deps = [ + 'mock', + 'pytest', + 'pytest-cov', + 'nose', + 'parameterized', +] + +doc_deps = [ + 'Sphinx', + 'alabaster', + 'pygments', +] + setup( name='omxplayer-wrapper', - author='Will Price', - author_email='will.price94+dev@gmail.com', + author=about['__author__'], + author_email=about['__author_email__'], url='https://github.com/willprice/python-omxplayer-wrapper', - version='0.2.3', + version=about['__version__'], - description='Control OMXPlayer on the Raspberry Pi', - long_description='Control OMXPlayer on the Raspberry Pi through DBus', + description=about['__description__'], + long_description=long_description, - license='LGPLv3+', + license=about['__license__'], classifiers=[ 'Development Status :: 4 - Beta', 'Intended Audience :: Developers', @@ -25,28 +56,22 @@ 'Programming Language :: Python :: 3.4', 'Programming Language :: Python :: 3.5', 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', ], - keywords='omxplayer pi raspberry raspberrypi raspberry_pi library video media', + keywords=' '.join(['omxplayer', + 'pi', + 'raspberry', + 'raspberrypi', + 'raspberry_pi', + 'library', + 'video', + 'media']), - packages=find_packages(exclude=['*tests']), - # Depends on dbus-python which is only shipped via package managers or as a - # source dist (incompatible with distutils - install_requires=[ - 'decorator', - 'evento' - ], + packages=find_packages(exclude=['*test*']), + install_requires=lib_deps, extras_require={ - 'test': [ - 'mock', - 'nose', - 'parameterized', - ], - 'docs': [ - 'Sphinx', - 'sphinxcontrib-napoleon', - 'sphinx-rtd-theme', - 'pygments', - ] + 'test': test_deps, + 'docs': doc_deps } ) diff --git a/smoke_tests/__init__.py b/smoke_tests/__init__.py deleted file mode 100644 index 0e0cc59..0000000 --- a/smoke_tests/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -# -*- coding: utf-8 -*- -import os.path -import logging - -logging.basicConfig(level=logging.DEBUG) - - -_file_path = os.path.realpath(__file__) -_media_path = os.path.dirname(_file_path) + "/media" - -TEST_MEDIA_FILE_1 = _media_path + "/test_media_1.mp4" -TEST_MEDIA_FILE_2 = _media_path + "/test_media_2.mp4" -TEST_STREAM_FILE_1 = "rtsp://wowzaec2demo.streamlock.net/vod/mp4:BigBuckBunny_115k.mov" diff --git a/smoke_tests/test.py b/smoke_tests/test.py deleted file mode 100755 index 2d03e04..0000000 --- a/smoke_tests/test.py +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env python2 -from omxplayer import OMXPlayer -from time import sleep -from smoke_tests import TEST_MEDIA_FILE_1 - - -print(TEST_MEDIA_FILE_1) -vid1 = OMXPlayer(TEST_MEDIA_FILE_1, pause=True) -print("Start playing") -vid1.set_position(5) -vid1.play() -sleep(2) -print("Stop playing") -vid1.pause() -sleep(2) -print("Exit") -vid1.quit() diff --git a/smoke_tests/test2.py b/smoke_tests/test2.py deleted file mode 100755 index 76bf80d..0000000 --- a/smoke_tests/test2.py +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env python2 -import os.path -from time import sleep -from omxplayer import OMXPlayer -from smoke_tests import TEST_MEDIA_FILE_1, TEST_MEDIA_FILE_2 - -vid1 = OMXPlayer(TEST_MEDIA_FILE_1) -print("Start playing vid1") -sleep(5) - -print("Stop playing vid1") -vid1.pause() -sleep(2) - -print("Exit vid1") -vid1.quit() -sleep(1) - - -vid2 = OMXPlayer(TEST_MEDIA_FILE_2) -print("Start playing vid2") -sleep(2) - -print("Stop playing vid2") -vid2.pause() -sleep(2) - -print("Exit vid2") -vid2.quit() diff --git a/smoke_tests/test_load.py b/smoke_tests/test_load.py deleted file mode 100755 index 135440f..0000000 --- a/smoke_tests/test_load.py +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/bin/env python2 -import os.path -from time import sleep -from omxplayer import OMXPlayer -from smoke_tests import TEST_MEDIA_FILE_1, TEST_MEDIA_FILE_2 - -player = OMXPlayer(TEST_MEDIA_FILE_1, pause=True) -print("Start playing vid1") -player.play() -sleep(2) -print("Stop playing vid1") -player.pause() -sleep(2) - - -player.load(TEST_MEDIA_FILE_2, pause=True) -print("Start playing vid2") -player.play() -sleep(2) -print("Stop playing vid2") -player.pause() -sleep(2) - -print("Exit") -player.quit() -sleep(1) diff --git a/smoke_tests/test_stream.py b/smoke_tests/test_stream.py deleted file mode 100755 index 8642903..0000000 --- a/smoke_tests/test_stream.py +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env python2 -from omxplayer import OMXPlayer -from time import sleep -from smoke_tests import TEST_STREAM_FILE_1 - -print("Streaming " + TEST_STREAM_FILE_1) -print("Starting OMXPlayer") -vid1 = OMXPlayer(TEST_STREAM_FILE_1) -for i in range(0, 10): - print("sleeping %s" % i) - sleep(1) -print("Pause for 2 seconds") -vid1.pause() -sleep(2) -print("Exiting") -vid1.quit() diff --git a/tests/context.py b/tests/context.py new file mode 100644 index 0000000..d0fe478 --- /dev/null +++ b/tests/context.py @@ -0,0 +1,6 @@ +import sys +import os + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +import omxplayer diff --git a/tests/integration/README.md b/tests/integration/README.md new file mode 100644 index 0000000..0f7c1ef --- /dev/null +++ b/tests/integration/README.md @@ -0,0 +1,20 @@ +# Smoke Tests + +These are tests (also function very well as example usage) to test out the behaviour +of the wrapper. They are executed and verified manually[1]. + +## Tests + +* **Basic**, run a video from start to finish, tests the lifecycle of the + `OMXPlayer` object ensuring it can construct and manage the dbus session for + duration of a single video +* **Multiple Videos**, run two videos consecutively using + * Two `OMXPlayer` objects + * One `OMXPlayer` instance and `load` +* **Multiple Videos** concurrently split screen +* **Remote stream** + * Single video + * Multiple videos + +[1]: Unless anyone else has a better idea for integration tests for verifying + the correctness of the library? diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/test.py b/tests/integration/test.py new file mode 100644 index 0000000..287b6d5 --- /dev/null +++ b/tests/integration/test.py @@ -0,0 +1,271 @@ +#!/usr/bin/env python +from pathlib import Path +from time import sleep +import os +import dbus + +import unittest + +from mock import Mock + +from omxplayer import OMXPlayer, keys + +# Decimal places for numerical comparison +_TIME_DP=0 +_RATE_DP=2 +_VOLUME_DP=3 + +MEDIA_ROOT = Path(os.path.abspath(os.path.dirname(__file__))) / '../media/' +MEDIA_FILE_PATH = MEDIA_ROOT / 'test_media_1.mp4' +MEDIA_2_SECOND_FILE_PATH = MEDIA_ROOT / 'test_media_2_second.mp4' + + +class OMXPlayerTest(unittest.TestCase): + def setUp(self): + self.player = OMXPlayer(MEDIA_FILE_PATH) + sleep(1) # Give the player time to start up + + def tearDown(self): + self.player.quit() + + +class OMXPlayerSetupTests(unittest.TestCase): + + def test_args_list_constructor(self): + player = OMXPlayer(MEDIA_FILE_PATH, args=['--layer', '2', '--orientation', '90']) + sleep(1) + player.quit() + + def test_args_str_constructor(self): + player = OMXPlayer(MEDIA_FILE_PATH, args='--layer 2 --orientation 90') + sleep(1) + player.quit() + + def test_str_media_file_path(self): + player = OMXPlayer(str(MEDIA_FILE_PATH)) + sleep(1) + player.quit() + + def test_load(self): + player = OMXPlayer(MEDIA_FILE_PATH) + sleep(1) + player.load(MEDIA_FILE_PATH) + sleep(1) + player.quit() + + def test_exit_event_on_quit(self): + player = OMXPlayer(MEDIA_FILE_PATH) + exit_fn = Mock() + player.exitEvent += exit_fn + sleep(1) + + player.quit() + + exit_fn.assert_called_once_with(player, -15) + + def test_exit_event_on_video_end(self): + player = OMXPlayer(MEDIA_2_SECOND_FILE_PATH) + exit_fn = Mock() + player.exitEvent += exit_fn + + sleep(3) + + exit_fn.assert_called_once_with(player, 0) + + +class OMXPlayerRootInterfacePropertiesTest(OMXPlayerTest): + def test_can_quit(self): + self.assertTrue(self.player.can_quit()) + + def test_fullscreen(self): + self.assertTrue(self.player.fullscreen()) + + def test_can_set_fullscreen(self): + self.assertFalse(self.player.can_set_fullscreen()) # can't set fullscreen because on start it already is + + def test_can_raise(self): + self.assertFalse(self.player.can_raise()) + + def test_has_track_list(self): + self.assertFalse(self.player.has_track_list()) + + def test_identity(self): + self.assertEqual("OMXPlayer", self.player.identity()) + + def test_supported_uri_schemes(self): + self.assertEqual(["file", "http", "rtsp", "rtmp"], self.player.supported_uri_schemes()) + + +class OMXPlayerPlayerInterfacePropertiesTest(OMXPlayerTest): + def test_can_go_next(self): + self.assertFalse(self.player.can_go_next()) + + def test_can_go_previous(self): + self.assertFalse(self.player.can_go_previous()) + + def test_can_seek(self): + self.assertTrue(self.player.can_seek()) + + def test_can_control(self): + self.assertTrue(self.player.can_control()) + + def test_can_play(self): + self.assertTrue(self.player.can_play()) + + def test_can_pause(self): + self.assertTrue(self.player.can_pause()) + + def test_playback_status(self): + self.player.pause() + self.assertEqual("Paused", self.player.playback_status()) + + def test_volume(self): + self.assertEqual(0.0, self.player.volume(), _VOLUME_DP) + + def test_set_volume(self): + self.assertAlmostEqual(0.0, self.player.volume(), _VOLUME_DP) + self.player.set_volume(1) + self.assertAlmostEqual(1.0, self.player.volume(), _VOLUME_DP) + + def test_position(self): + self.assertTrue(self.player.position() < 1.0) + + def test_minimum_rate(self): + self.assertAlmostEqual(0.001, self.player.minimum_rate(), _RATE_DP) + + def test_maximum_rate(self): + self.assertAlmostEqual(4.0, self.player.maximum_rate(), _RATE_DP) + + def test_rate(self): + self.assertAlmostEqual(1.0, self.player.rate(), _RATE_DP) + + def test_set_rate(self): + self.assertAlmostEqual(1.0, self.player.rate(), _RATE_DP) + new_rate = 0.5 + + self.player.set_rate(new_rate) + sleep(0.2) + + self.assertAlmostEqual(new_rate, self.player.rate(), _RATE_DP) + + def test_metadata(self): + expectedMetadata = { + 'mpris:length': 19691000, + 'xesam:url': 'file://' + os.path.abspath(MEDIA_FILE_PATH) + } + self.assertEqual(expectedMetadata, self.player.metadata()) + + +class OMXPlayerPlayerNonStandardPropertiesTest(OMXPlayerTest): + def test_aspect(self): + self.assertEqual(0, self.player.aspect_ratio()) + + def test_video_stream_count(self): + self.assertEqual(1, self.player.video_stream_count()) + + def test_video_width(self): + self.assertEqual(1280, self.player.width()) + + def test_video_height(self): + self.assertEqual(720, self.player.height()) + + def test_duration(self): + self.assertAlmostEqual(19.691, self.player.duration(), _TIME_DP) + + +class OMXPlayerPlayerInterfaceMethodsTest(OMXPlayerTest): + def test_get_source(self): + self.assertEqual(self.MEDIA_FILE_PATH, self.player.get_source()) + + def test_next(self): + self.player.next() + + def test_previous(self): + self.player.previous() + + def test_playing_on_start(self): + sleep(1) # it takes a while for omxplayer to load and start playing + # before starting video playback on startup it will be in the + # "Paused" state + self.assertEqual("Playing", self.player.playback_status()) + + def test_play_pause(self): + self.player.play_pause() + self.assertEqual("Paused", self.player.playback_status()) + + self.player.play_pause() + self.assertEqual("Playing", self.player.playback_status()) + + def test_pause(self): + self.player.pause() + self.assertEqual("Paused", self.player.playback_status()) + + def test_stop(self): + self.player.stop() + self.assertRaises(dbus.DBusException, self.player.playback_status) + + def test_seek(self): + initial_position = self.player.position() + self.player.seek(10) + self.assertAlmostEqual(initial_position + 10, self.player.position(), _TIME_DP) + + def test_set_position(self): + self.player.set_position(10) + self.assertAlmostEqual(10, self.player.position(), _TIME_DP) + + def test_set_alpha(self): + self.player.set_alpha(255) + + def test_set_aspect_mode(self): + self.player.set_aspect_mode("stretch") + + def test_mute(self): + self.player.set_volume(1) + + self.player.mute() + + self.assertAlmostEqual(0, self.player.volume(), _VOLUME_DP) + + def test_unmute(self): + self.player.set_volume(1) + self.player.mute() + self.assertAlmostEqual(0, self.player.volume(), _VOLUME_DP) + + self.player.unmute() + self.assertAlmostEqual(1, self.player.volume(), _VOLUME_DP) + + def test_list_subtitles(self): + self.assertEqual([], self.player.list_subtitles()) + + def test_set_video_crop(self): + self.player.set_video_crop(0, 0, 100, 100) + + def test_hide_video(self): + self.player.hide_video() + + def test_show_video(self): + self.player.show_video() + + def test_list_audio(self): + self.assertEqual(1, len(self.player.list_audio())) + + def test_list_video(self): + self.assertEqual(1, len(self.player.list_video())) + + def test_select_subtitle(self): + self.player.select_subtitle(0) + + def test_select_audio(self): + self.player.select_audio(0) + + def test_show_subtitles(self): + self.player.show_subtitles() + + def test_hide_subtitles(self): + self.player.hide_subtitles() + + def test_action(self): + self.player.action(keys.SHOW_INFO) + +class OMXPlayerPlayerInterfaceNonStandardMethodsTest(OMXPlayerTest): + pass diff --git a/smoke_tests/media/test_media_1.mp4 b/tests/media/test_media_1.mp4 similarity index 100% rename from smoke_tests/media/test_media_1.mp4 rename to tests/media/test_media_1.mp4 diff --git a/tests/media/test_media_1_second.mp4 b/tests/media/test_media_1_second.mp4 new file mode 100644 index 0000000..f71573e Binary files /dev/null and b/tests/media/test_media_1_second.mp4 differ diff --git a/smoke_tests/media/test_media_2.mp4 b/tests/media/test_media_2.mp4 similarity index 100% rename from smoke_tests/media/test_media_2.mp4 rename to tests/media/test_media_2.mp4 diff --git a/tests/media/test_media_2_second.mp4 b/tests/media/test_media_2_second.mp4 new file mode 100644 index 0000000..f71573e Binary files /dev/null and b/tests/media/test_media_2_second.mp4 differ diff --git a/tests/test_bus_finder.py b/tests/unit/test_bus_finder.py similarity index 97% rename from tests/test_bus_finder.py rename to tests/unit/test_bus_finder.py index 2e5c701..0ae6093 100644 --- a/tests/test_bus_finder.py +++ b/tests/unit/test_bus_finder.py @@ -12,13 +12,12 @@ builtin = 'builtins' from omxplayer.bus_finder import BusFinder -#### CONSTANTS #### +# CONSTANTS EXAMPLE_DBUS_FILE_CONTENTS = 'EXAMPLE_CONTENTS' MOCK_OPEN = mock_open(read_data=EXAMPLE_DBUS_FILE_CONTENTS) - -#### CLASSES #### +# CLASSES class BusFinderTests(unittest.TestCase): dbus_file_path = '/tmp/omxplayerdbus' diff --git a/tests/test_dbus_connection.py b/tests/unit/test_dbus_connection.py similarity index 100% rename from tests/test_dbus_connection.py rename to tests/unit/test_dbus_connection.py diff --git a/tests/test_omxplayer.py b/tests/unit/test_omxplayer.py similarity index 60% rename from tests/test_omxplayer.py rename to tests/unit/test_omxplayer.py index ecd6219..97aaa69 100644 --- a/tests/test_omxplayer.py +++ b/tests/unit/test_omxplayer.py @@ -16,10 +16,10 @@ builtin = 'builtins' - MOCK_OPEN = mock_open() +@patch('atexit.register') @patch('{}.open'.format(builtin), MOCK_OPEN) @patch('os.killpg') @patch('os.path.isfile') @@ -46,53 +46,89 @@ def test_tries_to_open_dbus_again_if_it_cant_connect(self, *args): self.patch_and_run_omxplayer(Connection=dbus_connection) self.assertEqual(50, self.player.tries) + def test_dbus_failure_kills(self, popen, sleep, isfile, killpg, *args): + omxplayer_process = Mock() + popen.return_value = omxplayer_process + dbus_connection = Mock(side_effect=DBusConnectionError) + with patch('os.getpgid', Mock(return_value=omxplayer_process.pid)): + with self.assertRaises(SystemError): + self.patch_and_run_omxplayer(Connection=dbus_connection) + killpg.assert_called_once_with(omxplayer_process.pid, signal.SIGTERM) + + def test_thread_failure_kills(self, popen, sleep, isfile, killpg, *args): + omxplayer_process = Mock() + popen.return_value = omxplayer_process + with patch ('threading.Thread', Mock(side_effect=RuntimeError)): + with patch('os.getpgid', Mock(return_value=omxplayer_process.pid)): + with self.assertRaises(RuntimeError): + self.patch_and_run_omxplayer() + killpg.assert_called_once_with(omxplayer_process.pid, signal.SIGTERM) + @parameterized.expand([ ['can_quit', 'CanQuit', [], []], ['can_set_fullscreen', 'CanSetFullscreen', [], []], ['identity', 'Identity', [], []] ]) - def test_root_interface_commands(self, popen, sleep, isfile, killpg, command_name, - interface_command_name, *args): + def test_root_interface_properties(self, popen, sleep, isfile, killpg, atexit, command_name, + property_name, command_args, expected_dbus_call_args): self.patch_and_run_omxplayer() - self.patch_interface_and_run_command('_get_root_interface', - command_name, - interface_command_name, *args) + self.player._root_interface.dbus_interface = "org.mpris.MediaPlayer2" + interface = self.player._properties_interface + interface.reset_mock() + + self.patch_interface_and_run_command(command_name, command_args) + + expected_call = call.Get("org.mpris.MediaPlayer2", property_name, *expected_dbus_call_args) + interface.assert_has_calls([expected_call]) @parameterized.expand([ ['pause', 'Pause', [], []], ['stop', 'Stop', [], []], - ['seek', 'Seek', [100], [100]], - ['set_position', 'SetPosition', [1], [dbus.ObjectPath("/not/used"), - dbus.Int64(1000000)]], + ['seek', 'Seek', [100], [dbus.Int64(100 * 1e6)]], + ['set_position', 'SetPosition', [1], [dbus.ObjectPath("/not/used"), dbus.Int64(1000000)]], + ['set_layer', 'SetLayer', [1], [dbus.Int64(1)]], ['list_subtitles', 'ListSubtitles', [], []], + ['mute', 'Mute', [], []], + ['unmute', 'Unmute', [], []], ['action', 'Action', ['p'], ['p']] ]) - def test_player_interface_commands(self, popen, sleep, isfile, killpg, command_name, - interface_command_name, *args): + def test_player_interface_commands(self, popen, sleep, isfile, killpg, atexit, command_name, + interface_command_name, command_args, expected_dbus_call_args): self.patch_and_run_omxplayer() - self.patch_interface_and_run_command('_get_player_interface', - command_name, - interface_command_name, *args) + self.player._player_interface.dbus_interface = "org.mpris.MediaPlayer2" + interface = self.player._player_interface + interface.reset_mock() + + self.patch_interface_and_run_command(command_name, command_args) + + expected_call = getattr(call, interface_command_name)(*expected_dbus_call_args) + interface.assert_has_calls([expected_call]) @parameterized.expand([ - ['can_play', 'CanPlay', [], []], - ['can_seek', 'CanSeek', [], []], - ['can_control', 'CanControl', [], []], - ['playback_status', 'PlaybackStatus', [], []], - ['volume', 'Volume', [], []], - ['mute', 'Mute', [], []], - ['unmute', 'Unmute', [], []], - ['position', 'Position', [], []], - ['duration', 'Duration', [], []], - ['minimum_rate', 'MinimumRate', [], []], - ['maximum_rate', 'MaximumRate', [], []], + ['can_play', 'CanPlay', True, dbus.Boolean(True)], + ['can_seek', 'CanSeek', False, dbus.Boolean(False)], + ['can_control', 'CanControl', True, dbus.Boolean(True)], + ['playback_status', 'PlaybackStatus', "playing", dbus.String("playing")], + ['position', 'Position', 1.2, dbus.Int64(1.2 * 1000 * 1000)], + ['duration', 'Duration', 10.1, dbus.Int64(10.1 * 1000 * 1000)], + ['volume', 'Volume', 10, dbus.Int64(10)], + ['minimum_rate', 'MinimumRate', 0.1, dbus.Double(0.1)], + ['maximum_rate', 'MaximumRate', 4.0, dbus.Double(4.0)], ]) - def test_properties_interface_commands(self, popen, sleep, isfile, killpg, command_name, - interface_command_name, *args): + def test_player_interface_properties(self, popen, sleep, isfile, killpg, atexit, + command_name, property_name, expected_result, property_result): + interface_address = "org.mpris.MediaPlayer2" self.patch_and_run_omxplayer() - self.patch_interface_and_run_command('_get_properties_interface', - command_name, - interface_command_name, *args) + self.player._root_interface.dbus_interface = interface_address + interface = self.player._properties_interface + interface.reset_mock() + mock = interface.Get + mock.configure_mock(return_value=property_result) + + result = self.patch_interface_and_run_command(command_name, []) + + interface.assert_has_calls([(call.Get(interface_address, property_name))]) + self.assertEqual(expected_result, result) def test_quitting(self, popen, sleep, isfile, killpg, *args): omxplayer_process = Mock() @@ -102,7 +138,7 @@ def test_quitting(self, popen, sleep, isfile, killpg, *args): self.player.quit() killpg.assert_called_once_with(omxplayer_process.pid, signal.SIGTERM) - def test_quitting_waits_for_omxplayer_to_die(self, popen, sleep, isfile, killpg, *args): + def test_quitting_waits_for_omxplayer_to_die(self, popen, *args): omxplayer_process = Mock() popen.return_value = omxplayer_process self.patch_and_run_omxplayer() @@ -110,6 +146,27 @@ def test_quitting_waits_for_omxplayer_to_die(self, popen, sleep, isfile, killpg, self.player.quit() omxplayer_process.wait.assert_has_calls([call()]) + def test_quitting_when_already_dead(self, popen, sleep, isfile, killpg, *args): + omxplayer_process = Mock() + popen.return_value = omxplayer_process + self.patch_and_run_omxplayer() + # Pretend the process is already dead + killpg.configure_mock(side_effect=OSError) + with patch('os.getpgid', Mock(return_value=omxplayer_process.pid)): + # This tests that quit handles the OSError + self.player.quit() + killpg.assert_called_once_with(omxplayer_process.pid, signal.SIGTERM) + + def test_quitting_twice(self, popen, sleep, isfile, killpg, *args): + omxplayer_process = Mock() + popen.return_value = omxplayer_process + self.patch_and_run_omxplayer() + with patch('os.getpgid', Mock(return_value=omxplayer_process.pid)): + # This should not raise, and call killpg once + self.player.quit() + self.player.quit() + killpg.assert_called_once_with(omxplayer_process.pid, signal.SIGTERM) + def test_check_process_still_exists_before_dbus_call(self, *args): self.patch_and_run_omxplayer() self.player._process = process = Mock(return_value=None) @@ -119,16 +176,6 @@ def test_check_process_still_exists_before_dbus_call(self, *args): process.poll.assert_called_once_with() - def test_checks_media_file_exists_before_launching_player(self, *args): - with patch('os.path') as ospath: - self.patch_and_run_omxplayer() - ospath.isfile.assert_called_once_with(self.TEST_FILE_NAME) - - def test_player_doesnt_check_source_path_exists_for_a_url(self, *args): - with patch('os.path') as ospath: - self.patch_and_run_omxplayer_url() - ospath.isfile.assert_not_called() - def test_stop_event(self, *args): self.patch_and_run_omxplayer(active=True) callback = Mock() @@ -202,20 +249,10 @@ def test_position_event(self, *args): callback.assert_called_once_with(self.player, 5.01) - def patch_interface_and_run_command(self, interface_name, - command_name, interface_command_name, - command_args, - expected_args): + def patch_interface_and_run_command(self, command_name, command_args): self.player._process.poll = Mock(return_value=None) - with patch.object(self.player, interface_name) as interface: - self.run_command(command_name, *command_args) - # generates a call of the form `call().CanQuit` - expected_call = getattr(call(), interface_command_name)(*expected_args) - interface.assert_has_calls([expected_call]) - - def run_command(self, command_name, *args): - command = getattr(self.player, command_name) - command(*args) + result = getattr(self.player, command_name)(*command_args) + return result # Must have the prefix 'patch' for the decorators to take effect def patch_and_run_omxplayer(self, Connection=Mock(), active=False): @@ -257,14 +294,13 @@ def test_load(self, popen, sleep, isfile, killpg, *args): # verify a new process was started for the second time self.assertEqual(popen.call_count, 2) - - def test_init_without_pause(self, popen, sleep, isfile, killpg, *args): - with patch.object(OMXPlayer, 'pause', return_value=None) as mock_method: + def test_init_without_pause(self, *args): + with patch.object(OMXPlayer, 'pause', return_value=None) as pause_method: self.patch_and_run_omxplayer() - self.assertEqual(mock_method.call_count, 0) + self.assertEqual(pause_method.call_count, 0) - def test_init_pause(self, popen, sleep, isfile, killpg, *args): - with patch.object(OMXPlayer, 'pause', return_value=None) as mock_method: + def test_init_pause(self, *args): + with patch.object(OMXPlayer, 'pause', return_value=None) as pause_method: # self.patch_and_run_omxplayer(pause=False) bus_address_finder = Mock() bus_address_finder.get_address.return_val = "example_bus_address" @@ -273,16 +309,20 @@ def test_init_pause(self, popen, sleep, isfile, killpg, *args): Connection=Mock(), pause=True) - self.assertEqual(mock_method.call_count, 1) + self.assertEqual(pause_method.call_count, 1) - def test_load_and_pause(self, popen, sleep, isfile, killpg, *args): - with patch.object(OMXPlayer, 'pause', return_value=None) as mock_method: + def test_load_and_pause(self, *args): + with patch.object(OMXPlayer, 'pause', return_value=None) as pause_method: self.patch_and_run_omxplayer() self.player.load('./test2.mp4', pause=True) - self.assertEqual(mock_method.call_count, 1) + self.assertEqual(pause_method.call_count, 1) - def test_load_without_pause(self, popen, sleep, isfile, killpg, *args): - with patch.object(OMXPlayer, 'pause', return_value=None) as mock_method: + def test_load_without_pause(self, *args): + with patch.object(OMXPlayer, 'pause', return_value=None) as pause_method: self.patch_and_run_omxplayer() self.player.load('./test2.mp4') - self.assertEqual(mock_method.call_count, 0) + self.assertEqual(pause_method.call_count, 0) + + def test_register_quit_handler_atexit(self, popen, sleep, isfile, killpg, atexit): + self.patch_and_run_omxplayer() + atexit.assert_called_once_with(self.player.quit) diff --git a/tox.ini b/tox.ini index da8977f..e0bbeb1 100644 --- a/tox.ini +++ b/tox.ini @@ -1,13 +1,11 @@ [tox] -envlist = py27,py36 +envlist = py27, py35, py36, py37 [testenv] -whitelist_externals: nosetests -deps = -rrequirements.txt -commands = nosetests --with-coverage \ - --cover-erase \ - --cover-xml \ - --cover-branches \ - --cover-package=omxplayer -# We need sitepackages for python-dbus as it isn't distributed on PyPI -sitepackages = True +# Necessary for tox and pipenv to play nicely together +# https://github.com/kennethreitz/pipenv/issues/256 +passenv=HOME +deps = .[test] +commands = pytest tests/unit \ + --cov-branch \ + --cov={envsitepackagesdir}/omxplayer