├── .github └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── .mailmap ├── LICENSE ├── MANIFEST.in ├── README.rst ├── mopidy_mpris ├── __init__.py ├── ext.conf ├── frontend.py ├── interface.py ├── player.py ├── playlists.py ├── root.py └── server.py ├── pyproject.toml ├── setup.cfg ├── setup.py ├── tests ├── __init__.py ├── conftest.py ├── dummy_audio.py ├── dummy_backend.py ├── dummy_mixer.py ├── test_events.py ├── test_extension.py ├── test_player.py ├── test_playlists.py └── test_root.py └── tox.ini /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | main: 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | include: 15 | - name: "Test: Python 3.9" 16 | python: "3.9" 17 | tox: py39 18 | - name: "Test: Python 3.10" 19 | python: "3.10" 20 | tox: py310 21 | - name: "Test: Python 3.11" 22 | python: "3.11" 23 | tox: py311 24 | coverage: true 25 | - name: "Lint: check-manifest" 26 | python: "3.11" 27 | tox: check-manifest 28 | - name: "Lint: flake8" 29 | python: "3.11" 30 | tox: flake8 31 | 32 | name: ${{ matrix.name }} 33 | runs-on: ubuntu-22.04 34 | container: ghcr.io/mopidy/ci:latest 35 | 36 | steps: 37 | - uses: actions/checkout@v3 38 | - name: Fix home dir permissions to enable pip caching 39 | run: chown -R root /github/home 40 | - uses: actions/setup-python@v4 41 | with: 42 | python-version: ${{ matrix.python }} 43 | cache: pip 44 | cache-dependency-path: setup.cfg 45 | - run: python -m pip install pygobject tox 46 | - run: python -m tox -e ${{ matrix.tox }} 47 | if: ${{ ! matrix.coverage }} 48 | - run: python -m tox -e ${{ matrix.tox }} -- --cov-report=xml 49 | if: ${{ matrix.coverage }} 50 | - uses: codecov/codecov-action@v3 51 | if: ${{ matrix.coverage }} 52 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | release: 9 | runs-on: ubuntu-20.04 10 | 11 | steps: 12 | - uses: actions/checkout@v2 13 | - uses: actions/setup-python@v2 14 | with: 15 | python-version: '3.10' 16 | - name: "Install dependencies" 17 | run: python3 -m pip install build 18 | - name: "Build package" 19 | run: python3 -m build 20 | - uses: pypa/gh-action-pypi-publish@v1.4.1 21 | with: 22 | user: __token__ 23 | password: ${{ secrets.PYPI_TOKEN }} 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | /.coverage 3 | /.mypy_cache/ 4 | /.pytest_cache/ 5 | /.tox/ 6 | /*.egg-info 7 | /build/ 8 | /dist/ 9 | /MANIFEST 10 | -------------------------------------------------------------------------------- /.mailmap: -------------------------------------------------------------------------------- 1 | Tobias Laundal 2 | Marcin Klimczak 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.py 2 | include *.rst 3 | include .mailmap 4 | include LICENSE 5 | include MANIFEST.in 6 | include pyproject.toml 7 | include tox.ini 8 | 9 | recursive-include .github * 10 | 11 | include mopidy_*/ext.conf 12 | 13 | recursive-include tests *.py 14 | recursive-include tests/data * 15 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ************ 2 | Mopidy-MPRIS 3 | ************ 4 | 5 | .. image:: https://img.shields.io/pypi/v/Mopidy-MPRIS 6 | :target: https://pypi.org/project/Mopidy-MPRIS/ 7 | :alt: Latest PyPI version 8 | 9 | .. image:: https://img.shields.io/github/actions/workflow/status/mopidy/mopidy-mpris/ci.yml?branch=main 10 | :target: https://github.com/mopidy/mopidy-mpris/actions 11 | :alt: CI build status 12 | 13 | .. image:: https://img.shields.io/codecov/c/gh/mopidy/mopidy-mpris 14 | :target: https://codecov.io/gh/mopidy/mopidy-mpris 15 | :alt: Test coverage 16 | 17 | `Mopidy`_ extension for controlling Mopidy through D-Bus using the `MPRIS 18 | specification`_. 19 | 20 | Mopidy-MPRIS supports the minimum requirements of the `MPRIS specification`_ 21 | as well as the optional `Playlists interface`_. The `TrackList interface`_ 22 | is currently not supported. 23 | 24 | .. _Mopidy: https://www.mopidy.com/ 25 | .. _MPRIS specification: https://specifications.freedesktop.org/mpris-spec/latest/ 26 | .. _Playlists interface: https://specifications.freedesktop.org/mpris-spec/latest/Playlists_Interface.html 27 | .. _TrackList interface: https://specifications.freedesktop.org/mpris-spec/latest/Track_List_Interface.html 28 | 29 | 30 | Maintainer wanted 31 | ================= 32 | 33 | Mopidy-MPRIS is currently kept on life support by the Mopidy core developers. 34 | It is in need of a more dedicated maintainer. 35 | 36 | If you want to be the maintainer of Mopidy-MPRIS, please: 37 | 38 | 1. Make 2-3 good pull requests improving any part of the project. 39 | 40 | 2. Read and get familiar with all of the project's open issues. 41 | 42 | 3. Send a pull request removing this section and adding yourself as the 43 | "Current maintainer" in the "Credits" section below. In the pull request 44 | description, please refer to the previous pull requests and state that 45 | you've familiarized yourself with the open issues. 46 | 47 | As a maintainer, you'll be given push access to the repo and the authority 48 | to make releases to PyPI when you see fit. 49 | 50 | 51 | Table of contents 52 | ================= 53 | 54 | - Requirements_ 55 | - Installation_ 56 | - Configuration_ 57 | - Usage_ 58 | - Clients_ 59 | 60 | - `GNOME Shell builtin`_ 61 | - `gnome-shell-extensions-mediaplayer`_ 62 | - `gnome-shell-extensions-mpris-indicator-button`_ 63 | - `Ubuntu Sound Menu`_ 64 | 65 | - `Advanced setups`_ 66 | 67 | - `Running as a service`_ 68 | - `MPRIS on the system bus`_ 69 | - `UPnP/DLNA with Rygel`_ 70 | 71 | - `Development tips`_ 72 | 73 | - `Browsing the MPRIS API with D-Feet`_ 74 | - `Testing the MPRIS API with pydbus`_ 75 | 76 | - `Project resources`_ 77 | - Credits_ 78 | 79 | 80 | Requirements 81 | ============ 82 | 83 | - `pydbus`_ D-Bus Python bindings, which again depends on ``python-gi``. Thus 84 | it is usually easiest to install with your distribution's package manager. 85 | 86 | .. _pydbus: https://github.com/LEW21/pydbus 87 | 88 | 89 | Installation 90 | ============ 91 | 92 | Install by running:: 93 | 94 | sudo python3 -m pip install Mopidy-MPRIS 95 | 96 | See https://mopidy.com/ext/mpris/ for alternative installation methods. 97 | 98 | 99 | Configuration 100 | ============= 101 | 102 | No configuration is required for the MPRIS extension to work. 103 | 104 | The following configuration values are available: 105 | 106 | - ``mpris/enabled``: If the MPRIS extension should be enabled or not. 107 | Defaults to ``true``. 108 | 109 | - ``mpris/bus_type``: The type of D-Bus bus Mopidy-MPRIS should connect to. 110 | Choices include ``session`` (the default) and ``system``. 111 | 112 | 113 | Usage 114 | ===== 115 | 116 | Once Mopidy-MPRIS has been installed and your Mopidy server has been 117 | restarted, the Mopidy-MPRIS extension announces its presence on D-Bus so that 118 | any MPRIS compatible clients on your system can interact with it. Exactly how 119 | you control Mopidy through MPRIS depends on which MPRIS client you use. 120 | 121 | 122 | Clients 123 | ======= 124 | 125 | The following clients have been tested with Mopidy-MPRIS. 126 | 127 | GNOME Shell builtin 128 | ------------------- 129 | 130 | State: 131 | Not working 132 | Tested versions: 133 | Ubuntu 18.10, 134 | GNOME Shell 3.30.1-2ubuntu1, 135 | Mopidy-MPRIS 2.0.0 136 | 137 | GNOME Shell, which is the default desktop on Ubuntu 18.04 onwards, has a 138 | builtin MPRIS client. This client seems to work well with Spotify's player, 139 | but Mopidy-MPRIS does not show up here. 140 | 141 | If you have any tips on what's missing to get this working, please open an 142 | issue. 143 | 144 | gnome-shell-extensions-mediaplayer 145 | ---------------------------------- 146 | 147 | State: 148 | Working 149 | Tested versions: 150 | Ubuntu 18.10, 151 | GNOME Shell 3.30.1-2ubuntu1, 152 | gnome-shell-extension-mediaplayer 63, 153 | Mopidy-MPRIS 2.0.0 154 | Website: 155 | https://github.com/JasonLG1979/gnome-shell-extensions-mediaplayer 156 | 157 | gnome-shell-extensions-mediaplayer is a quite feature rich MPRIS client 158 | built as an extension to GNOME Shell. With the improvements to Mopidy-MPRIS 159 | in v2.0, this extension works very well with Mopidy. 160 | 161 | gnome-shell-extensions-mpris-indicator-button 162 | --------------------------------------------- 163 | 164 | State: 165 | Working 166 | Tested versions: 167 | Ubuntu 18.10, 168 | GNOME Shell 3.30.1-2ubuntu1, 169 | gnome-shell-extensions-mpris-indicator-button 5, 170 | Mopidy-MPRIS 2.0.0 171 | Website: 172 | https://github.com/JasonLG1979/gnome-shell-extensions-mpris-indicator-button/ 173 | 174 | gnome-shell-extensions-mpris-indicator-button is a minimalistic version of 175 | gnome-shell-extensions-mediaplayer. It works with Mopidy-MPRIS, with the 176 | exception of the play/pause button not changing state when Mopidy starts 177 | playing. 178 | 179 | If you have any tips on what's missing to get the play/pause button display 180 | correctly, please open an issue. 181 | 182 | Ubuntu Sound Menu 183 | ----------------- 184 | 185 | State: 186 | Unknown 187 | 188 | Historically, Ubuntu Sound Menu was the primary target for Mopidy-MPRIS' 189 | development. Since Ubuntu 18.04 replaced Unity with GNOME Shell, this is no 190 | longer the case. It is currently unknown to what degree Mopidy-MPRIS works 191 | with old Ubuntu setups. 192 | 193 | If you run an Ubuntu setup with Unity and have tested Mopidy-MPRIS, please 194 | open an issue to share your results. 195 | 196 | 197 | Advanced setups 198 | =============== 199 | 200 | Running as a service 201 | -------------------- 202 | 203 | If you have input on how to best configure Mopidy-MPRIS when Mopidy is 204 | running as a service, please add a comment to `issue #15`_. 205 | 206 | .. _issue #15: https://github.com/mopidy/mopidy-mpris/issues/15 207 | 208 | MPRIS on the system bus 209 | ----------------------- 210 | 211 | You can set the ``mpris/bus_type`` config value to ``system``. This will lead 212 | to Mopidy-MPRIS making itself available on the system bus instead of the 213 | logged in user's session bus. 214 | 215 | .. note:: 216 | Few MPRIS clients will try to access MPRIS devices on the system bus, so 217 | this will give you limited functionality. For example, media keys in 218 | GNOME Shell does not work with media players that expose their MPRIS 219 | interface on the system bus instead of the user's session bus. 220 | 221 | The default setup will often not permit Mopidy to publish its service on the 222 | D-Bus system bus, causing a warning similar to this in Mopidy's log:: 223 | 224 | MPRIS frontend setup failed (g-dbus-error-quark: 225 | GDBus.Error:org.freedesktop.DBus.Error.AccessDenied: Connection ":1.3071" 226 | is not allowed to own the service "org.mpris.MediaPlayer2.mopidy" due to 227 | security policies in the configuration file (9)) 228 | 229 | To solve this, create the file 230 | ``/etc/dbus-1/system.d/org.mpris.MediaPlayer2.mopidy.conf`` with the 231 | following contents: 232 | 233 | .. code:: xml 234 | 235 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | If you run Mopidy as another user than ``mopidy``, you must 251 | update ``user="mopidy"`` in the above file accordingly. 252 | 253 | Once the file is in place, you must restart Mopidy for the change to take 254 | effect. 255 | 256 | To test the setup, you can run the following command as any user on the 257 | system to play/pause the music:: 258 | 259 | dbus-send --system --print-reply \ 260 | --dest=org.mpris.MediaPlayer2.mopidy \ 261 | /org/mpris/MediaPlayer2 \ 262 | org.mpris.MediaPlayer2.Player.PlayPause 263 | 264 | UPnP/DLNA with Rygel 265 | -------------------- 266 | 267 | Rygel_ is an application that will translate between Mopidy's MPRIS interface 268 | and UPnP. Rygel must be run on the same machine as Mopidy, but will make 269 | Mopidy controllable by any device on the local network that can control a 270 | UPnP/DLNA MediaRenderer. 271 | 272 | .. _Rygel: https://wiki.gnome.org/Projects/Rygel 273 | 274 | The setup process is approximately as follows: 275 | 276 | 1. Install Rygel. 277 | 278 | On Debian/Ubuntu/Raspbian:: 279 | 280 | sudo apt install rygel 281 | 282 | 2. Enable Rygel's MPRIS plugin. 283 | 284 | On Debian/Ubuntu/Raspbian, edit ``/etc/rygel.conf``, find the ``[MPRIS]`` 285 | section, and change ``enabled=false`` to ``enabled=true``. 286 | 287 | 3. Start Rygel. 288 | 289 | To start it as the current user:: 290 | 291 | systemctl --user start rygel 292 | 293 | To make Rygel start as the current user on boot:: 294 | 295 | systemctl --user enable rygel 296 | 297 | 4. Configure your system's firewall to allow the local network to reach 298 | Rygel. Exactly how is out of scope for this document. 299 | 300 | 5. Start Mopidy with Mopidy-MPRIS enabled. 301 | 302 | 6. If you view Rygel's log output with:: 303 | 304 | journalctl --user -feu rygel 305 | 306 | You should see a log statement similar to:: 307 | 308 | New plugin "org.mpris.MediaPlayer2.mopidy" available 309 | 310 | 6. If everything went well, you should now be able to control Mopidy from a 311 | device on your local network that can control an UPnP/DLNA MediaRenderer, 312 | for example the Android app BubbleUPnP. 313 | 314 | Alternatively, `upmpdcli combined with Mopidy-MPD`_ serves the same purpose as 315 | this setup. 316 | 317 | .. _upmpdcli combined with Mopidy-MPD: https://docs.mopidy.com/en/latest/clients/upnp/ 318 | 319 | 320 | Development tips 321 | ================ 322 | 323 | Mopidy-MPRIS has an extensive test suite, so the first step for all changes 324 | or additions is to add a test exercising the new code. However, making the 325 | tests pass doesn't ensure that what comes out on the D-Bus bus is correct. To 326 | introspect this through the bus, there's a couple of useful tools. 327 | 328 | 329 | Browsing the MPRIS API with D-Feet 330 | ---------------------------------- 331 | 332 | D-Feet is a graphical D-Bus browser. On Debian/Ubuntu systems it can be 333 | installed by running:: 334 | 335 | sudo apt install d-feet 336 | 337 | Then run the ``d-feet`` command. In the D-Feet window, select the tab 338 | corresponding to the bus you run Mopidy-MPRIS on, usually the session bus. 339 | Then search for "MediaPlayer2" to find all available MPRIS interfaces. 340 | 341 | To get the current value of a property, double-click it. To execute a method, 342 | double-click it, provide any required arguments, and click "Execute". 343 | 344 | For more information on D-Feet, see the `GNOME wiki 345 | `_. 346 | 347 | 348 | Testing the MPRIS API with pydbus 349 | --------------------------------- 350 | 351 | To use the MPRIS API directly, start Mopidy, and then run the following in a 352 | Python shell to use ``pydbus`` as an MPRIS client:: 353 | 354 | >>> import pydbus 355 | >>> bus = pydbus.SessionBus() 356 | >>> player = bus.get('org.mpris.MediaPlayer2.mopidy', '/org/mpris/MediaPlayer2') 357 | 358 | Now you can control Mopidy through the player object. To get properties from 359 | Mopidy, run for example:: 360 | 361 | >>> player.PlaybackStatus 362 | 'Playing' 363 | >>> player.Metadata 364 | {'mpris:artUrl': 'https://i.scdn.co/image/8eb49b41eeb45c1cf53e1ddfea7973d9ca257777', 365 | 'mpris:length': 342000000, 366 | 'mpris:trackid': '/com/mopidy/track/36', 367 | 'xesam:album': '65/Milo', 368 | 'xesam:albumArtist': ['Kiasmos'], 369 | 'xesam:artist': ['Rival Consoles'], 370 | 'xesam:discNumber': 1, 371 | 'xesam:title': 'Arp', 372 | 'xesam:trackNumber': 5, 373 | 'xesam:url': 'spotify:track:7CoxEEsqo3XdvUsScRV4WD'} 374 | >>> 375 | 376 | To pause Mopidy's playback through D-Bus, run:: 377 | 378 | >>> player.Pause() 379 | >>> 380 | 381 | For details on the API, please refer to the `MPRIS specification 382 | `__. 383 | 384 | 385 | Project resources 386 | ================= 387 | 388 | - `Source code `_ 389 | - `Issue tracker `_ 390 | - `Changelog `_ 391 | 392 | 393 | Credits 394 | ======= 395 | 396 | - Original author: `Stein Magnus Jodal `__ 397 | - Current maintainer: None. Maintainer wanted, see section above. 398 | - `Contributors `_ 399 | -------------------------------------------------------------------------------- /mopidy_mpris/__init__.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | 3 | import pkg_resources 4 | 5 | from mopidy import config, exceptions, ext 6 | 7 | __version__ = pkg_resources.get_distribution("Mopidy-MPRIS").version 8 | 9 | 10 | class Extension(ext.Extension): 11 | dist_name = "Mopidy-MPRIS" 12 | ext_name = "mpris" 13 | version = __version__ 14 | 15 | def get_default_config(self): 16 | return config.read(pathlib.Path(__file__).parent / "ext.conf") 17 | 18 | def get_config_schema(self): 19 | schema = super().get_config_schema() 20 | schema["desktop_file"] = config.Deprecated() 21 | schema["bus_type"] = config.String(choices=["session", "system"]) 22 | return schema 23 | 24 | def validate_environment(self): 25 | try: 26 | import pydbus # noqa 27 | except ImportError as e: 28 | raise exceptions.ExtensionError("pydbus library not found", e) 29 | 30 | def setup(self, registry): 31 | from .frontend import MprisFrontend 32 | 33 | registry.add("frontend", MprisFrontend) 34 | -------------------------------------------------------------------------------- /mopidy_mpris/ext.conf: -------------------------------------------------------------------------------- 1 | [mpris] 2 | enabled = true 3 | bus_type = session 4 | -------------------------------------------------------------------------------- /mopidy_mpris/frontend.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import pykka 4 | 5 | from mopidy.core import CoreListener 6 | from mopidy_mpris.playlists import get_playlist_id 7 | from mopidy_mpris.server import Server 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | class MprisFrontend(pykka.ThreadingActor, CoreListener): 13 | def __init__(self, config, core): 14 | super().__init__() 15 | self.config = config 16 | self.core = core 17 | self.mpris = None 18 | 19 | def on_start(self): 20 | try: 21 | self.mpris = Server(self.config, self.core) 22 | self.mpris.publish() 23 | except Exception as e: 24 | logger.warning("MPRIS frontend setup failed (%s)", e) 25 | self.stop() 26 | 27 | def on_stop(self): 28 | logger.debug("Removing MPRIS object from D-Bus connection...") 29 | if self.mpris: 30 | self.mpris.unpublish() 31 | self.mpris = None 32 | logger.debug("Removed MPRIS object from D-Bus connection") 33 | 34 | def on_event(self, event, **kwargs): 35 | logger.debug("Received %s event", event) 36 | if self.mpris is None: 37 | return 38 | return super().on_event(event, **kwargs) 39 | 40 | def track_playback_paused(self, tl_track, time_position): 41 | _emit_properties_changed(self.mpris.player, ["PlaybackStatus"]) 42 | 43 | def track_playback_resumed(self, tl_track, time_position): 44 | _emit_properties_changed(self.mpris.player, ["PlaybackStatus"]) 45 | 46 | def track_playback_started(self, tl_track): 47 | _emit_properties_changed( 48 | self.mpris.player, ["PlaybackStatus", "Metadata"] 49 | ) 50 | 51 | def track_playback_ended(self, tl_track, time_position): 52 | _emit_properties_changed( 53 | self.mpris.player, ["PlaybackStatus", "Metadata"] 54 | ) 55 | 56 | def playback_state_changed(self, old_state, new_state): 57 | _emit_properties_changed( 58 | self.mpris.player, ["PlaybackStatus", "Metadata"] 59 | ) 60 | 61 | def tracklist_changed(self): 62 | pass # TODO Implement if adding tracklist support 63 | 64 | def playlists_loaded(self): 65 | _emit_properties_changed(self.mpris.playlists, ["PlaylistCount"]) 66 | 67 | def playlist_changed(self, playlist): 68 | playlist_id = get_playlist_id(playlist.uri) 69 | self.mpris.playlists.PlaylistChanged(playlist_id, playlist.name, "") 70 | 71 | def playlist_deleted(self, uri): 72 | _emit_properties_changed(self.mpris.playlists, ["PlaylistCount"]) 73 | 74 | def options_changed(self): 75 | _emit_properties_changed( 76 | self.mpris.player, 77 | ["LoopStatus", "Shuffle", "CanGoPrevious", "CanGoNext"], 78 | ) 79 | 80 | def volume_changed(self, volume): 81 | _emit_properties_changed(self.mpris.player, ["Volume"]) 82 | 83 | def mute_changed(self, mute): 84 | _emit_properties_changed(self.mpris.player, ["Volume"]) 85 | 86 | def seeked(self, time_position): 87 | time_position_in_microseconds = time_position * 1000 88 | self.mpris.player.Seeked(time_position_in_microseconds) 89 | 90 | def stream_title_changed(self, title): 91 | _emit_properties_changed(self.mpris.player, ["Metadata"]) 92 | 93 | 94 | def _emit_properties_changed(interface, changed_properties): 95 | props_with_new_values = [ 96 | (p, getattr(interface, p)) for p in changed_properties 97 | ] 98 | interface.PropertiesChanged( 99 | interface.INTERFACE, dict(props_with_new_values), [] 100 | ) 101 | -------------------------------------------------------------------------------- /mopidy_mpris/interface.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from pydbus.generic import signal 4 | 5 | logger = logging.getLogger(__name__) 6 | 7 | # This should be kept in sync with mopidy.internal.log.TRACE_LOG_LEVEL 8 | TRACE_LOG_LEVEL = 5 9 | 10 | 11 | class Interface: 12 | def __init__(self, config, core): 13 | self.config = config 14 | self.core = core 15 | 16 | PropertiesChanged = signal() 17 | 18 | def log_trace(self, *args, **kwargs): 19 | logger.log(TRACE_LOG_LEVEL, *args, **kwargs) 20 | -------------------------------------------------------------------------------- /mopidy_mpris/player.py: -------------------------------------------------------------------------------- 1 | """Implementation of org.mpris.MediaPlayer2.Player interface. 2 | 3 | https://specifications.freedesktop.org/mpris-spec/2.2/Player_Interface.html 4 | """ 5 | 6 | 7 | import logging 8 | 9 | from gi.repository.GLib import Variant 10 | from pydbus.generic import signal 11 | 12 | from mopidy.core import PlaybackState 13 | from mopidy_mpris.interface import Interface 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | 18 | class Player(Interface): 19 | """ 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | """ 59 | 60 | INTERFACE = "org.mpris.MediaPlayer2.Player" 61 | 62 | # To override from tests. 63 | _CanControl = True 64 | 65 | def Next(self): 66 | logger.debug("%s.Next called", self.INTERFACE) 67 | if not self.CanGoNext: 68 | logger.debug("%s.Next not allowed", self.INTERFACE) 69 | return 70 | self.core.playback.next().get() 71 | 72 | def Previous(self): 73 | logger.debug("%s.Previous called", self.INTERFACE) 74 | if not self.CanGoPrevious: 75 | logger.debug("%s.Previous not allowed", self.INTERFACE) 76 | return 77 | self.core.playback.previous().get() 78 | 79 | def Pause(self): 80 | logger.debug("%s.Pause called", self.INTERFACE) 81 | if not self.CanPause: 82 | logger.debug("%s.Pause not allowed", self.INTERFACE) 83 | return 84 | self.core.playback.pause().get() 85 | 86 | def PlayPause(self): 87 | logger.debug("%s.PlayPause called", self.INTERFACE) 88 | if not self.CanPause: 89 | logger.debug("%s.PlayPause not allowed", self.INTERFACE) 90 | return 91 | state = self.core.playback.get_state().get() 92 | if state == PlaybackState.PLAYING: 93 | self.core.playback.pause().get() 94 | elif state == PlaybackState.PAUSED: 95 | self.core.playback.resume().get() 96 | elif state == PlaybackState.STOPPED: 97 | self.core.playback.play().get() 98 | 99 | def Stop(self): 100 | logger.debug("%s.Stop called", self.INTERFACE) 101 | if not self.CanControl: 102 | logger.debug("%s.Stop not allowed", self.INTERFACE) 103 | return 104 | self.core.playback.stop().get() 105 | 106 | def Play(self): 107 | logger.debug("%s.Play called", self.INTERFACE) 108 | if not self.CanPlay: 109 | logger.debug("%s.Play not allowed", self.INTERFACE) 110 | return 111 | state = self.core.playback.get_state().get() 112 | if state == PlaybackState.PAUSED: 113 | self.core.playback.resume().get() 114 | else: 115 | self.core.playback.play().get() 116 | 117 | def Seek(self, offset): 118 | logger.debug("%s.Seek called", self.INTERFACE) 119 | if not self.CanSeek: 120 | logger.debug("%s.Seek not allowed", self.INTERFACE) 121 | return 122 | offset_in_milliseconds = offset // 1000 123 | current_position = self.core.playback.get_time_position().get() 124 | new_position = current_position + offset_in_milliseconds 125 | if new_position < 0: 126 | new_position = 0 127 | self.core.playback.seek(new_position).get() 128 | 129 | def SetPosition(self, track_id, position): 130 | logger.debug("%s.SetPosition called", self.INTERFACE) 131 | if not self.CanSeek: 132 | logger.debug("%s.SetPosition not allowed", self.INTERFACE) 133 | return 134 | position = position // 1000 135 | current_tl_track = self.core.playback.get_current_tl_track().get() 136 | if current_tl_track is None: 137 | return 138 | if track_id != get_track_id(current_tl_track.tlid): 139 | return 140 | if position < 0: 141 | return 142 | if current_tl_track.track.length < position: 143 | return 144 | self.core.playback.seek(position).get() 145 | 146 | def OpenUri(self, uri): 147 | logger.debug("%s.OpenUri called", self.INTERFACE) 148 | if not self.CanControl: 149 | # NOTE The spec does not explicitly require this check, but 150 | # guarding the other methods doesn't help much if OpenUri is open 151 | # for use. 152 | logger.debug("%s.OpenUri not allowed", self.INTERFACE) 153 | return 154 | # NOTE Check if URI has MIME type known to the backend, if MIME support 155 | # is added to the backend. 156 | tl_tracks = self.core.tracklist.add(uris=[uri]).get() 157 | if tl_tracks: 158 | self.core.playback.play(tlid=tl_tracks[0].tlid).get() 159 | else: 160 | logger.debug('Track with URI "%s" not found in library.', uri) 161 | 162 | Seeked = signal() 163 | 164 | @property 165 | def PlaybackStatus(self): 166 | self.log_trace("Getting %s.PlaybackStatus", self.INTERFACE) 167 | state = self.core.playback.get_state().get() 168 | if state == PlaybackState.PLAYING: 169 | return "Playing" 170 | elif state == PlaybackState.PAUSED: 171 | return "Paused" 172 | elif state == PlaybackState.STOPPED: 173 | return "Stopped" 174 | 175 | @property 176 | def LoopStatus(self): 177 | self.log_trace("Getting %s.LoopStatus", self.INTERFACE) 178 | repeat = self.core.tracklist.get_repeat().get() 179 | single = self.core.tracklist.get_single().get() 180 | if not repeat: 181 | return "None" 182 | else: 183 | if single: 184 | return "Track" 185 | else: 186 | return "Playlist" 187 | 188 | @LoopStatus.setter 189 | def LoopStatus(self, value): 190 | if not self.CanControl: 191 | logger.debug("Setting %s.LoopStatus not allowed", self.INTERFACE) 192 | return 193 | logger.debug("Setting %s.LoopStatus to %s", self.INTERFACE, value) 194 | if value == "None": 195 | self.core.tracklist.set_repeat(False) 196 | self.core.tracklist.set_single(False) 197 | elif value == "Track": 198 | self.core.tracklist.set_repeat(True) 199 | self.core.tracklist.set_single(True) 200 | elif value == "Playlist": 201 | self.core.tracklist.set_repeat(True) 202 | self.core.tracklist.set_single(False) 203 | 204 | @property 205 | def Rate(self): 206 | self.log_trace("Getting %s.Rate", self.INTERFACE) 207 | return 1.0 208 | 209 | @Rate.setter 210 | def Rate(self, value): 211 | if not self.CanControl: 212 | # NOTE The spec does not explicitly require this check, but it was 213 | # added to be consistent with all the other property setters. 214 | logger.debug("Setting %s.Rate not allowed", self.INTERFACE) 215 | return 216 | logger.debug("Setting %s.Rate to %s", self.INTERFACE, value) 217 | if value == 0: 218 | self.Pause() 219 | 220 | @property 221 | def Shuffle(self): 222 | self.log_trace("Getting %s.Shuffle", self.INTERFACE) 223 | return self.core.tracklist.get_random().get() 224 | 225 | @Shuffle.setter 226 | def Shuffle(self, value): 227 | if not self.CanControl: 228 | logger.debug("Setting %s.Shuffle not allowed", self.INTERFACE) 229 | return 230 | logger.debug("Setting %s.Shuffle to %s", self.INTERFACE, value) 231 | self.core.tracklist.set_random(bool(value)) 232 | 233 | @property 234 | def Metadata(self): 235 | self.log_trace("Getting %s.Metadata", self.INTERFACE) 236 | current_tl_track = self.core.playback.get_current_tl_track().get() 237 | stream_title = self.core.playback.get_stream_title().get() 238 | if current_tl_track is None: 239 | return {} 240 | else: 241 | (tlid, track) = current_tl_track 242 | track_id = get_track_id(tlid) 243 | res = {"mpris:trackid": Variant("o", track_id)} 244 | if track.length: 245 | res["mpris:length"] = Variant("x", track.length * 1000) 246 | if track.uri: 247 | res["xesam:url"] = Variant("s", track.uri) 248 | if stream_title or track.name: 249 | res["xesam:title"] = Variant("s", stream_title or track.name) 250 | if track.artists: 251 | artists = list(track.artists) 252 | artists.sort(key=lambda a: a.name or "") 253 | res["xesam:artist"] = Variant( 254 | "as", [a.name for a in artists if a.name] 255 | ) 256 | if track.album and track.album.name: 257 | res["xesam:album"] = Variant("s", track.album.name) 258 | if track.album and track.album.artists: 259 | artists = list(track.album.artists) 260 | artists.sort(key=lambda a: a.name or "") 261 | res["xesam:albumArtist"] = Variant( 262 | "as", [a.name for a in artists if a.name] 263 | ) 264 | art_url = self._get_art_url(track) 265 | if art_url: 266 | res["mpris:artUrl"] = Variant("s", art_url) 267 | if track.disc_no: 268 | res["xesam:discNumber"] = Variant("i", track.disc_no) 269 | if track.track_no: 270 | res["xesam:trackNumber"] = Variant("i", track.track_no) 271 | return res 272 | 273 | def _get_art_url(self, track): 274 | images = self.core.library.get_images([track.uri]).get() 275 | if images[track.uri]: 276 | largest_image = sorted( 277 | images[track.uri], key=lambda i: i.width or 0, reverse=True 278 | )[0] 279 | return largest_image.uri 280 | 281 | @property 282 | def Volume(self): 283 | self.log_trace("Getting %s.Volume", self.INTERFACE) 284 | mute = self.core.mixer.get_mute().get() 285 | volume = self.core.mixer.get_volume().get() 286 | if volume is None or mute is True: 287 | return 0 288 | return volume / 100.0 289 | 290 | @Volume.setter 291 | def Volume(self, value): 292 | if not self.CanControl: 293 | logger.debug("Setting %s.Volume not allowed", self.INTERFACE) 294 | return 295 | logger.debug("Setting %s.Volume to %s", self.INTERFACE, value) 296 | if value is None: 297 | return 298 | if value < 0: 299 | value = 0 300 | elif value > 1: 301 | value = 1 302 | self.core.mixer.set_volume(int(value * 100)) 303 | if value > 0: 304 | self.core.mixer.set_mute(False) 305 | 306 | @property 307 | def Position(self): 308 | self.log_trace("Getting %s.Position", self.INTERFACE) 309 | return self.core.playback.get_time_position().get() * 1000 310 | 311 | MinimumRate = 1.0 312 | MaximumRate = 1.0 313 | 314 | @property 315 | def CanGoNext(self): 316 | self.log_trace("Getting %s.CanGoNext", self.INTERFACE) 317 | if not self.CanControl: 318 | return False 319 | current_tlid = self.core.playback.get_current_tlid().get() 320 | next_tlid = self.core.tracklist.get_next_tlid().get() 321 | return next_tlid != current_tlid 322 | 323 | @property 324 | def CanGoPrevious(self): 325 | self.log_trace("Getting %s.CanGoPrevious", self.INTERFACE) 326 | if not self.CanControl: 327 | return False 328 | current_tlid = self.core.playback.get_current_tlid().get() 329 | previous_tlid = self.core.tracklist.get_previous_tlid().get() 330 | return previous_tlid != current_tlid 331 | 332 | @property 333 | def CanPlay(self): 334 | self.log_trace("Getting %s.CanPlay", self.INTERFACE) 335 | if not self.CanControl: 336 | return False 337 | current_tlid = self.core.playback.get_current_tlid().get() 338 | next_tlid = self.core.tracklist.get_next_tlid().get() 339 | return current_tlid is not None or next_tlid is not None 340 | 341 | @property 342 | def CanPause(self): 343 | self.log_trace("Getting %s.CanPause", self.INTERFACE) 344 | if not self.CanControl: 345 | return False 346 | # NOTE Should be changed to vary based on capabilities of the current 347 | # track if Mopidy starts supporting non-seekable media, like streams. 348 | return True 349 | 350 | @property 351 | def CanSeek(self): 352 | self.log_trace("Getting %s.CanSeek", self.INTERFACE) 353 | if not self.CanControl: 354 | return False 355 | # NOTE Should be changed to vary based on capabilities of the current 356 | # track if Mopidy starts supporting non-seekable media, like streams. 357 | return True 358 | 359 | @property 360 | def CanControl(self): 361 | # NOTE This could be a setting for the end user to change. 362 | return self._CanControl 363 | 364 | 365 | def get_track_id(tlid): 366 | return "/com/mopidy/track/%d" % tlid 367 | 368 | 369 | def get_track_tlid(track_id): 370 | assert track_id.startswith("/com/mopidy/track/") 371 | return track_id.split("/")[-1] 372 | -------------------------------------------------------------------------------- /mopidy_mpris/playlists.py: -------------------------------------------------------------------------------- 1 | """Implementation of org.mpris.MediaPlayer2.Playlists interface. 2 | 3 | https://specifications.freedesktop.org/mpris-spec/2.2/Playlists_Interface.html 4 | """ 5 | 6 | import base64 7 | import logging 8 | from typing import Union 9 | 10 | from pydbus.generic import signal 11 | 12 | from mopidy_mpris.interface import Interface 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | 17 | class Playlists(Interface): 18 | """ 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | """ 40 | 41 | INTERFACE = "org.mpris.MediaPlayer2.Playlists" 42 | 43 | def ActivatePlaylist(self, playlist_id): 44 | logger.debug( 45 | "%s.ActivatePlaylist(%r) called", self.INTERFACE, playlist_id 46 | ) 47 | playlist_uri = get_playlist_uri(playlist_id) 48 | playlist = self.core.playlists.lookup(playlist_uri).get() 49 | if playlist and playlist.tracks: 50 | tl_tracks = self.core.tracklist.add(playlist.tracks).get() 51 | self.core.playback.play(tlid=tl_tracks[0].tlid).get() 52 | 53 | def GetPlaylists(self, index, max_count, order, reverse): 54 | logger.debug( 55 | "%s.GetPlaylists(%r, %r, %r, %r) called", 56 | self.INTERFACE, 57 | index, 58 | max_count, 59 | order, 60 | reverse, 61 | ) 62 | playlists = self.core.playlists.as_list().get() 63 | if order == "Alphabetical": 64 | playlists.sort(key=lambda p: p.name, reverse=reverse) 65 | elif order == "User" and reverse: 66 | playlists.reverse() 67 | slice_end = index + max_count 68 | playlists = playlists[index:slice_end] 69 | results = [(get_playlist_id(p.uri), p.name, "") for p in playlists] 70 | return results 71 | 72 | PlaylistChanged = signal() 73 | 74 | @property 75 | def PlaylistCount(self): 76 | self.log_trace("Getting %s.PlaylistCount", self.INTERFACE) 77 | return len(self.core.playlists.as_list().get()) 78 | 79 | @property 80 | def Orderings(self): 81 | self.log_trace("Getting %s.Orderings", self.INTERFACE) 82 | return [ 83 | "Alphabetical", # Order by playlist.name 84 | "User", # Don't change order 85 | ] 86 | 87 | @property 88 | def ActivePlaylist(self): 89 | self.log_trace("Getting %s.ActivePlaylist", self.INTERFACE) 90 | playlist_is_valid = False 91 | playlist = ("/", "None", "") 92 | return (playlist_is_valid, playlist) 93 | 94 | 95 | def get_playlist_id(playlist_uri: Union[str, bytes]) -> str: 96 | # Only A-Za-z0-9_ is allowed, which is 63 chars, so we can't use 97 | # base64. Luckily, D-Bus does not limit the length of object paths. 98 | # Since base32 pads trailing bytes with "=" chars, we need to replace 99 | # them with an allowed character such as "_". 100 | if isinstance(playlist_uri, str): 101 | playlist_uri = playlist_uri.encode() 102 | encoded_uri = base64.b32encode(playlist_uri).decode().replace("=", "_") 103 | return "/com/mopidy/playlist/%s" % encoded_uri 104 | 105 | 106 | def get_playlist_uri(playlist_id: Union[str, bytes]) -> str: 107 | if isinstance(playlist_id, bytes): 108 | playlist_id = playlist_id.decode() 109 | encoded_uri = playlist_id.split("/")[-1].replace("_", "=").encode() 110 | return base64.b32decode(encoded_uri).decode() 111 | -------------------------------------------------------------------------------- /mopidy_mpris/root.py: -------------------------------------------------------------------------------- 1 | """Implementation of org.mpris.MediaPlayer2 interface. 2 | 3 | https://specifications.freedesktop.org/mpris-spec/2.2/Media_Player.html 4 | """ 5 | 6 | 7 | import logging 8 | 9 | from mopidy_mpris.interface import Interface 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | class Root(Interface): 15 | """ 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | """ 32 | 33 | INTERFACE = "org.mpris.MediaPlayer2" 34 | 35 | def Raise(self): 36 | logger.debug("%s.Raise called", self.INTERFACE) 37 | # Do nothing, as we do not have a GUI 38 | 39 | def Quit(self): 40 | logger.debug("%s.Quit called", self.INTERFACE) 41 | # Do nothing, as we do not allow MPRIS clients to shut down Mopidy 42 | 43 | CanQuit = False 44 | 45 | @property 46 | def Fullscreen(self): 47 | self.log_trace("Getting %s.Fullscreen", self.INTERFACE) 48 | return False 49 | 50 | @Fullscreen.setter 51 | def Fullscreen(self, value): 52 | logger.debug("Setting %s.Fullscreen to %s", self.INTERFACE, value) 53 | pass 54 | 55 | CanSetFullscreen = False 56 | CanRaise = False 57 | HasTrackList = False # NOTE Change if adding optional track list support 58 | Identity = "Mopidy" 59 | 60 | @property 61 | def DesktopEntry(self): 62 | self.log_trace("Getting %s.DesktopEntry", self.INTERFACE) 63 | # This property is optional to expose. If we set this to "mopidy", the 64 | # basename of "mopidy.desktop", some MPRIS clients will start a new 65 | # Mopidy instance in a terminal window if one clicks outside the 66 | # buttons of the UI. This is probably never what the user wants. 67 | return "" 68 | 69 | @property 70 | def SupportedUriSchemes(self): 71 | self.log_trace("Getting %s.SupportedUriSchemes", self.INTERFACE) 72 | return self.core.get_uri_schemes().get() 73 | 74 | @property 75 | def SupportedMimeTypes(self): 76 | # NOTE Return MIME types supported by local backend if support for 77 | # reporting supported MIME types is added. 78 | self.log_trace("Getting %s.SupportedMimeTypes", self.INTERFACE) 79 | return [ 80 | "audio/mpeg", 81 | "audio/x-ms-wma", 82 | "audio/x-ms-asf", 83 | "audio/x-flac", 84 | "audio/flac", 85 | "audio/l16;channels=2;rate=44100", 86 | "audio/l16;rate=44100;channels=2", 87 | ] 88 | -------------------------------------------------------------------------------- /mopidy_mpris/server.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import pydbus 4 | 5 | from mopidy_mpris.player import Player 6 | from mopidy_mpris.playlists import Playlists 7 | from mopidy_mpris.root import Root 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | class Server: 13 | def __init__(self, config, core): 14 | self.config = config 15 | self.core = core 16 | 17 | self.root = Root(config, core) 18 | self.player = Player(config, core) 19 | self.playlists = Playlists(config, core) 20 | 21 | self._publication_token = None 22 | 23 | def publish(self): 24 | bus_type = self.config["mpris"]["bus_type"] 25 | logger.debug("Connecting to D-Bus %s bus...", bus_type) 26 | 27 | if bus_type == "system": 28 | bus = pydbus.SystemBus() 29 | else: 30 | bus = pydbus.SessionBus() 31 | 32 | logger.info("MPRIS server connected to D-Bus %s bus", bus_type) 33 | 34 | self._publication_token = bus.publish( 35 | "org.mpris.MediaPlayer2.mopidy", 36 | ("/org/mpris/MediaPlayer2", self.root), 37 | ("/org/mpris/MediaPlayer2", self.player), 38 | ("/org/mpris/MediaPlayer2", self.playlists), 39 | ) 40 | 41 | def unpublish(self): 42 | if self._publication_token: 43 | self._publication_token.unpublish() 44 | self._publication_token = None 45 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools >= 30.3.0", "wheel"] 3 | 4 | 5 | [tool.black] 6 | target-version = ["py39", "py310", "py311"] 7 | line-length = 80 8 | 9 | 10 | [tool.isort] 11 | multi_line_output = 3 12 | include_trailing_comma = true 13 | force_grid_wrap = 0 14 | use_parentheses = true 15 | line_length = 88 16 | known_tests = "tests" 17 | sections = "FUTURE,STDLIB,THIRDPARTY,FIRSTPARTY,TESTS,LOCALFOLDER" 18 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = Mopidy-MPRIS 3 | version = 3.0.3 4 | url = https://github.com/mopidy/mopidy-mpris 5 | author = Stein Magnus Jodal 6 | author_email = stein.magnus@jodal.no 7 | license = Apache License, Version 2.0 8 | license_file = LICENSE 9 | description = Mopidy extension for controlling Mopidy through the MPRIS D-Bus interface 10 | long_description = file: README.rst 11 | classifiers = 12 | Development Status :: 5 - Production/Stable 13 | Environment :: No Input/Output (Daemon) 14 | Intended Audience :: End Users/Desktop 15 | License :: OSI Approved :: Apache Software License 16 | Operating System :: POSIX :: Linux 17 | Programming Language :: Python :: 3 18 | Programming Language :: Python :: 3.9 19 | Programming Language :: Python :: 3.10 20 | Programming Language :: Python :: 3.11 21 | Topic :: Multimedia :: Sound/Audio :: Players 22 | 23 | 24 | [options] 25 | zip_safe = False 26 | include_package_data = True 27 | packages = find: 28 | python_requires = >= 3.9 29 | install_requires = 30 | Mopidy >= 3.0.0 31 | Pykka >= 2.0.1 32 | setuptools 33 | pydbus >= 0.6.0 34 | 35 | 36 | [options.extras_require] 37 | lint = 38 | black 39 | check-manifest 40 | flake8 41 | flake8-black 42 | flake8-bugbear 43 | flake8-import-order 44 | isort[pyproject] 45 | test = 46 | pytest 47 | pytest-cov 48 | dev = 49 | %(lint)s 50 | %(test)s 51 | 52 | 53 | [options.packages.find] 54 | exclude = 55 | tests 56 | tests.* 57 | 58 | 59 | [options.entry_points] 60 | mopidy.ext = 61 | mpris = mopidy_mpris:Extension 62 | 63 | 64 | [flake8] 65 | application-import-names = mopidy_mpris, tests 66 | max-line-length = 80 67 | exclude = .git, .tox, build 68 | select = 69 | # Regular flake8 rules 70 | C, E, F, W 71 | # flake8-bugbear rules 72 | B 73 | # B950: line too long (soft speed limit) 74 | B950 75 | # pep8-naming rules 76 | N 77 | ignore = 78 | # E203: whitespace before ':' (not PEP8 compliant) 79 | E203 80 | # E501: line too long (replaced by B950) 81 | E501 82 | # W503: line break before binary operator (not PEP8 compliant) 83 | W503 84 | # B305: .next() is not a thing on Python 3 (used by playback controller) 85 | B305 86 | # N802: function name should be lowercase 87 | N802 88 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup() 4 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mopidy/mopidy-mpris/e6f4fead21d3a5a0b4cf72a6f9060350bd2b55d8/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from mopidy.core import Core 4 | 5 | from tests import dummy_audio, dummy_backend, dummy_mixer 6 | 7 | 8 | @pytest.fixture 9 | def config(): 10 | return { 11 | "core": {"max_tracklist_length": 10000}, 12 | } 13 | 14 | 15 | @pytest.fixture 16 | def audio(): 17 | actor = dummy_audio.create_proxy() 18 | yield actor 19 | actor.stop() 20 | 21 | 22 | @pytest.fixture 23 | def backend(audio): 24 | actor = dummy_backend.create_proxy(audio=audio) 25 | yield actor 26 | actor.stop() 27 | 28 | 29 | @pytest.fixture 30 | def mixer(): 31 | actor = dummy_mixer.create_proxy() 32 | yield actor 33 | actor.stop() 34 | 35 | 36 | @pytest.fixture 37 | def core(config, backend, mixer, audio): 38 | actor = Core.start( 39 | config=config, backends=[backend], mixer=mixer, audio=audio 40 | ).proxy() 41 | yield actor 42 | actor.stop() 43 | -------------------------------------------------------------------------------- /tests/dummy_audio.py: -------------------------------------------------------------------------------- 1 | """A dummy audio actor for use in tests. 2 | 3 | This class implements the audio API in the simplest way possible. It is used in 4 | tests of the core and backends. 5 | """ 6 | 7 | 8 | import pykka 9 | 10 | from mopidy import audio 11 | 12 | 13 | def create_proxy(config=None, mixer=None): 14 | return DummyAudio.start(config, mixer).proxy() 15 | 16 | 17 | # TODO: reset position on track change? 18 | class DummyAudio(pykka.ThreadingActor): 19 | def __init__(self, config=None, mixer=None): 20 | super().__init__() 21 | self.state = audio.PlaybackState.STOPPED 22 | self._volume = 0 23 | self._position = 0 24 | self._source_setup_callback = None 25 | self._about_to_finish_callback = None 26 | self._uri = None 27 | self._stream_changed = False 28 | self._live_stream = False 29 | self._tags = {} 30 | self._bad_uris = set() 31 | 32 | def set_uri(self, uri, live_stream=False, download=False): 33 | assert self._uri is None, "prepare change not called before set" 34 | self._position = 0 35 | self._uri = uri 36 | self._stream_changed = True 37 | self._live_stream = live_stream 38 | self._tags = {} 39 | 40 | def set_appsrc(self, *args, **kwargs): 41 | pass 42 | 43 | def emit_data(self, buffer_): 44 | pass 45 | 46 | def get_position(self): 47 | return self._position 48 | 49 | def set_position(self, position): 50 | self._position = position 51 | audio.AudioListener.send("position_changed", position=position) 52 | return True 53 | 54 | def start_playback(self): 55 | return self._change_state(audio.PlaybackState.PLAYING) 56 | 57 | def pause_playback(self): 58 | return self._change_state(audio.PlaybackState.PAUSED) 59 | 60 | def prepare_change(self): 61 | self._uri = None 62 | self._source_setup_callback = None 63 | return True 64 | 65 | def stop_playback(self): 66 | return self._change_state(audio.PlaybackState.STOPPED) 67 | 68 | def get_volume(self): 69 | return self._volume 70 | 71 | def set_volume(self, volume): 72 | self._volume = volume 73 | return True 74 | 75 | def set_metadata(self, track): 76 | pass 77 | 78 | def get_current_tags(self): 79 | return self._tags 80 | 81 | def set_source_setup_callback(self, callback): 82 | self._source_setup_callback = callback 83 | 84 | def set_about_to_finish_callback(self, callback): 85 | self._about_to_finish_callback = callback 86 | 87 | def enable_sync_handler(self): 88 | pass 89 | 90 | def wait_for_state_change(self): 91 | pass 92 | 93 | def _change_state(self, new_state): 94 | if not self._uri: 95 | return False 96 | 97 | if new_state == audio.PlaybackState.STOPPED and self._uri: 98 | self._stream_changed = True 99 | self._uri = None 100 | 101 | if self._stream_changed: 102 | self._stream_changed = False 103 | audio.AudioListener.send("stream_changed", uri=self._uri) 104 | 105 | if self._uri is not None: 106 | audio.AudioListener.send("position_changed", position=0) 107 | 108 | old_state, self.state = self.state, new_state 109 | audio.AudioListener.send( 110 | "state_changed", 111 | old_state=old_state, 112 | new_state=new_state, 113 | target_state=None, 114 | ) 115 | 116 | if new_state == audio.PlaybackState.PLAYING: 117 | self._tags["audio-codec"] = ["fake info..."] 118 | audio.AudioListener.send("tags_changed", tags=["audio-codec"]) 119 | 120 | return self._uri not in self._bad_uris 121 | 122 | def trigger_fake_playback_failure(self, uri): 123 | self._bad_uris.add(uri) 124 | 125 | def trigger_fake_tags_changed(self, tags): 126 | self._tags.update(tags) 127 | audio.AudioListener.send("tags_changed", tags=self._tags.keys()) 128 | 129 | def get_source_setup_callback(self): 130 | # This needs to be called from outside the actor or we lock up. 131 | def wrapper(): 132 | if self._source_setup_callback: 133 | self._source_setup_callback() 134 | 135 | return wrapper 136 | 137 | def get_about_to_finish_callback(self): 138 | # This needs to be called from outside the actor or we lock up. 139 | def wrapper(): 140 | if self._about_to_finish_callback: 141 | self.prepare_change() 142 | self._about_to_finish_callback() 143 | 144 | if not self._uri or not self._about_to_finish_callback: 145 | self._tags = {} 146 | audio.AudioListener.send("reached_end_of_stream") 147 | else: 148 | audio.AudioListener.send("position_changed", position=0) 149 | audio.AudioListener.send("stream_changed", uri=self._uri) 150 | 151 | return wrapper 152 | -------------------------------------------------------------------------------- /tests/dummy_backend.py: -------------------------------------------------------------------------------- 1 | """A dummy backend for use in tests. 2 | 3 | This backend implements the backend API in the simplest way possible. It is 4 | used in tests of the frontends. 5 | """ 6 | 7 | 8 | import pykka 9 | from mopidy import backend 10 | from mopidy.models import Playlist, Ref, SearchResult 11 | 12 | 13 | def create_proxy(config=None, audio=None): 14 | return DummyBackend.start(config=config, audio=audio).proxy() 15 | 16 | 17 | class DummyBackend(pykka.ThreadingActor, backend.Backend): 18 | def __init__(self, config, audio): 19 | super().__init__() 20 | 21 | self.library = DummyLibraryProvider(backend=self) 22 | if audio: 23 | self.playback = backend.PlaybackProvider(audio=audio, backend=self) 24 | else: 25 | self.playback = DummyPlaybackProvider(audio=audio, backend=self) 26 | self.playlists = DummyPlaylistsProvider(backend=self) 27 | 28 | self.uri_schemes = ["dummy"] 29 | 30 | 31 | class DummyLibraryProvider(backend.LibraryProvider): 32 | root_directory = Ref.directory(uri="dummy:/", name="dummy") 33 | 34 | def __init__(self, *args, **kwargs): 35 | super().__init__(*args, **kwargs) 36 | self.dummy_library = [] 37 | self.dummy_get_distinct_result = {} 38 | self.dummy_get_images_result = {} 39 | self.dummy_browse_result = {} 40 | self.dummy_find_exact_result = SearchResult() 41 | self.dummy_search_result = SearchResult() 42 | 43 | def browse(self, path): 44 | return self.dummy_browse_result.get(path, []) 45 | 46 | def get_distinct(self, field, query=None): 47 | return self.dummy_get_distinct_result.get(field, set()) 48 | 49 | def get_images(self, uris): 50 | return self.dummy_get_images_result 51 | 52 | def lookup(self, uri): 53 | uri = Ref.track(uri=uri).uri 54 | return [t for t in self.dummy_library if uri == t.uri] 55 | 56 | def refresh(self, uri=None): 57 | pass 58 | 59 | def search(self, query=None, uris=None, exact=False): 60 | if exact: # TODO: remove uses of dummy_find_exact_result 61 | return self.dummy_find_exact_result 62 | return self.dummy_search_result 63 | 64 | 65 | class DummyPlaybackProvider(backend.PlaybackProvider): 66 | def __init__(self, *args, **kwargs): 67 | super().__init__(*args, **kwargs) 68 | self._uri = None 69 | self._time_position = 0 70 | 71 | def pause(self): 72 | return True 73 | 74 | def play(self): 75 | return self._uri and self._uri != "dummy:error" 76 | 77 | def change_track(self, track): 78 | """Pass a track with URI 'dummy:error' to force failure""" 79 | self._uri = track.uri 80 | self._time_position = 0 81 | return True 82 | 83 | def prepare_change(self): 84 | pass 85 | 86 | def resume(self): 87 | return True 88 | 89 | def seek(self, time_position): 90 | self._time_position = time_position 91 | return True 92 | 93 | def stop(self): 94 | self._uri = None 95 | return True 96 | 97 | def get_time_position(self): 98 | return self._time_position 99 | 100 | 101 | class DummyPlaylistsProvider(backend.PlaylistsProvider): 102 | def __init__(self, backend): 103 | super().__init__(backend) 104 | self._playlists = [] 105 | self._allow_save = True 106 | 107 | def set_dummy_playlists(self, playlists): 108 | """For tests using the dummy provider through an actor proxy.""" 109 | self._playlists = playlists 110 | 111 | def set_allow_save(self, enabled): 112 | self._allow_save = enabled 113 | 114 | def as_list(self): 115 | return [ 116 | Ref.playlist(uri=pl.uri, name=pl.name) for pl in self._playlists 117 | ] 118 | 119 | def get_items(self, uri): 120 | playlist = self.lookup(uri) 121 | if playlist is None: 122 | return 123 | return [Ref.track(uri=t.uri, name=t.name) for t in playlist.tracks] 124 | 125 | def lookup(self, uri): 126 | uri = Ref.playlist(uri=uri).uri 127 | for playlist in self._playlists: 128 | if playlist.uri == uri: 129 | return playlist 130 | 131 | def refresh(self): 132 | pass 133 | 134 | def create(self, name): 135 | playlist = Playlist(name=name, uri=f"dummy:{name}") 136 | self._playlists.append(playlist) 137 | return playlist 138 | 139 | def delete(self, uri): 140 | playlist = self.lookup(uri) 141 | if playlist: 142 | self._playlists.remove(playlist) 143 | 144 | def save(self, playlist): 145 | if not self._allow_save: 146 | return None 147 | 148 | old_playlist = self.lookup(playlist.uri) 149 | 150 | if old_playlist is not None: 151 | index = self._playlists.index(old_playlist) 152 | self._playlists[index] = playlist 153 | else: 154 | self._playlists.append(playlist) 155 | 156 | return playlist 157 | -------------------------------------------------------------------------------- /tests/dummy_mixer.py: -------------------------------------------------------------------------------- 1 | import pykka 2 | 3 | from mopidy import mixer 4 | 5 | 6 | def create_proxy(config=None): 7 | return DummyMixer.start(config=None).proxy() 8 | 9 | 10 | class DummyMixer(pykka.ThreadingActor, mixer.Mixer): 11 | def __init__(self, config): 12 | super().__init__() 13 | self._volume = None 14 | self._mute = None 15 | 16 | def get_volume(self): 17 | return self._volume 18 | 19 | def set_volume(self, volume): 20 | self._volume = volume 21 | self.trigger_volume_changed(volume=volume) 22 | return True 23 | 24 | def get_mute(self): 25 | return self._mute 26 | 27 | def set_mute(self, mute): 28 | self._mute = mute 29 | self.trigger_mute_changed(mute=mute) 30 | return True 31 | -------------------------------------------------------------------------------- /tests/test_events.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | import pytest 4 | 5 | from mopidy.core.playback import PlaybackState 6 | from mopidy.models import Playlist, TlTrack 7 | from mopidy_mpris import frontend as frontend_mod 8 | from mopidy_mpris import player, playlists, root, server 9 | 10 | 11 | @pytest.fixture 12 | def frontend(): 13 | # As a plain class, not an actor: 14 | result = frontend_mod.MprisFrontend(config=None, core=None) 15 | result.mpris = mock.Mock(spec=server.Server) 16 | result.mpris.root = mock.Mock(spec=root.Root) 17 | result.mpris.root.INTERFACE = root.Root.INTERFACE 18 | result.mpris.player = mock.Mock(spec=player.Player) 19 | result.mpris.player.INTERFACE = player.Player.INTERFACE 20 | result.mpris.playlists = mock.Mock(spec=playlists.Playlists) 21 | result.mpris.playlists.INTERFACE = playlists.Playlists.INTERFACE 22 | return result 23 | 24 | 25 | def test_track_playback_paused_event_changes_playback_status(frontend): 26 | frontend.mpris.player.PlaybackStatus = "Paused" 27 | 28 | frontend.track_playback_paused(tl_track=TlTrack(), time_position=0) 29 | 30 | frontend.mpris.player.PropertiesChanged.assert_called_with( 31 | player.Player.INTERFACE, {"PlaybackStatus": "Paused"}, [] 32 | ) 33 | 34 | 35 | def test_track_playback_resumed_event_changes_playback_status(frontend): 36 | frontend.mpris.player.PlaybackStatus = "Playing" 37 | 38 | frontend.track_playback_resumed(tl_track=TlTrack(), time_position=0) 39 | 40 | frontend.mpris.player.PropertiesChanged.assert_called_with( 41 | player.Player.INTERFACE, {"PlaybackStatus": "Playing"}, [] 42 | ) 43 | 44 | 45 | def test_track_playback_started_changes_playback_status_and_metadata(frontend): 46 | frontend.mpris.player.Metadata = "..." 47 | frontend.mpris.player.PlaybackStatus = "Playing" 48 | 49 | frontend.track_playback_started(tl_track=TlTrack()) 50 | 51 | frontend.mpris.player.PropertiesChanged.assert_called_with( 52 | player.Player.INTERFACE, 53 | {"Metadata": "...", "PlaybackStatus": "Playing"}, 54 | [], 55 | ) 56 | 57 | 58 | def test_track_playback_ended_changes_playback_status_and_metadata(frontend): 59 | frontend.mpris.player.Metadata = "..." 60 | frontend.mpris.player.PlaybackStatus = "Stopped" 61 | 62 | frontend.track_playback_ended(tl_track=TlTrack(), time_position=0) 63 | 64 | frontend.mpris.player.PropertiesChanged.assert_called_with( 65 | player.Player.INTERFACE, 66 | {"Metadata": "...", "PlaybackStatus": "Stopped"}, 67 | [], 68 | ) 69 | 70 | 71 | def test_playback_state_changed_changes_playback_status_and_metadata(frontend): 72 | frontend.mpris.player.Metadata = "..." 73 | frontend.mpris.player.PlaybackStatus = "Stopped" 74 | 75 | frontend.playback_state_changed( 76 | PlaybackState.PLAYING, PlaybackState.STOPPED 77 | ) 78 | 79 | frontend.mpris.player.PropertiesChanged.assert_called_with( 80 | player.Player.INTERFACE, 81 | {"Metadata": "...", "PlaybackStatus": "Stopped"}, 82 | [], 83 | ) 84 | 85 | 86 | def test_playlists_loaded_event_changes_playlist_count(frontend): 87 | frontend.mpris.playlists.PlaylistCount = 17 88 | 89 | frontend.playlists_loaded() 90 | 91 | frontend.mpris.playlists.PropertiesChanged.assert_called_with( 92 | playlists.Playlists.INTERFACE, {"PlaylistCount": 17}, [] 93 | ) 94 | 95 | 96 | def test_playlist_changed_event_causes_mpris_playlist_changed_event(frontend): 97 | playlist = Playlist(uri="dummy:foo", name="foo") 98 | 99 | frontend.playlist_changed(playlist=playlist) 100 | 101 | frontend.mpris.playlists.PlaylistChanged.assert_called_with( 102 | "/com/mopidy/playlist/MR2W23LZHJTG63Y_", "foo", "" 103 | ) 104 | 105 | 106 | def test_playlist_deleted_event_changes_playlist_count(frontend): 107 | frontend.mpris.playlists.PlaylistCount = 17 108 | 109 | frontend.playlist_deleted("dummy:foo") 110 | 111 | frontend.mpris.playlists.PropertiesChanged.assert_called_with( 112 | playlists.Playlists.INTERFACE, {"PlaylistCount": 17}, [] 113 | ) 114 | 115 | 116 | def test_options_changed_event_changes_loopstatus_and_shuffle(frontend): 117 | frontend.mpris.player.CanGoPrevious = False 118 | frontend.mpris.player.CanGoNext = True 119 | frontend.mpris.player.LoopStatus = "Track" 120 | frontend.mpris.player.Shuffle = True 121 | 122 | frontend.options_changed() 123 | 124 | frontend.mpris.player.PropertiesChanged.assert_called_with( 125 | player.Player.INTERFACE, 126 | { 127 | "LoopStatus": "Track", 128 | "Shuffle": True, 129 | "CanGoPrevious": False, 130 | "CanGoNext": True, 131 | }, 132 | [], 133 | ) 134 | 135 | 136 | def test_volume_changed_event_changes_volume(frontend): 137 | frontend.mpris.player.Volume = 1.0 138 | 139 | frontend.volume_changed(volume=100) 140 | 141 | frontend.mpris.player.PropertiesChanged.assert_called_with( 142 | player.Player.INTERFACE, {"Volume": 1.0}, [] 143 | ) 144 | 145 | 146 | def test_mute_changed_event_changes_volume(frontend): 147 | frontend.mpris.player.Volume = 0.0 148 | 149 | frontend.mute_changed(True) 150 | 151 | frontend.mpris.player.PropertiesChanged.assert_called_with( 152 | player.Player.INTERFACE, {"Volume": 0.0}, [] 153 | ) 154 | 155 | 156 | def test_seeked_event_causes_mpris_seeked_event(frontend): 157 | frontend.seeked(time_position=31000) 158 | 159 | frontend.mpris.player.Seeked.assert_called_with(31000000) 160 | 161 | 162 | def test_stream_title_changed_changes_metadata(frontend): 163 | frontend.mpris.player.Metadata = "..." 164 | 165 | frontend.stream_title_changed("a new title") 166 | 167 | frontend.mpris.player.PropertiesChanged.assert_called_with( 168 | player.Player.INTERFACE, {"Metadata": "..."}, [] 169 | ) 170 | -------------------------------------------------------------------------------- /tests/test_extension.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest import mock 3 | 4 | from mopidy_mpris import Extension 5 | from mopidy_mpris import frontend as frontend_lib 6 | 7 | 8 | class ExtensionTest(unittest.TestCase): 9 | def test_get_default_config(self): 10 | ext = Extension() 11 | 12 | config = ext.get_default_config() 13 | 14 | self.assertIn("[mpris]", config) 15 | self.assertIn("enabled = true", config) 16 | self.assertIn("bus_type = session", config) 17 | 18 | def test_get_config_schema(self): 19 | ext = Extension() 20 | 21 | schema = ext.get_config_schema() 22 | 23 | self.assertIn("desktop_file", schema) 24 | self.assertIn("bus_type", schema) 25 | 26 | def test_get_frontend_classes(self): 27 | ext = Extension() 28 | registry = mock.Mock() 29 | 30 | ext.setup(registry) 31 | 32 | registry.add.assert_called_once_with( 33 | "frontend", frontend_lib.MprisFrontend 34 | ) 35 | -------------------------------------------------------------------------------- /tests/test_player.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from gi.repository import GLib 3 | from mopidy.core import PlaybackState 4 | from mopidy.models import Album, Artist, Image, Track 5 | 6 | from mopidy_mpris.player import Player 7 | 8 | PLAYING = PlaybackState.PLAYING 9 | PAUSED = PlaybackState.PAUSED 10 | STOPPED = PlaybackState.STOPPED 11 | 12 | 13 | @pytest.fixture 14 | def player(config, core): 15 | return Player(config, core) 16 | 17 | 18 | @pytest.mark.parametrize( 19 | "state, expected", 20 | [(PLAYING, "Playing"), (PAUSED, "Paused"), (STOPPED, "Stopped")], 21 | ) 22 | def test_get_playback_status(core, player, state, expected): 23 | core.playback.set_state(state) 24 | 25 | assert player.PlaybackStatus == expected 26 | 27 | 28 | @pytest.mark.parametrize( 29 | "repeat, single, expected", 30 | [ 31 | (False, False, "None"), 32 | (False, True, "None"), 33 | (True, False, "Playlist"), 34 | (True, True, "Track"), 35 | ], 36 | ) 37 | def test_get_loop_status(core, player, repeat, single, expected): 38 | core.tracklist.set_repeat(repeat) 39 | core.tracklist.set_single(single) 40 | 41 | assert player.LoopStatus == expected 42 | 43 | 44 | @pytest.mark.parametrize( 45 | "status, expected_repeat, expected_single", 46 | [("None", False, False), ("Track", True, True), ("Playlist", True, False)], 47 | ) 48 | def test_set_loop_status( 49 | core, player, status, expected_repeat, expected_single 50 | ): 51 | player.LoopStatus = status 52 | 53 | assert core.tracklist.get_repeat().get() is expected_repeat 54 | assert core.tracklist.get_single().get() is expected_single 55 | 56 | 57 | def test_set_loop_status_is_ignored_if_can_control_is_false(core, player): 58 | player._CanControl = False 59 | core.tracklist.set_repeat(True) 60 | core.tracklist.set_single(True) 61 | 62 | player.LoopStatus = "None" 63 | 64 | assert core.tracklist.get_repeat().get() is True 65 | assert core.tracklist.get_single().get() is True 66 | 67 | 68 | def test_get_rate_is_greater_or_equal_than_minimum_rate(player): 69 | assert player.Rate >= player.MinimumRate 70 | 71 | 72 | def test_get_rate_is_less_or_equal_than_maximum_rate(player): 73 | assert player.Rate <= player.MaximumRate 74 | 75 | 76 | def test_set_rate_to_zero_pauses_playback(core, player): 77 | core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) 78 | core.playback.play().get() 79 | assert core.playback.get_state().get() == PLAYING 80 | 81 | player.Rate = 0 82 | 83 | assert core.playback.get_state().get() == PAUSED 84 | 85 | 86 | def test_set_rate_is_ignored_if_can_control_is_false(core, player): 87 | player._CanControl = False 88 | core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) 89 | core.playback.play().get() 90 | assert core.playback.get_state().get() == PLAYING 91 | 92 | player.Rate = 0 93 | 94 | assert core.playback.get_state().get() == PLAYING 95 | 96 | 97 | @pytest.mark.parametrize("random", [True, False]) 98 | def test_get_shuffle(core, player, random): 99 | core.tracklist.set_random(random) 100 | 101 | assert player.Shuffle is random 102 | 103 | 104 | @pytest.mark.parametrize("value", [True, False]) 105 | def test_set_shuffle(core, player, value): 106 | core.tracklist.set_random(not value) 107 | assert core.tracklist.get_random().get() is not value 108 | 109 | player.Shuffle = value 110 | 111 | assert core.tracklist.get_random().get() is value 112 | 113 | 114 | def test_set_shuffle_is_ignored_if_can_control_is_false(core, player): 115 | player._CanControl = False 116 | core.tracklist.set_random(False) 117 | 118 | player.Shuffle = True 119 | 120 | assert core.tracklist.get_random().get() is False 121 | 122 | 123 | def test_get_metadata_is_empty_when_no_current_track(player): 124 | assert player.Metadata == {} 125 | 126 | 127 | def test_get_metadata(core, player): 128 | core.tracklist.add( 129 | [ 130 | Track( 131 | uri="dummy:a", 132 | length=3600000, 133 | name="a", 134 | artists=[Artist(name="b"), Artist(name="c"), Artist(name=None)], 135 | album=Album( 136 | name="d", artists=[Artist(name="e"), Artist(name=None)] 137 | ), 138 | ) 139 | ] 140 | ) 141 | core.playback.play().get() 142 | 143 | (tlid, track) = core.playback.get_current_tl_track().get() 144 | 145 | result = player.Metadata 146 | 147 | assert result["mpris:trackid"] == GLib.Variant( 148 | "o", "/com/mopidy/track/%d" % tlid 149 | ) 150 | assert result["mpris:length"] == GLib.Variant("x", 3600000000) 151 | assert result["xesam:url"] == GLib.Variant("s", "dummy:a") 152 | assert result["xesam:title"] == GLib.Variant("s", "a") 153 | assert result["xesam:artist"] == GLib.Variant("as", ["b", "c"]) 154 | assert result["xesam:album"] == GLib.Variant("s", "d") 155 | assert result["xesam:albumArtist"] == GLib.Variant("as", ["e"]) 156 | 157 | 158 | def test_get_metadata_prefers_stream_title_over_track_name(audio, core, player): 159 | core.tracklist.add([Track(uri="dummy:a", name="Track name")]) 160 | core.playback.play().get() 161 | 162 | result = player.Metadata 163 | assert result["xesam:title"] == GLib.Variant("s", "Track name") 164 | 165 | audio.trigger_fake_tags_changed( 166 | { 167 | "organization": [ 168 | "Required for Mopidy core to care about the title" 169 | ], 170 | "title": ["Stream title"], 171 | } 172 | ).get() 173 | 174 | result = player.Metadata 175 | assert result["xesam:title"] == GLib.Variant("s", "Stream title") 176 | 177 | 178 | def test_get_metadata_use_library_image_as_art_url(backend, core, player): 179 | backend.library.dummy_get_images_result = { 180 | "dummy:a": [ 181 | Image(uri="http://example.com/small.jpg", width=100, height=100), 182 | Image(uri="http://example.com/large.jpg", width=200, height=200), 183 | Image(uri="http://example.com/unsized.jpg"), 184 | ], 185 | } 186 | core.tracklist.add([Track(uri="dummy:a")]) 187 | core.playback.play().get() 188 | 189 | result = player.Metadata 190 | 191 | assert result["mpris:artUrl"] == GLib.Variant( 192 | "s", "http://example.com/large.jpg" 193 | ) 194 | 195 | 196 | def test_get_metadata_has_disc_number_in_album(core, player): 197 | core.tracklist.add([Track(uri="dummy:a", disc_no=2)]) 198 | core.playback.play().get() 199 | 200 | assert player.Metadata["xesam:discNumber"] == GLib.Variant("i", 2) 201 | 202 | 203 | def test_get_metadata_has_track_number_in_album(core, player): 204 | core.tracklist.add([Track(uri="dummy:a", track_no=7)]) 205 | core.playback.play().get() 206 | 207 | assert player.Metadata["xesam:trackNumber"] == GLib.Variant("i", 7) 208 | 209 | 210 | def test_get_volume_should_return_volume_between_zero_and_one(core, player): 211 | # dummy_mixer starts out with None as the volume 212 | assert player.Volume == 0 213 | 214 | core.mixer.set_volume(0) 215 | assert player.Volume == 0 216 | 217 | core.mixer.set_volume(50) 218 | assert player.Volume == 0.5 219 | 220 | core.mixer.set_volume(100) 221 | assert player.Volume == 1 222 | 223 | 224 | def test_get_volume_should_return_0_if_muted(core, player): 225 | assert player.Volume == 0 226 | 227 | core.mixer.set_volume(100) 228 | assert player.Volume == 1 229 | 230 | core.mixer.set_mute(True) 231 | assert player.Volume == 0 232 | 233 | core.mixer.set_mute(False) 234 | assert player.Volume == 1 235 | 236 | 237 | @pytest.mark.parametrize( 238 | "volume, expected", [(-1.0, 0), (0, 0), (0.5, 50), (1.0, 100), (2.0, 100)] 239 | ) 240 | def test_set_volume(core, player, volume, expected): 241 | player.Volume = volume 242 | 243 | assert core.mixer.get_volume().get() == expected 244 | 245 | 246 | def test_set_volume_to_not_a_number_does_not_change_volume(core, player): 247 | core.mixer.set_volume(10).get() 248 | 249 | player.Volume = None 250 | 251 | assert core.mixer.get_volume().get() == 10 252 | 253 | 254 | def test_set_volume_is_ignored_if_can_control_is_false(core, player): 255 | player._CanControl = False 256 | core.mixer.set_volume(0) 257 | 258 | player.Volume = 1.0 259 | 260 | assert core.mixer.get_volume().get() == 0 261 | 262 | 263 | def test_set_volume_to_positive_value_unmutes_if_muted(core, player): 264 | core.mixer.set_volume(10).get() 265 | core.mixer.set_mute(True).get() 266 | 267 | player.Volume = 1.0 268 | 269 | assert core.mixer.get_volume().get() == 100 270 | assert core.mixer.get_mute().get() is False 271 | 272 | 273 | def test_set_volume_to_zero_does_not_unmute_if_muted(core, player): 274 | core.mixer.set_volume(10).get() 275 | core.mixer.set_mute(True).get() 276 | 277 | player.Volume = 0.0 278 | 279 | assert core.mixer.get_volume().get() == 0 280 | assert core.mixer.get_mute().get() is True 281 | 282 | 283 | def test_get_position_returns_time_position_in_microseconds(core, player): 284 | core.tracklist.add([Track(uri="dummy:a", length=40000)]) 285 | core.playback.play().get() 286 | core.playback.seek(10000).get() 287 | 288 | result_in_microseconds = player.Position 289 | 290 | result_in_milliseconds = result_in_microseconds // 1000 291 | assert result_in_milliseconds >= 10000 292 | 293 | 294 | def test_get_position_when_no_current_track_should_be_zero(player): 295 | result_in_microseconds = player.Position 296 | 297 | result_in_milliseconds = result_in_microseconds // 1000 298 | assert result_in_milliseconds == 0 299 | 300 | 301 | def test_get_minimum_rate_is_one_or_less(player): 302 | assert player.MinimumRate <= 1.0 303 | 304 | 305 | def test_get_maximum_rate_is_one_or_more(player): 306 | assert player.MaximumRate >= 1.0 307 | 308 | 309 | def test_can_go_next_is_true_if_can_control_and_other_next_track(core, player): 310 | player._CanControl = True 311 | core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) 312 | core.playback.play().get() 313 | 314 | assert player.CanGoNext 315 | 316 | 317 | def test_can_go_next_is_false_if_next_track_is_the_same(core, player): 318 | player._CanControl = True 319 | core.tracklist.add([Track(uri="dummy:a")]) 320 | core.tracklist.set_repeat(True) 321 | core.playback.play().get() 322 | 323 | assert not player.CanGoNext 324 | 325 | 326 | def test_can_go_next_is_false_if_can_control_is_false(core, player): 327 | player._CanControl = False 328 | core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) 329 | core.playback.play().get() 330 | 331 | assert not player.CanGoNext 332 | 333 | 334 | def test_can_go_previous_is_true_if_can_control_and_previous_track( 335 | core, player 336 | ): 337 | player._CanControl = True 338 | core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) 339 | core.playback.play().get() 340 | core.playback.next().get() 341 | 342 | assert player.CanGoPrevious 343 | 344 | 345 | def test_can_go_previous_is_false_if_previous_track_is_the_same(core, player): 346 | player._CanControl = True 347 | core.tracklist.add([Track(uri="dummy:a")]) 348 | core.tracklist.set_repeat(True) 349 | core.playback.play().get() 350 | 351 | assert not player.CanGoPrevious 352 | 353 | 354 | def test_can_go_previous_is_false_if_can_control_is_false(core, player): 355 | player._CanControl = False 356 | core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) 357 | core.playback.play().get() 358 | core.playback.next().get() 359 | 360 | assert not player.CanGoPrevious 361 | 362 | 363 | def test_can_play_is_true_if_can_control_and_current_track(core, player): 364 | player._CanControl = True 365 | core.tracklist.add([Track(uri="dummy:a")]) 366 | core.playback.play().get() 367 | assert core.playback.get_current_track().get() 368 | 369 | assert player.CanPlay 370 | 371 | 372 | def test_can_play_is_false_if_no_current_track(core, player): 373 | player._CanControl = True 374 | assert not core.playback.get_current_track().get() 375 | 376 | assert not player.CanPlay 377 | 378 | 379 | def test_can_play_if_false_if_can_control_is_false(core, player): 380 | player._CanControl = False 381 | 382 | assert not player.CanPlay 383 | 384 | 385 | def test_can_pause_is_true_if_can_control_and_track_can_be_paused(core, player): 386 | player._CanControl = True 387 | 388 | assert player.CanPause 389 | 390 | 391 | def test_can_pause_if_false_if_can_control_is_false(core, player): 392 | player._CanControl = False 393 | 394 | assert not player.CanPause 395 | 396 | 397 | def test_can_seek_is_true_if_can_control_is_true(core, player): 398 | player._CanControl = True 399 | 400 | assert player.CanSeek 401 | 402 | 403 | def test_can_seek_is_false_if_can_control_is_false(core, player): 404 | player._CanControl = False 405 | result = player.CanSeek 406 | assert not result 407 | 408 | 409 | def test_can_control_is_true(core, player): 410 | result = player.CanControl 411 | assert result 412 | 413 | 414 | def test_next_is_ignored_if_can_go_next_is_false(core, player): 415 | player._CanControl = False 416 | assert not player.CanGoNext 417 | core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) 418 | core.playback.play().get() 419 | assert core.playback.get_current_track().get().uri == "dummy:a" 420 | 421 | player.Next() 422 | 423 | assert core.playback.get_current_track().get().uri == "dummy:a" 424 | 425 | 426 | def test_next_when_playing_skips_to_next_track_and_keep_playing(core, player): 427 | core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) 428 | core.playback.play().get() 429 | assert core.playback.get_current_track().get().uri == "dummy:a" 430 | assert core.playback.get_state().get() == PLAYING 431 | 432 | player.Next() 433 | 434 | assert core.playback.get_current_track().get().uri == "dummy:b" 435 | assert core.playback.get_state().get() == PLAYING 436 | 437 | 438 | def test_next_when_at_end_of_list_should_stop_playback(core, player): 439 | core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) 440 | core.playback.play().get() 441 | core.playback.next().get() 442 | assert core.playback.get_current_track().get().uri == "dummy:b" 443 | assert core.playback.get_state().get() == PLAYING 444 | player.Next() 445 | assert core.playback.get_state().get() == STOPPED 446 | 447 | 448 | def test_next_when_paused_should_skip_to_next_track_and_stay_paused( 449 | core, player 450 | ): 451 | core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) 452 | core.playback.play().get() 453 | core.playback.pause().get() 454 | assert core.playback.get_current_track().get().uri == "dummy:a" 455 | assert core.playback.get_state().get() == PAUSED 456 | player.Next() 457 | assert core.playback.get_current_track().get().uri == "dummy:b" 458 | assert core.playback.get_state().get() == PAUSED 459 | 460 | 461 | def test_next_when_stopped_skips_to_next_track_and_stay_stopped(core, player): 462 | core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) 463 | core.playback.play().get() 464 | core.playback.stop() 465 | assert core.playback.get_current_track().get().uri == "dummy:a" 466 | assert core.playback.get_state().get() == STOPPED 467 | player.Next() 468 | assert core.playback.get_current_track().get().uri == "dummy:b" 469 | assert core.playback.get_state().get() == STOPPED 470 | 471 | 472 | def test_previous_is_ignored_if_can_go_previous_is_false(core, player): 473 | player._CanControl = False 474 | assert not player.CanGoPrevious 475 | core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) 476 | core.playback.play().get() 477 | core.playback.next().get() 478 | assert core.playback.get_current_track().get().uri == "dummy:b" 479 | 480 | player.Previous() 481 | 482 | assert core.playback.get_current_track().get().uri == "dummy:b" 483 | 484 | 485 | def test_previous_when_playing_skips_to_prev_track_and_keep_playing( 486 | core, player 487 | ): 488 | core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) 489 | core.playback.play().get() 490 | core.playback.next().get() 491 | assert core.playback.get_current_track().get().uri == "dummy:b" 492 | assert core.playback.get_state().get() == PLAYING 493 | 494 | player.Previous() 495 | 496 | assert core.playback.get_current_track().get().uri == "dummy:a" 497 | assert core.playback.get_state().get() == PLAYING 498 | 499 | 500 | def test_previous_when_at_start_of_list_should_stop_playback(core, player): 501 | core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) 502 | core.playback.play().get() 503 | assert core.playback.get_current_track().get().uri == "dummy:a" 504 | assert core.playback.get_state().get() == PLAYING 505 | 506 | player.Previous() 507 | 508 | assert core.playback.get_state().get() == STOPPED 509 | 510 | 511 | def test_previous_when_paused_skips_to_previous_track_and_pause(core, player): 512 | core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) 513 | core.playback.play().get() 514 | core.playback.next().get() 515 | core.playback.pause().get() 516 | assert core.playback.get_current_track().get().uri == "dummy:b" 517 | assert core.playback.get_state().get() == PAUSED 518 | 519 | player.Previous() 520 | 521 | assert core.playback.get_current_track().get().uri == "dummy:a" 522 | assert core.playback.get_state().get() == PAUSED 523 | 524 | 525 | def test_previous_when_stopped_skips_to_previous_track_and_stops(core, player): 526 | core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) 527 | core.playback.play().get() 528 | core.playback.next().get() 529 | core.playback.stop() 530 | assert core.playback.get_current_track().get().uri == "dummy:b" 531 | assert core.playback.get_state().get() == STOPPED 532 | 533 | player.Previous() 534 | 535 | assert core.playback.get_current_track().get().uri == "dummy:a" 536 | assert core.playback.get_state().get() == STOPPED 537 | 538 | 539 | def test_pause_is_ignored_if_can_pause_is_false(core, player): 540 | player._CanControl = False 541 | assert not player.CanPause 542 | core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) 543 | core.playback.play().get() 544 | assert core.playback.get_state().get() == PLAYING 545 | 546 | player.Pause() 547 | 548 | assert core.playback.get_state().get() == PLAYING 549 | 550 | 551 | def test_pause_when_playing_should_pause_playback(core, player): 552 | core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) 553 | core.playback.play().get() 554 | assert core.playback.get_state().get() == PLAYING 555 | 556 | player.Pause() 557 | 558 | assert core.playback.get_state().get() == PAUSED 559 | 560 | 561 | def test_pause_when_paused_has_no_effect(core, player): 562 | core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) 563 | core.playback.play().get() 564 | core.playback.pause().get() 565 | assert core.playback.get_state().get() == PAUSED 566 | 567 | player.Pause() 568 | 569 | assert core.playback.get_state().get() == PAUSED 570 | 571 | 572 | def test_playpause_is_ignored_if_can_pause_is_false(core, player): 573 | player._CanControl = False 574 | assert not player.CanPause 575 | core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) 576 | core.playback.play().get() 577 | assert core.playback.get_state().get() == PLAYING 578 | 579 | player.PlayPause() 580 | 581 | assert core.playback.get_state().get() == PLAYING 582 | 583 | 584 | def test_playpause_when_playing_should_pause_playback(core, player): 585 | core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) 586 | core.playback.play().get() 587 | assert core.playback.get_state().get() == PLAYING 588 | 589 | player.PlayPause() 590 | 591 | assert core.playback.get_state().get() == PAUSED 592 | 593 | 594 | def test_playpause_when_paused_should_resume_playback(core, player): 595 | core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) 596 | core.playback.play().get() 597 | core.playback.pause().get() 598 | 599 | assert core.playback.get_state().get() == PAUSED 600 | at_pause = core.playback.get_time_position().get() 601 | assert at_pause >= 0 602 | 603 | player.PlayPause() 604 | 605 | assert core.playback.get_state().get() == PLAYING 606 | after_pause = core.playback.get_time_position().get() 607 | assert after_pause >= at_pause 608 | 609 | 610 | def test_playpause_when_stopped_should_start_playback(core, player): 611 | core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) 612 | assert core.playback.get_state().get() == STOPPED 613 | 614 | player.PlayPause() 615 | 616 | assert core.playback.get_state().get() == PLAYING 617 | 618 | 619 | def test_stop_is_ignored_if_can_control_is_false(core, player): 620 | player._CanControl = False 621 | core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) 622 | core.playback.play().get() 623 | assert core.playback.get_state().get() == PLAYING 624 | 625 | player.Stop() 626 | 627 | assert core.playback.get_state().get() == PLAYING 628 | 629 | 630 | def test_stop_when_playing_should_stop_playback(core, player): 631 | core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) 632 | core.playback.play().get() 633 | assert core.playback.get_state().get() == PLAYING 634 | 635 | player.Stop() 636 | 637 | assert core.playback.get_state().get() == STOPPED 638 | 639 | 640 | def test_stop_when_paused_should_stop_playback(core, player): 641 | core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) 642 | core.playback.play().get() 643 | core.playback.pause().get() 644 | assert core.playback.get_state().get() == PAUSED 645 | 646 | player.Stop() 647 | 648 | assert core.playback.get_state().get() == STOPPED 649 | 650 | 651 | def test_play_is_ignored_if_can_play_is_false(core, player): 652 | player._CanControl = False 653 | assert not player.CanPlay 654 | core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) 655 | assert core.playback.get_state().get() == STOPPED 656 | 657 | player.Play() 658 | 659 | assert core.playback.get_state().get() == STOPPED 660 | 661 | 662 | def test_play_when_stopped_starts_playback(core, player): 663 | core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) 664 | assert core.playback.get_state().get() == STOPPED 665 | 666 | player.Play() 667 | 668 | assert core.playback.get_state().get() == PLAYING 669 | 670 | 671 | def test_play_after_pause_resumes_from_same_position(core, player): 672 | core.tracklist.add([Track(uri="dummy:a", length=40000)]) 673 | core.playback.play().get() 674 | 675 | before_pause = core.playback.get_time_position().get() 676 | assert before_pause >= 0 677 | 678 | player.Pause() 679 | 680 | assert core.playback.get_state().get() == PAUSED 681 | at_pause = core.playback.get_time_position().get() 682 | assert at_pause >= before_pause 683 | 684 | player.Play() 685 | 686 | assert core.playback.get_state().get() == PLAYING 687 | after_pause = core.playback.get_time_position().get() 688 | assert after_pause >= at_pause 689 | 690 | 691 | def test_play_when_there_is_no_track_has_no_effect(core, player): 692 | core.tracklist.clear() 693 | assert core.playback.get_state().get() == STOPPED 694 | 695 | player.Play() 696 | 697 | assert core.playback.get_state().get() == STOPPED 698 | 699 | 700 | def test_seek_is_ignored_if_can_seek_is_false(core, player): 701 | player._CanControl = False 702 | assert not player.CanSeek 703 | core.tracklist.add([Track(uri="dummy:a", length=40000)]) 704 | core.playback.play().get() 705 | 706 | before_seek = core.playback.get_time_position().get() 707 | assert before_seek >= 0 708 | 709 | milliseconds_to_seek = 10000 710 | microseconds_to_seek = milliseconds_to_seek * 1000 711 | 712 | player.Seek(microseconds_to_seek) 713 | 714 | after_seek = core.playback.get_time_position().get() 715 | assert before_seek <= after_seek 716 | assert after_seek < before_seek + milliseconds_to_seek 717 | 718 | 719 | def test_seek_seeks_given_microseconds_forward_in_the_current_track( 720 | core, player 721 | ): 722 | core.tracklist.add([Track(uri="dummy:a", length=40000)]) 723 | core.playback.play().get() 724 | 725 | before_seek = core.playback.get_time_position().get() 726 | assert before_seek >= 0 727 | 728 | milliseconds_to_seek = 10000 729 | microseconds_to_seek = milliseconds_to_seek * 1000 730 | 731 | player.Seek(microseconds_to_seek) 732 | 733 | assert core.playback.get_state().get() == PLAYING 734 | 735 | after_seek = core.playback.get_time_position().get() 736 | assert after_seek >= before_seek + milliseconds_to_seek 737 | 738 | 739 | def test_seek_seeks_given_microseconds_backward_if_negative(core, player): 740 | core.tracklist.add([Track(uri="dummy:a", length=40000)]) 741 | core.playback.play().get() 742 | core.playback.seek(20000).get() 743 | 744 | before_seek = core.playback.get_time_position().get() 745 | assert before_seek >= 20000 746 | 747 | milliseconds_to_seek = -10000 748 | microseconds_to_seek = milliseconds_to_seek * 1000 749 | 750 | player.Seek(microseconds_to_seek) 751 | 752 | assert core.playback.get_state().get() == PLAYING 753 | 754 | after_seek = core.playback.get_time_position().get() 755 | assert after_seek >= before_seek + milliseconds_to_seek 756 | assert after_seek < before_seek 757 | 758 | 759 | def test_seek_seeks_to_start_of_track_if_new_position_is_negative(core, player): 760 | core.tracklist.add([Track(uri="dummy:a", length=40000)]) 761 | core.playback.play().get() 762 | core.playback.seek(20000).get() 763 | 764 | before_seek = core.playback.get_time_position().get() 765 | assert before_seek >= 20000 766 | 767 | milliseconds_to_seek = -30000 768 | microseconds_to_seek = milliseconds_to_seek * 1000 769 | 770 | player.Seek(microseconds_to_seek) 771 | 772 | assert core.playback.get_state().get() == PLAYING 773 | 774 | after_seek = core.playback.get_time_position().get() 775 | assert after_seek >= before_seek + milliseconds_to_seek 776 | assert after_seek < before_seek 777 | assert after_seek >= 0 778 | 779 | 780 | def test_seek_skips_to_next_track_if_new_position_gt_track_length(core, player): 781 | core.tracklist.add( 782 | [Track(uri="dummy:a", length=40000), Track(uri="dummy:b")] 783 | ) 784 | core.playback.play().get() 785 | core.playback.seek(20000).get() 786 | 787 | before_seek = core.playback.get_time_position().get() 788 | assert before_seek >= 20000 789 | assert core.playback.get_state().get() == PLAYING 790 | assert core.playback.get_current_track().get().uri == "dummy:a" 791 | 792 | milliseconds_to_seek = 50000 793 | microseconds_to_seek = milliseconds_to_seek * 1000 794 | 795 | player.Seek(microseconds_to_seek) 796 | 797 | assert core.playback.get_state().get() == PLAYING 798 | assert core.playback.get_current_track().get().uri == "dummy:b" 799 | 800 | after_seek = core.playback.get_time_position().get() 801 | assert after_seek >= 0 802 | assert after_seek < before_seek 803 | 804 | 805 | def test_set_position_is_ignored_if_can_seek_is_false(core, player): 806 | player.get_CanSeek = lambda *_: False 807 | core.tracklist.add([Track(uri="dummy:a", length=40000)]) 808 | core.playback.play().get() 809 | 810 | before_set_position = core.playback.get_time_position().get() 811 | assert before_set_position <= 5000 812 | 813 | track_id = "a" 814 | 815 | position_to_set_in_millisec = 20000 816 | position_to_set_in_microsec = position_to_set_in_millisec * 1000 817 | 818 | player.SetPosition(track_id, position_to_set_in_microsec) 819 | 820 | after_set_position = core.playback.get_time_position().get() 821 | assert before_set_position <= after_set_position 822 | assert after_set_position < position_to_set_in_millisec 823 | 824 | 825 | def test_set_position_sets_the_current_track_position_in_microsecs( 826 | core, player 827 | ): 828 | core.tracklist.add([Track(uri="dummy:a", length=40000)]) 829 | core.playback.play().get() 830 | 831 | before_set_position = core.playback.get_time_position().get() 832 | assert before_set_position <= 5000 833 | assert core.playback.get_state().get() == PLAYING 834 | 835 | track_id = "/com/mopidy/track/1" 836 | 837 | position_to_set_in_millisec = 20000 838 | position_to_set_in_microsec = position_to_set_in_millisec * 1000 839 | 840 | player.SetPosition(track_id, position_to_set_in_microsec) 841 | 842 | assert core.playback.get_state().get() == PLAYING 843 | 844 | after_set_position = core.playback.get_time_position().get() 845 | assert after_set_position >= position_to_set_in_millisec 846 | 847 | 848 | def test_set_position_does_nothing_if_the_position_is_negative(core, player): 849 | core.tracklist.add([Track(uri="dummy:a", length=40000)]) 850 | core.playback.play().get() 851 | core.playback.seek(20000) 852 | 853 | before_set_position = core.playback.get_time_position().get() 854 | assert before_set_position >= 20000 855 | assert before_set_position <= 25000 856 | assert core.playback.get_state().get() == PLAYING 857 | assert core.playback.get_current_track().get().uri == "dummy:a" 858 | 859 | track_id = "/com/mopidy/track/1" 860 | 861 | position_to_set_in_millisec = -1000 862 | position_to_set_in_microsec = position_to_set_in_millisec * 1000 863 | 864 | player.SetPosition(track_id, position_to_set_in_microsec) 865 | 866 | after_set_position = core.playback.get_time_position().get() 867 | assert after_set_position >= before_set_position 868 | assert core.playback.get_state().get() == PLAYING 869 | assert core.playback.get_current_track().get().uri == "dummy:a" 870 | 871 | 872 | def test_set_position_does_nothing_if_position_is_gt_track_length(core, player): 873 | core.tracklist.add([Track(uri="dummy:a", length=40000)]) 874 | core.playback.play().get() 875 | core.playback.seek(20000) 876 | 877 | before_set_position = core.playback.get_time_position().get() 878 | assert before_set_position >= 20000 879 | assert before_set_position <= 25000 880 | assert core.playback.get_state().get() == PLAYING 881 | assert core.playback.get_current_track().get().uri == "dummy:a" 882 | 883 | track_id = "a" 884 | 885 | position_to_set_in_millisec = 50000 886 | position_to_set_in_microsec = position_to_set_in_millisec * 1000 887 | 888 | player.SetPosition(track_id, position_to_set_in_microsec) 889 | 890 | after_set_position = core.playback.get_time_position().get() 891 | assert after_set_position >= before_set_position 892 | assert core.playback.get_state().get() == PLAYING 893 | assert core.playback.get_current_track().get().uri == "dummy:a" 894 | 895 | 896 | def test_set_position_is_noop_if_track_id_isnt_current_track(core, player): 897 | core.tracklist.add([Track(uri="dummy:a", length=40000)]) 898 | core.playback.play().get() 899 | core.playback.seek(20000) 900 | 901 | before_set_position = core.playback.get_time_position().get() 902 | assert before_set_position >= 20000 903 | assert before_set_position <= 25000 904 | assert core.playback.get_state().get() == PLAYING 905 | assert core.playback.get_current_track().get().uri == "dummy:a" 906 | 907 | track_id = "b" 908 | 909 | position_to_set_in_millisec = 0 910 | position_to_set_in_microsec = position_to_set_in_millisec * 1000 911 | 912 | player.SetPosition(track_id, position_to_set_in_microsec) 913 | 914 | after_set_position = core.playback.get_time_position().get() 915 | assert after_set_position >= before_set_position 916 | assert core.playback.get_state().get() == PLAYING 917 | assert core.playback.get_current_track().get().uri == "dummy:a" 918 | 919 | 920 | def test_open_uri_is_ignored_if_can_control_is_false(backend, core, player): 921 | player._CanControl = False 922 | backend.library.dummy_library = [Track(uri="dummy:/test/uri")] 923 | 924 | player.OpenUri("dummy:/test/uri") 925 | 926 | assert core.tracklist.get_length().get() == 0 927 | 928 | 929 | def test_open_uri_ignores_uris_with_unknown_uri_scheme(backend, core, player): 930 | assert core.get_uri_schemes().get() == ["dummy"] 931 | backend.library.dummy_library = [Track(uri="notdummy:/test/uri")] 932 | 933 | player.OpenUri("notdummy:/test/uri") 934 | 935 | assert core.tracklist.get_length().get() == 0 936 | 937 | 938 | def test_open_uri_adds_uri_to_tracklist(backend, core, player): 939 | backend.library.dummy_library = [Track(uri="dummy:/test/uri")] 940 | 941 | player.OpenUri("dummy:/test/uri") 942 | 943 | assert core.tracklist.get_length().get() == 1 944 | assert core.tracklist.get_tracks().get()[0].uri == "dummy:/test/uri" 945 | 946 | 947 | def test_open_uri_starts_playback_of_new_track_if_stopped( 948 | backend, core, player 949 | ): 950 | backend.library.dummy_library = [Track(uri="dummy:/test/uri")] 951 | core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) 952 | assert core.playback.get_state().get() == STOPPED 953 | 954 | player.OpenUri("dummy:/test/uri") 955 | 956 | assert core.playback.get_state().get() == PLAYING 957 | assert core.playback.get_current_track().get().uri == "dummy:/test/uri" 958 | 959 | 960 | def test_open_uri_starts_playback_of_new_track_if_paused(backend, core, player): 961 | backend.library.dummy_library = [Track(uri="dummy:/test/uri")] 962 | core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) 963 | core.playback.play().get() 964 | core.playback.pause().get() 965 | assert core.playback.get_state().get() == PAUSED 966 | assert core.playback.get_current_track().get().uri == "dummy:a" 967 | 968 | player.OpenUri("dummy:/test/uri") 969 | 970 | assert core.playback.get_state().get() == PLAYING 971 | assert core.playback.get_current_track().get().uri == "dummy:/test/uri" 972 | 973 | 974 | def test_open_uri_starts_playback_of_new_track_if_playing( 975 | backend, core, player 976 | ): 977 | backend.library.dummy_library = [Track(uri="dummy:/test/uri")] 978 | core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) 979 | core.playback.play().get() 980 | assert core.playback.get_state().get() == PLAYING 981 | assert core.playback.get_current_track().get().uri == "dummy:a" 982 | 983 | player.OpenUri("dummy:/test/uri") 984 | 985 | assert core.playback.get_state().get() == PLAYING 986 | assert core.playback.get_current_track().get().uri == "dummy:/test/uri" 987 | -------------------------------------------------------------------------------- /tests/test_playlists.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from mopidy.audio import PlaybackState 4 | from mopidy.models import Track 5 | from mopidy_mpris.playlists import Playlists 6 | 7 | 8 | @pytest.fixture 9 | def dummy_playlists(core): 10 | result = {} 11 | 12 | for name, lm in [("foo", 3000000), ("bar", 2000000), ("baz", 1000000)]: 13 | pl = core.playlists.create(name).get() 14 | pl = pl.replace(last_modified=lm) 15 | result[name] = core.playlists.save(pl).get() 16 | 17 | return result 18 | 19 | 20 | @pytest.fixture 21 | def playlists(config, core, dummy_playlists): 22 | return Playlists(config, core) 23 | 24 | 25 | def test_activate_playlist_appends_tracks_to_tracklist( 26 | core, playlists, dummy_playlists 27 | ): 28 | core.tracklist.add([Track(uri="dummy:old-a"), Track(uri="dummy:old-b")]) 29 | assert core.tracklist.get_length().get() == 2 30 | 31 | pl = dummy_playlists["baz"] 32 | pl = pl.replace( 33 | tracks=[ 34 | Track(uri="dummy:baz-a"), 35 | Track(uri="dummy:baz-b"), 36 | Track(uri="dummy:baz-c"), 37 | ] 38 | ) 39 | pl = core.playlists.save(pl).get() 40 | playlist_id = playlists.GetPlaylists(0, 100, "User", False)[2][0] 41 | 42 | playlists.ActivatePlaylist(playlist_id) 43 | 44 | assert core.tracklist.get_length().get() == 5 45 | assert core.playback.get_state().get() == PlaybackState.PLAYING 46 | assert core.playback.get_current_track().get() == pl.tracks[0] 47 | 48 | 49 | def test_activate_empty_playlist_is_harmless(core, playlists): 50 | assert core.tracklist.get_length().get() == 0 51 | playlist_id = playlists.GetPlaylists(0, 100, "User", False)[2][0] 52 | 53 | playlists.ActivatePlaylist(playlist_id) 54 | 55 | assert core.tracklist.get_length().get() == 0 56 | assert core.playback.get_state().get() == PlaybackState.STOPPED 57 | assert core.playback.get_current_track().get() is None 58 | 59 | 60 | def test_get_playlists_in_alphabetical_order(playlists): 61 | result = playlists.GetPlaylists(0, 100, "Alphabetical", False) 62 | 63 | assert result == [ 64 | ("/com/mopidy/playlist/MR2W23LZHJRGC4Q_", "bar", ""), 65 | ("/com/mopidy/playlist/MR2W23LZHJRGC6Q_", "baz", ""), 66 | ("/com/mopidy/playlist/MR2W23LZHJTG63Y_", "foo", ""), 67 | ] 68 | 69 | 70 | def test_get_playlists_in_reverse_alphabetical_order(playlists): 71 | result = playlists.GetPlaylists(0, 100, "Alphabetical", True) 72 | 73 | assert len(result) == 3 74 | assert result[0][1] == "foo" 75 | assert result[1][1] == "baz" 76 | assert result[2][1] == "bar" 77 | 78 | 79 | def test_get_playlists_in_user_order(playlists): 80 | result = playlists.GetPlaylists(0, 100, "User", False) 81 | 82 | assert len(result) == 3 83 | assert result[0][1] == "foo" 84 | assert result[1][1] == "bar" 85 | assert result[2][1] == "baz" 86 | 87 | 88 | def test_get_playlists_in_reverse_user_order(playlists): 89 | result = playlists.GetPlaylists(0, 100, "User", True) 90 | 91 | assert len(result) == 3 92 | assert result[0][1] == "baz" 93 | assert result[1][1] == "bar" 94 | assert result[2][1] == "foo" 95 | 96 | 97 | def test_get_playlists_slice_on_start_of_list(playlists): 98 | result = playlists.GetPlaylists(0, 2, "User", False) 99 | 100 | assert len(result) == 2 101 | assert result[0][1] == "foo" 102 | assert result[1][1] == "bar" 103 | 104 | 105 | def test_get_playlists_slice_later_in_list(playlists): 106 | result = playlists.GetPlaylists(2, 2, "User", False) 107 | 108 | assert len(result) == 1 109 | assert result[0][1] == "baz" 110 | 111 | 112 | def test_get_playlist_count_returns_number_of_playlists(playlists): 113 | assert playlists.PlaylistCount == 3 114 | 115 | 116 | def test_get_orderings_includes_alpha_modified_and_user(playlists): 117 | result = playlists.Orderings 118 | 119 | assert "Alphabetical" in result 120 | assert "Created" not in result 121 | assert "Modified" not in result 122 | assert "Played" not in result 123 | assert "User" in result 124 | 125 | 126 | def test_get_active_playlist_does_not_return_a_playlist(playlists): 127 | result = playlists.ActivePlaylist 128 | 129 | valid, playlist = result 130 | playlist_id, playlist_name, playlist_icon_uri = playlist 131 | 132 | assert valid is False 133 | assert playlist_id == "/" 134 | assert playlist_name == "None" 135 | assert playlist_icon_uri == "" 136 | -------------------------------------------------------------------------------- /tests/test_root.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from mopidy_mpris.root import Root 4 | 5 | 6 | @pytest.fixture 7 | def root(config, core): 8 | return Root(config, core) 9 | 10 | 11 | def test_fullscreen_returns_false(root): 12 | assert root.Fullscreen is False 13 | 14 | 15 | def test_setting_fullscreen_fails(root): 16 | root.Fullscreen = True 17 | 18 | assert root.Fullscreen is False 19 | 20 | 21 | def test_can_set_fullscreen_returns_false(root): 22 | assert root.CanSetFullscreen is False 23 | 24 | 25 | def test_can_raise_returns_false(root): 26 | assert root.CanRaise is False 27 | 28 | 29 | def test_raise_does_nothing(root): 30 | root.Raise() 31 | 32 | 33 | def test_can_quit_returns_false(root): 34 | assert root.CanQuit is False 35 | 36 | 37 | def test_quit_does_nothing(root): 38 | root.Quit() 39 | 40 | 41 | def test_has_track_list_returns_false(root): 42 | assert root.HasTrackList is False 43 | 44 | 45 | def test_identify_is_mopidy(root): 46 | assert root.Identity == "Mopidy" 47 | 48 | 49 | def test_desktop_entry_is_blank(root, config): 50 | assert root.DesktopEntry == "" 51 | 52 | 53 | def test_supported_uri_schemes_includes_backend_uri_schemes(root): 54 | assert root.SupportedUriSchemes == ["dummy"] 55 | 56 | 57 | def test_supported_mime_types_has_hardcoded_entries(root): 58 | assert root.SupportedMimeTypes == [ 59 | "audio/mpeg", 60 | "audio/x-ms-wma", 61 | "audio/x-ms-asf", 62 | "audio/x-flac", 63 | "audio/flac", 64 | "audio/l16;channels=2;rate=44100", 65 | "audio/l16;rate=44100;channels=2", 66 | ] 67 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py39, py310, py311, check-manifest, flake8 3 | 4 | [testenv] 5 | sitepackages = true 6 | deps = .[test] 7 | commands = 8 | python -m pytest \ 9 | --basetemp={envtmpdir} \ 10 | --cov=mopidy_mpris --cov-report=term-missing \ 11 | {posargs} 12 | 13 | [testenv:check-manifest] 14 | deps = .[lint] 15 | commands = python -m check_manifest 16 | 17 | [testenv:flake8] 18 | deps = .[lint] 19 | commands = python -m flake8 --show-source --statistics 20 | --------------------------------------------------------------------------------