././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1777126579.3214633 mopidy_mpris-4.0.0/0000755000175100017510000000000015173146263013704 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1777126572.0 mopidy_mpris-4.0.0/.copier-answers.yml0000644000175100017510000000044315173146254017447 0ustar00runnerrunner_commit: v2.2.0 _src_path: gh:mopidy/mopidy-ext-template author_email: stein.magnus@jodal.no author_full_name: Stein Magnus Jodal dist_name: mopidy-mpris ext_name: mpris github_username: mopidy short_description: Mopidy extension for controlling Mopidy through the MPRIS D-Bus interface ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1777126579.3147185 mopidy_mpris-4.0.0/.github/0000755000175100017510000000000015173146263015244 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1777126579.3168523 mopidy_mpris-4.0.0/.github/workflows/0000755000175100017510000000000015173146263017301 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1777126572.0 mopidy_mpris-4.0.0/.github/workflows/ci.yml0000644000175100017510000000300215173146254020412 0ustar00runnerrunnername: CI on: pull_request: push: branches: - main workflow_dispatch: jobs: build: name: Build runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v6 - uses: hynek/build-and-inspect-python-package@v2 main: strategy: fail-fast: false matrix: include: - name: "pytest (3.13)" python: "3.13" tox: "3.13" - name: "pytest (3.14)" python: "3.14" tox: "3.14" coverage: true - name: "pyright" python: "3.14" tox: "pyright" - name: "ruff check" python: "3.14" tox: "ruff-check" - name: "ruff format" python: "3.14" tox: "ruff-format" name: ${{ matrix.name }} runs-on: ubuntu-24.04 container: ghcr.io/mopidy/ci:latest steps: - uses: actions/checkout@v6 - name: Fix home dir permissions to enable pip caching run: chown -R root /github/home - uses: actions/setup-python@v6 with: python-version: ${{ matrix.python }} cache: pip allow-prereleases: true - run: python -m pip install tox - run: python -m tox -e ${{ matrix.tox }} if: ${{ ! matrix.coverage }} - run: python -m tox -e ${{ matrix.tox }} -- --cov-report=xml if: ${{ matrix.coverage }} - uses: codecov/codecov-action@v5 if: ${{ matrix.coverage }} with: token: ${{ secrets.CODECOV_TOKEN }} ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1777126572.0 mopidy_mpris-4.0.0/.github/workflows/release.yml0000644000175100017510000000101715173146254021443 0ustar00runnerrunnername: Release on: release: types: [published] jobs: release: runs-on: ubuntu-24.04 environment: name: pypi url: https://pypi.org/project/mopidy-mpris/ permissions: id-token: write steps: - uses: actions/checkout@v6 - uses: hynek/build-and-inspect-python-package@v2 id: build - uses: actions/download-artifact@v4 with: name: ${{ steps.build.outputs.artifact-name }} path: dist - uses: pypa/gh-action-pypi-publish@release/v1 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1777126572.0 mopidy_mpris-4.0.0/.gitignore0000644000175100017510000000012515173146254015672 0ustar00runnerrunner*.egg-info/ /*.lock /.*_cache/ /.coverage /.tox/ /.venv/ /build/ /dist/ __pycache__/ ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1777126572.0 mopidy_mpris-4.0.0/.mailmap0000644000175100017510000000011315173146254015320 0ustar00runnerrunnerTobias Laundal Marcin Klimczak ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1777126572.0 mopidy_mpris-4.0.0/LICENSE0000644000175100017510000002613515173146254014720 0ustar00runnerrunner Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.././@PaxHeader0000000000000000000000000000003200000000000010210 xustar0026 mtime=1777126579.32143 mopidy_mpris-4.0.0/PKG-INFO0000644000175100017510000003130115173146263014777 0ustar00runnerrunnerMetadata-Version: 2.4 Name: mopidy-mpris Version: 4.0.0 Summary: Mopidy extension for controlling Mopidy through the MPRIS D-Bus interface Author-email: Stein Magnus Jodal License: Apache-2.0 Project-URL: Homepage, https://github.com/mopidy/mopidy-mpris Classifier: Environment :: No Input/Output (Daemon) Classifier: Intended Audience :: End Users/Desktop Classifier: License :: OSI Approved :: Apache Software License Classifier: Operating System :: OS Independent Classifier: Topic :: Multimedia :: Sound/Audio :: Players Requires-Python: >=3.13 Description-Content-Type: text/markdown License-File: LICENSE Requires-Dist: mopidy>=4.0.0 Requires-Dist: pydbus>=0.6.0 Requires-Dist: pygobject>=3.50 Requires-Dist: pykka>=4.1 Dynamic: license-file # mopidy-mpris [![Latest PyPI version](https://img.shields.io/pypi/v/mopidy-mpris)](https://pypi.org/p/mopidy-mpris) [![CI build status](https://img.shields.io/github/actions/workflow/status/mopidy/mopidy-mpris/ci.yml)](https://github.com/mopidy/mopidy-mpris/actions/workflows/ci.yml) [![Test coverage](https://img.shields.io/codecov/c/gh/mopidy/mopidy-mpris)](https://codecov.io/gh/mopidy/mopidy-mpris) [Mopidy](https://mopidy.com/) extension for controlling Mopidy through the MPRIS D-Bus interface. Mopidy-MPRIS supports the minimum requirements of the [MPRIS specification][1] as well as the optional [Playlists interface][2]. The [TrackList interface][3] is currently not supported. [1]: https://specifications.freedesktop.org/mpris-spec/latest/ [2]: https://specifications.freedesktop.org/mpris-spec/latest/Playlists_Interface.html [3]: https://specifications.freedesktop.org/mpris-spec/latest/Track_List_Interface.html ## Maintainer wanted Mopidy-MPRIS is currently kept on life support by the Mopidy core developers. It is in need of a more dedicated maintainer. If you want to be the maintainer of Mopidy-MPRIS, please: 1. Make 2-3 good pull requests improving any part of the project. 2. Read and get familiar with all of the project's open issues. 3. Send a pull request removing this section and adding yourself as the "Current maintainer" in the "Credits" section below. In the pull request description, please refer to the previous pull requests and state that you've familiarized yourself with the open issues. As a maintainer, you'll be given push access to the repo and the authority to make releases to PyPI when you see fit. ## Installation Install by running: ```sh python3 -m pip install mopidy-mpris ``` See https://mopidy.com/ext/mpris/ for alternative installation methods. ## Configuration No configuration is required for the MPRIS extension to work. The following configuration values are available: - `mpris/enabled`: If the MPRIS extension should be enabled or not. Defaults to ``true``. - `mpris/bus_type`: The type of D-Bus bus Mopidy-MPRIS should connect to. Choices include `session` (the default) and `system`. ## Usage Once Mopidy-MPRIS has been installed and your Mopidy server has been restarted, the Mopidy-MPRIS extension announces its presence on D-Bus so that any MPRIS compatible clients on your system can interact with it. Exactly how you control Mopidy through MPRIS depends on which MPRIS client you use. ## Clients The following clients have been tested with Mopidy-MPRIS. ### GNOME Shell builtin - State: Not working - Tested versions: - Ubuntu 18.10, - GNOME Shell 3.30.1-2ubuntu1, - Mopidy-MPRIS 2.0.0 GNOME Shell, which is the default desktop on Ubuntu 18.04 onwards, has a builtin MPRIS client. This client seems to work well with Spotify's player, but Mopidy-MPRIS does not show up here. If you have any tips on what's missing to get this working, please open an issue. ### gnome-shell-extensions-mediaplayer - State: Working - Tested versions: - Ubuntu 18.10, - GNOME Shell 3.30.1-2ubuntu1, - gnome-shell-extension-mediaplayer 63, - Mopidy-MPRIS 2.0.0 - Website: https://github.com/JasonLG1979/gnome-shell-extensions-mediaplayer gnome-shell-extensions-mediaplayer is a quite feature rich MPRIS client built as an extension to GNOME Shell. With the improvements to Mopidy-MPRIS in v2.0, this extension works very well with Mopidy. ### gnome-shell-extensions-mpris-indicator-button - State: Working - Tested versions: - Ubuntu 18.10, - GNOME Shell 3.30.1-2ubuntu1, - gnome-shell-extensions-mpris-indicator-button 5, - Mopidy-MPRIS 2.0.0 - Website: https://github.com/JasonLG1979/gnome-shell-extensions-mpris-indicator-button/ gnome-shell-extensions-mpris-indicator-button is a minimalistic version of gnome-shell-extensions-mediaplayer. It works with Mopidy-MPRIS, with the exception of the play/pause button not changing state when Mopidy starts playing. If you have any tips on what's missing to get the play/pause button display correctly, please open an issue. ### Ubuntu Sound Menu - State: Unknown Historically, Ubuntu Sound Menu was the primary target for Mopidy-MPRIS' development. Since Ubuntu 18.04 replaced Unity with GNOME Shell, this is no longer the case. It is currently unknown to what degree Mopidy-MPRIS works with old Ubuntu setups. If you run an Ubuntu setup with Unity and have tested Mopidy-MPRIS, please open an issue to share your results. ## Advanced setups ### Running as a service If you have input on how to best configure Mopidy-MPRIS when Mopidy is running as a service, please add a comment to [issue #15](https://github.com/mopidy/mopidy-mpris/issues/15). ### MPRIS on the system bus You can set the `mpris/bus_type` config value to `system`. This will lead to Mopidy-MPRIS making itself available on the system bus instead of the logged in user's session bus. > [!NOTE] > Few MPRIS clients will try to access MPRIS devices on the system bus, so > this will give you limited functionality. For example, media keys in > GNOME Shell does not work with media players that expose their MPRIS > interface on the system bus instead of the user's session bus. The default setup will often not permit Mopidy to publish its service on the D-Bus system bus, causing a warning similar to this in Mopidy's log: ``` MPRIS frontend setup failed (g-dbus-error-quark: GDBus.Error:org.freedesktop.DBus.Error.AccessDenied: Connection ":1.3071" is not allowed to own the service "org.mpris.MediaPlayer2.mopidy" due to security policies in the configuration file (9)) ``` To solve this, create the file `/etc/dbus-1/system.d/org.mpris.MediaPlayer2.mopidy.conf` with the following contents: ```xml ``` If you run Mopidy as another user than `mopidy`, you must update `user="mopidy"` in the above file accordingly. Once the file is in place, you must restart Mopidy for the change to take effect. To test the setup, you can run the following command as any user on the system to play/pause the music: ```sh dbus-send --system --print-reply \ --dest=org.mpris.MediaPlayer2.mopidy \ /org/mpris/MediaPlayer2 \ org.mpris.MediaPlayer2.Player.PlayPause ``` ### UPnP/DLNA with Rygel [Rygel](https://wiki.gnome.org/Projects/Rygel) is an application that will translate between Mopidy's MPRIS interface and UPnP. Rygel must be run on the same machine as Mopidy, but will make Mopidy controllable by any device on the local network that can control a UPnP/DLNA MediaRenderer. The setup process is approximately as follows: 1. Install Rygel. On Debian/Ubuntu: ```sh sudo apt install rygel ``` 2. Enable Rygel's MPRIS plugin. On Debian/Ubuntu, edit `/etc/rygel.conf`, find the `[MPRIS]` section, and change `enabled=false` to `enabled=true`. 3. Start Rygel. To start it as the current user:: ```sh systemctl --user start rygel ``` To make Rygel start as the current user on boot:: ```sh systemctl --user enable rygel ``` 4. Configure your system's firewall to allow the local network to reach Rygel. Exactly how is out of scope for this document. 5. Start Mopidy with Mopidy-MPRIS enabled. 6. If you view Rygel's log output with:: ```sh journalctl --user -feu rygel ``` You should see a log statement similar to:: ``` New plugin "org.mpris.MediaPlayer2.mopidy" available ``` 6. If everything went well, you should now be able to control Mopidy from a device on your local network that can control an UPnP/DLNA MediaRenderer, for example the Android app BubbleUPnP. Alternatively, [upmpdcli combined with Mopidy-MPD](https://docs.mopidy.com/en/latest/clients/upnp/) serves the same purpose as this setup. ## Project resources - [Source code](https://github.com/mopidy/mopidy-mpris) - [Issues](https://github.com/mopidy/mopidy-mpris/issues) - [Releases](https://github.com/mopidy/mopidy-mpris/releases) ## Development ### Set up development environment Clone the repo using, e.g. using [gh](https://cli.github.com/): ```sh gh repo clone mopidy/mopidy-mpris ``` Enter the directory, and install dependencies using [uv](https://docs.astral.sh/uv/): ```sh cd mopidy-mpris/ uv sync ``` ### Running tests To run all tests and linters in isolated environments, use [tox](https://tox.wiki/): ```sh tox ``` To only run tests, use [pytest](https://pytest.org/): ```sh pytest ``` To format the code, use [ruff](https://docs.astral.sh/ruff/): ```sh ruff format . ``` To check for lints with ruff, run: ```sh ruff check . ``` To check for type errors, use [pyright](https://microsoft.github.io/pyright/): ```sh pyright . ``` ### Adding features and fixing bugs Mopidy-MPRIS has an extensive test suite, so the first step for all changes or additions is to add a test exercising the new code. However, making the tests pass doesn't ensure that what comes out on the D-Bus bus is correct. To introspect this through the bus, there's a couple of useful tools. ### Browsing the MPRIS API with D-Feet D-Feet is a graphical D-Bus browser. On Debian/Ubuntu systems it can be installed by running: ```sh sudo apt install d-feet ``` Then run the `d-feet` command. In the D-Feet window, select the tab corresponding to the bus you run Mopidy-MPRIS on, usually the session bus. Then search for "MediaPlayer2" to find all available MPRIS interfaces. To get the current value of a property, double-click it. To execute a method, double-click it, provide any required arguments, and click "Execute". For more information on D-Feet, see the [GNOME wiki](https://wiki.gnome.org/Apps/DFeet). ### Testing the MPRIS API with pydbus To use the MPRIS API directly, start Mopidy, and then run the following in a Python shell to use `pydbus` as an MPRIS client: ```pycon >>> import pydbus >>> bus = pydbus.SessionBus() >>> player = bus.get('org.mpris.MediaPlayer2.mopidy', '/org/mpris/MediaPlayer2') ``` Now you can control Mopidy through the player object. To get properties from Mopidy, run for example: ```pycon >>> player.PlaybackStatus 'Playing' >>> player.Metadata {'mpris:artUrl': 'https://i.scdn.co/image/8eb49b41eeb45c1cf53e1ddfea7973d9ca257777', 'mpris:length': 342000000, 'mpris:trackid': '/com/mopidy/track/36', 'xesam:album': '65/Milo', 'xesam:albumArtist': ['Kiasmos'], 'xesam:artist': ['Rival Consoles'], 'xesam:discNumber': 1, 'xesam:title': 'Arp', 'xesam:trackNumber': 5, 'xesam:url': 'spotify:track:7CoxEEsqo3XdvUsScRV4WD'} >>> ``` To pause Mopidy's playback through D-Bus, run: ```pycon >>> player.Pause() >>> ``` For details on the API, please refer to the [MPRIS specification](https://specifications.freedesktop.org/mpris-spec/latest/). ### Making a release To make a release to PyPI, go to the project's [GitHub releases page](https://github.com/mopidy/mopidy-mpris/releases) and click the "Draft a new release" button. In the "choose a tag" dropdown, select the tag you want to release or create a new tag, e.g. `v0.1.0`. Add a title, e.g. `v0.1.0`, and a description of the changes. Decide if the release is a pre-release (alpha, beta, or release candidate) or should be marked as the latest release, and click "Publish release". Once the release is created, the `release.yml` GitHub Action will automatically build and publish the release to [PyPI](https://pypi.org/project/mopidy-mpris/). ## Credits - Original author: [Stein Magnus Jodal](https://github.com/mopidy) - Current maintainer: None. Maintainer wanted, see section above. - [Contributors](https://github.com/mopidy/mopidy-mpris/graphs/contributors) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1777126572.0 mopidy_mpris-4.0.0/README.md0000644000175100017510000002767415173146254015203 0ustar00runnerrunner# mopidy-mpris [![Latest PyPI version](https://img.shields.io/pypi/v/mopidy-mpris)](https://pypi.org/p/mopidy-mpris) [![CI build status](https://img.shields.io/github/actions/workflow/status/mopidy/mopidy-mpris/ci.yml)](https://github.com/mopidy/mopidy-mpris/actions/workflows/ci.yml) [![Test coverage](https://img.shields.io/codecov/c/gh/mopidy/mopidy-mpris)](https://codecov.io/gh/mopidy/mopidy-mpris) [Mopidy](https://mopidy.com/) extension for controlling Mopidy through the MPRIS D-Bus interface. Mopidy-MPRIS supports the minimum requirements of the [MPRIS specification][1] as well as the optional [Playlists interface][2]. The [TrackList interface][3] is currently not supported. [1]: https://specifications.freedesktop.org/mpris-spec/latest/ [2]: https://specifications.freedesktop.org/mpris-spec/latest/Playlists_Interface.html [3]: https://specifications.freedesktop.org/mpris-spec/latest/Track_List_Interface.html ## Maintainer wanted Mopidy-MPRIS is currently kept on life support by the Mopidy core developers. It is in need of a more dedicated maintainer. If you want to be the maintainer of Mopidy-MPRIS, please: 1. Make 2-3 good pull requests improving any part of the project. 2. Read and get familiar with all of the project's open issues. 3. Send a pull request removing this section and adding yourself as the "Current maintainer" in the "Credits" section below. In the pull request description, please refer to the previous pull requests and state that you've familiarized yourself with the open issues. As a maintainer, you'll be given push access to the repo and the authority to make releases to PyPI when you see fit. ## Installation Install by running: ```sh python3 -m pip install mopidy-mpris ``` See https://mopidy.com/ext/mpris/ for alternative installation methods. ## Configuration No configuration is required for the MPRIS extension to work. The following configuration values are available: - `mpris/enabled`: If the MPRIS extension should be enabled or not. Defaults to ``true``. - `mpris/bus_type`: The type of D-Bus bus Mopidy-MPRIS should connect to. Choices include `session` (the default) and `system`. ## Usage Once Mopidy-MPRIS has been installed and your Mopidy server has been restarted, the Mopidy-MPRIS extension announces its presence on D-Bus so that any MPRIS compatible clients on your system can interact with it. Exactly how you control Mopidy through MPRIS depends on which MPRIS client you use. ## Clients The following clients have been tested with Mopidy-MPRIS. ### GNOME Shell builtin - State: Not working - Tested versions: - Ubuntu 18.10, - GNOME Shell 3.30.1-2ubuntu1, - Mopidy-MPRIS 2.0.0 GNOME Shell, which is the default desktop on Ubuntu 18.04 onwards, has a builtin MPRIS client. This client seems to work well with Spotify's player, but Mopidy-MPRIS does not show up here. If you have any tips on what's missing to get this working, please open an issue. ### gnome-shell-extensions-mediaplayer - State: Working - Tested versions: - Ubuntu 18.10, - GNOME Shell 3.30.1-2ubuntu1, - gnome-shell-extension-mediaplayer 63, - Mopidy-MPRIS 2.0.0 - Website: https://github.com/JasonLG1979/gnome-shell-extensions-mediaplayer gnome-shell-extensions-mediaplayer is a quite feature rich MPRIS client built as an extension to GNOME Shell. With the improvements to Mopidy-MPRIS in v2.0, this extension works very well with Mopidy. ### gnome-shell-extensions-mpris-indicator-button - State: Working - Tested versions: - Ubuntu 18.10, - GNOME Shell 3.30.1-2ubuntu1, - gnome-shell-extensions-mpris-indicator-button 5, - Mopidy-MPRIS 2.0.0 - Website: https://github.com/JasonLG1979/gnome-shell-extensions-mpris-indicator-button/ gnome-shell-extensions-mpris-indicator-button is a minimalistic version of gnome-shell-extensions-mediaplayer. It works with Mopidy-MPRIS, with the exception of the play/pause button not changing state when Mopidy starts playing. If you have any tips on what's missing to get the play/pause button display correctly, please open an issue. ### Ubuntu Sound Menu - State: Unknown Historically, Ubuntu Sound Menu was the primary target for Mopidy-MPRIS' development. Since Ubuntu 18.04 replaced Unity with GNOME Shell, this is no longer the case. It is currently unknown to what degree Mopidy-MPRIS works with old Ubuntu setups. If you run an Ubuntu setup with Unity and have tested Mopidy-MPRIS, please open an issue to share your results. ## Advanced setups ### Running as a service If you have input on how to best configure Mopidy-MPRIS when Mopidy is running as a service, please add a comment to [issue #15](https://github.com/mopidy/mopidy-mpris/issues/15). ### MPRIS on the system bus You can set the `mpris/bus_type` config value to `system`. This will lead to Mopidy-MPRIS making itself available on the system bus instead of the logged in user's session bus. > [!NOTE] > Few MPRIS clients will try to access MPRIS devices on the system bus, so > this will give you limited functionality. For example, media keys in > GNOME Shell does not work with media players that expose their MPRIS > interface on the system bus instead of the user's session bus. The default setup will often not permit Mopidy to publish its service on the D-Bus system bus, causing a warning similar to this in Mopidy's log: ``` MPRIS frontend setup failed (g-dbus-error-quark: GDBus.Error:org.freedesktop.DBus.Error.AccessDenied: Connection ":1.3071" is not allowed to own the service "org.mpris.MediaPlayer2.mopidy" due to security policies in the configuration file (9)) ``` To solve this, create the file `/etc/dbus-1/system.d/org.mpris.MediaPlayer2.mopidy.conf` with the following contents: ```xml ``` If you run Mopidy as another user than `mopidy`, you must update `user="mopidy"` in the above file accordingly. Once the file is in place, you must restart Mopidy for the change to take effect. To test the setup, you can run the following command as any user on the system to play/pause the music: ```sh dbus-send --system --print-reply \ --dest=org.mpris.MediaPlayer2.mopidy \ /org/mpris/MediaPlayer2 \ org.mpris.MediaPlayer2.Player.PlayPause ``` ### UPnP/DLNA with Rygel [Rygel](https://wiki.gnome.org/Projects/Rygel) is an application that will translate between Mopidy's MPRIS interface and UPnP. Rygel must be run on the same machine as Mopidy, but will make Mopidy controllable by any device on the local network that can control a UPnP/DLNA MediaRenderer. The setup process is approximately as follows: 1. Install Rygel. On Debian/Ubuntu: ```sh sudo apt install rygel ``` 2. Enable Rygel's MPRIS plugin. On Debian/Ubuntu, edit `/etc/rygel.conf`, find the `[MPRIS]` section, and change `enabled=false` to `enabled=true`. 3. Start Rygel. To start it as the current user:: ```sh systemctl --user start rygel ``` To make Rygel start as the current user on boot:: ```sh systemctl --user enable rygel ``` 4. Configure your system's firewall to allow the local network to reach Rygel. Exactly how is out of scope for this document. 5. Start Mopidy with Mopidy-MPRIS enabled. 6. If you view Rygel's log output with:: ```sh journalctl --user -feu rygel ``` You should see a log statement similar to:: ``` New plugin "org.mpris.MediaPlayer2.mopidy" available ``` 6. If everything went well, you should now be able to control Mopidy from a device on your local network that can control an UPnP/DLNA MediaRenderer, for example the Android app BubbleUPnP. Alternatively, [upmpdcli combined with Mopidy-MPD](https://docs.mopidy.com/en/latest/clients/upnp/) serves the same purpose as this setup. ## Project resources - [Source code](https://github.com/mopidy/mopidy-mpris) - [Issues](https://github.com/mopidy/mopidy-mpris/issues) - [Releases](https://github.com/mopidy/mopidy-mpris/releases) ## Development ### Set up development environment Clone the repo using, e.g. using [gh](https://cli.github.com/): ```sh gh repo clone mopidy/mopidy-mpris ``` Enter the directory, and install dependencies using [uv](https://docs.astral.sh/uv/): ```sh cd mopidy-mpris/ uv sync ``` ### Running tests To run all tests and linters in isolated environments, use [tox](https://tox.wiki/): ```sh tox ``` To only run tests, use [pytest](https://pytest.org/): ```sh pytest ``` To format the code, use [ruff](https://docs.astral.sh/ruff/): ```sh ruff format . ``` To check for lints with ruff, run: ```sh ruff check . ``` To check for type errors, use [pyright](https://microsoft.github.io/pyright/): ```sh pyright . ``` ### Adding features and fixing bugs Mopidy-MPRIS has an extensive test suite, so the first step for all changes or additions is to add a test exercising the new code. However, making the tests pass doesn't ensure that what comes out on the D-Bus bus is correct. To introspect this through the bus, there's a couple of useful tools. ### Browsing the MPRIS API with D-Feet D-Feet is a graphical D-Bus browser. On Debian/Ubuntu systems it can be installed by running: ```sh sudo apt install d-feet ``` Then run the `d-feet` command. In the D-Feet window, select the tab corresponding to the bus you run Mopidy-MPRIS on, usually the session bus. Then search for "MediaPlayer2" to find all available MPRIS interfaces. To get the current value of a property, double-click it. To execute a method, double-click it, provide any required arguments, and click "Execute". For more information on D-Feet, see the [GNOME wiki](https://wiki.gnome.org/Apps/DFeet). ### Testing the MPRIS API with pydbus To use the MPRIS API directly, start Mopidy, and then run the following in a Python shell to use `pydbus` as an MPRIS client: ```pycon >>> import pydbus >>> bus = pydbus.SessionBus() >>> player = bus.get('org.mpris.MediaPlayer2.mopidy', '/org/mpris/MediaPlayer2') ``` Now you can control Mopidy through the player object. To get properties from Mopidy, run for example: ```pycon >>> player.PlaybackStatus 'Playing' >>> player.Metadata {'mpris:artUrl': 'https://i.scdn.co/image/8eb49b41eeb45c1cf53e1ddfea7973d9ca257777', 'mpris:length': 342000000, 'mpris:trackid': '/com/mopidy/track/36', 'xesam:album': '65/Milo', 'xesam:albumArtist': ['Kiasmos'], 'xesam:artist': ['Rival Consoles'], 'xesam:discNumber': 1, 'xesam:title': 'Arp', 'xesam:trackNumber': 5, 'xesam:url': 'spotify:track:7CoxEEsqo3XdvUsScRV4WD'} >>> ``` To pause Mopidy's playback through D-Bus, run: ```pycon >>> player.Pause() >>> ``` For details on the API, please refer to the [MPRIS specification](https://specifications.freedesktop.org/mpris-spec/latest/). ### Making a release To make a release to PyPI, go to the project's [GitHub releases page](https://github.com/mopidy/mopidy-mpris/releases) and click the "Draft a new release" button. In the "choose a tag" dropdown, select the tag you want to release or create a new tag, e.g. `v0.1.0`. Add a title, e.g. `v0.1.0`, and a description of the changes. Decide if the release is a pre-release (alpha, beta, or release candidate) or should be marked as the latest release, and click "Publish release". Once the release is created, the `release.yml` GitHub Action will automatically build and publish the release to [PyPI](https://pypi.org/project/mopidy-mpris/). ## Credits - Original author: [Stein Magnus Jodal](https://github.com/mopidy) - Current maintainer: None. Maintainer wanted, see section above. - [Contributors](https://github.com/mopidy/mopidy-mpris/graphs/contributors) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1777126572.0 mopidy_mpris-4.0.0/pyproject.toml0000644000175100017510000000655115173146254016627 0ustar00runnerrunner[project] name = "mopidy-mpris" description = "Mopidy extension for controlling Mopidy through the MPRIS D-Bus interface" readme = "README.md" requires-python = ">= 3.13" license = { text = "Apache-2.0" } authors = [{ name = "Stein Magnus Jodal", email = "stein.magnus@jodal.no" }] classifiers = [ "Environment :: No Input/Output (Daemon)", "Intended Audience :: End Users/Desktop", "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", "Topic :: Multimedia :: Sound/Audio :: Players", ] dynamic = ["version"] dependencies = [ "mopidy >= 4.0.0", "pydbus >= 0.6.0", "pygobject >= 3.50", "pykka >= 4.1", ] [project.urls] Homepage = "https://github.com/mopidy/mopidy-mpris" [project.entry-points."mopidy.ext"] mpris = "mopidy_mpris:Extension" [build-system] requires = ["setuptools >= 78", "setuptools-scm >= 8.2"] build-backend = "setuptools.build_meta" [dependency-groups] dev = [ "tox", { include-group = "ruff" }, { include-group = "tests" }, { include-group = "typing" }, ] ruff = ["ruff"] tests = ["pytest", "pytest-cov"] typing = ["pyright"] [tool.coverage.paths] source = ["src/", "*/site-packages/"] [tool.coverage.run] source_pkgs = ["mopidy_mpris"] [tool.coverage.report] show_missing = true [tool.pyright] pythonVersion = "3.13" typeCheckingMode = "standard" # Not all dependencies have type hints: reportMissingTypeStubs = false # Already covered by ruff's flake8-self checks: reportPrivateImportUsage = false [tool.pytest.ini_options] filterwarnings = [ # By default, fail tests on warnings from our own code "error:::mopidy_mpris", # # Add any warnings you want to ignore here ] [tool.ruff] target-version = "py313" [tool.ruff.lint] select = ["ALL"] ignore = [ # Add rules you want to ignore here "D", # pydocstyle "D203", # one-blank-line-before-class "D213", # multi-line-summary-second-line "FIX002", # line-contains-todo "G004", # logging-f-string "S101", # assert "TD002", # missing-todo-author "TD003", # missing-todo-link # # Conflicting with `ruff format` "COM812", # missing-trailing-comma "ISC001", # single-line-implicit-string-concatenation ] [tool.ruff.lint.per-file-ignores] "tests/*" = [ "ANN", # flake8-annotations "ARG", # flake8-unused-arguments "D", # pydocstyle "FBT003", # boolean-positional-value-in-call -- TODO "PLR2004", # magic-value-comparison "S101", # assert "SLF001", # private-member-access ] [tool.setuptools.package-data] "*" = ["*.conf"] [tool.setuptools_scm] # This section, even if empty, must be present for setuptools_scm to work [tool.tox] env_list = [ "3.13", "3.14", "pyright", "ruff-check", "ruff-format", ] [tool.tox.env_run_base] package = "wheel" wheel_build_env = ".pkg" dependency_groups = ["tests"] commands = [ [ "pytest", "--cov", "--basetemp={envtmpdir}", { replace = "posargs", extend = true }, ], ] [tool.tox.env.pyright] dependency_groups = ["typing"] commands = [["pyright", "{posargs:src}"]] [tool.tox.env.ruff-check] skip_install = true dependency_groups = ["ruff"] commands = [["ruff", "check", "{posargs:.}"]] [tool.tox.env.ruff-format] skip_install = true dependency_groups = ["ruff"] commands = [["ruff", "format", "--check", "--diff", "{posargs:.}"]] ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1777126579.3217592 mopidy_mpris-4.0.0/setup.cfg0000644000175100017510000000004615173146263015525 0ustar00runnerrunner[egg_info] tag_build = tag_date = 0 ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1777126579.3147185 mopidy_mpris-4.0.0/src/0000755000175100017510000000000015173146263014473 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1777126579.318222 mopidy_mpris-4.0.0/src/mopidy_mpris/0000755000175100017510000000000015173146263017206 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1777126572.0 mopidy_mpris-4.0.0/src/mopidy_mpris/__init__.py0000644000175100017510000000206115173146254021316 0ustar00runnerrunnerimport pathlib from importlib.metadata import version from mopidy import config, exceptions, ext from mopidy.config import ConfigSchema __version__ = version("mopidy-mpris") class Extension(ext.Extension): dist_name = "mopidy-mpris" ext_name = "mpris" version = __version__ def get_default_config(self) -> str: return config.read(pathlib.Path(__file__).parent / "ext.conf") def get_config_schema(self) -> ConfigSchema: schema = super().get_config_schema() schema["desktop_file"] = config.Deprecated() schema["bus_type"] = config.String(choices=["session", "system"]) return schema def validate_environment(self) -> None: try: import pydbus # noqa: F401, PLC0415 except ImportError as exc: msg = "pydbus library not found" raise exceptions.ExtensionError(msg, exc) from exc def setup(self, registry: ext.Registry) -> None: from mopidy_mpris.frontend import MprisFrontend # noqa: PLC0415 registry.add("frontend", MprisFrontend) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1777126572.0 mopidy_mpris-4.0.0/src/mopidy_mpris/ext.conf0000644000175100017510000000005215173146254020652 0ustar00runnerrunner[mpris] enabled = true bus_type = session ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1777126572.0 mopidy_mpris-4.0.0/src/mopidy_mpris/frontend.py0000644000175100017510000001116515173146254021403 0ustar00runnerrunnerimport logging from typing import override import pykka from mopidy.config import Config from mopidy.core import CoreEvent, CoreEventData, CoreListener, CoreProxy from mopidy.models import Playlist, TlTrack from mopidy.types import DurationMs, Percentage, PlaybackState, Uri from mopidy_mpris.interface import Interface from mopidy_mpris.playlists import get_playlist_id from mopidy_mpris.server import Server logger = logging.getLogger(__name__) class MprisFrontend(pykka.ThreadingActor, CoreListener): @override def __init__(self, config: Config, core: CoreProxy) -> None: super().__init__() self.config = config self.core = core self.mpris: Server | None = None @override def on_start(self) -> None: try: self.mpris = Server(self.config, self.core) self.mpris.publish() except Exception as e: # noqa: BLE001 logger.warning("MPRIS frontend setup failed (%s)", e) self.stop() @override def on_stop(self) -> None: logger.debug("Removing MPRIS object from D-Bus connection...") if self.mpris: self.mpris.unpublish() self.mpris = None logger.debug("Removed MPRIS object from D-Bus connection") @override def on_event(self, event: CoreEvent, **kwargs: CoreEventData) -> None: logger.debug("Received %s event", event) if self.mpris is None: return None return super().on_event(event, **kwargs) @override def track_playback_paused( self, tl_track: TlTrack, time_position: DurationMs, ) -> None: assert self.mpris _emit_properties_changed(self.mpris.player, ["PlaybackStatus"]) @override def track_playback_resumed( self, tl_track: TlTrack, time_position: DurationMs, ) -> None: assert self.mpris _emit_properties_changed(self.mpris.player, ["PlaybackStatus"]) @override def track_playback_started(self, tl_track: TlTrack) -> None: assert self.mpris _emit_properties_changed(self.mpris.player, ["PlaybackStatus", "Metadata"]) @override def track_playback_ended( self, tl_track: TlTrack, time_position: DurationMs, ) -> None: assert self.mpris _emit_properties_changed(self.mpris.player, ["PlaybackStatus", "Metadata"]) @override def playback_state_changed( self, old_state: PlaybackState, new_state: PlaybackState, ) -> None: assert self.mpris _emit_properties_changed(self.mpris.player, ["PlaybackStatus", "Metadata"]) @override def tracklist_changed(self) -> None: pass # TODO: Implement if adding tracklist support @override def playlists_loaded(self) -> None: assert self.mpris _emit_properties_changed(self.mpris.playlists, ["PlaylistCount"]) @override def playlist_changed(self, playlist: Playlist) -> None: assert self.mpris if playlist.uri is None: return playlist_id = get_playlist_id(playlist.uri) self.mpris.playlists.PlaylistChanged(playlist_id, playlist.name, "") # pyright: ignore[reportCallIssue] @override def playlist_deleted(self, uri: Uri) -> None: assert self.mpris _emit_properties_changed(self.mpris.playlists, ["PlaylistCount"]) @override def options_changed(self) -> None: assert self.mpris _emit_properties_changed( self.mpris.player, ["LoopStatus", "Shuffle", "CanGoPrevious", "CanGoNext"], ) @override def volume_changed(self, volume: Percentage) -> None: assert self.mpris _emit_properties_changed(self.mpris.player, ["Volume"]) @override def mute_changed(self, mute: bool) -> None: assert self.mpris _emit_properties_changed(self.mpris.player, ["Volume"]) @override def seeked(self, time_position: DurationMs) -> None: assert self.mpris time_position_in_microseconds = time_position * 1000 self.mpris.player.Seeked(time_position_in_microseconds) # pyright: ignore[reportCallIssue] @override def stream_title_changed(self, title: str) -> None: assert self.mpris _emit_properties_changed(self.mpris.player, ["Metadata"]) def _emit_properties_changed( interface: Interface, changed_properties: list[str] ) -> None: props_with_new_values = [(p, getattr(interface, p)) for p in changed_properties] interface.PropertiesChanged( # pyright: ignore[reportCallIssue] interface.INTERFACE, dict(props_with_new_values), [], ) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1777126572.0 mopidy_mpris-4.0.0/src/mopidy_mpris/interface.py0000644000175100017510000000126215173146254021521 0ustar00runnerrunnerfrom __future__ import annotations import logging from typing import TYPE_CHECKING, Any, ClassVar from pydbus.generic import signal if TYPE_CHECKING: from mopidy.config import Config from mopidy.core import CoreProxy logger = logging.getLogger(__name__) # This should be kept in sync with mopidy.internal.log.TRACE_LOG_LEVEL TRACE_LOG_LEVEL = 5 class Interface: INTERFACE: ClassVar[str] def __init__(self, config: Config, core: CoreProxy) -> None: self.config = config self.core = core PropertiesChanged = signal() def log_trace(self, *args: Any, **kwargs: Any) -> None: # noqa: ANN401 logger.log(TRACE_LOG_LEVEL, *args, **kwargs) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1777126572.0 mopidy_mpris-4.0.0/src/mopidy_mpris/player.py0000644000175100017510000003401515173146254021057 0ustar00runnerrunner"""Implementation of org.mpris.MediaPlayer2.Player interface. https://specifications.freedesktop.org/mpris/latest/Player_Interface.html """ # ruff: noqa: N802 import logging from typing import Literal from gi.repository.GLib import ( # pyright: ignore[reportMissingImports, reportMissingModuleSource] Variant, ) from mopidy.models import Track from mopidy.types import DurationMs, Percentage, PlaybackState, TracklistId, Uri from pydbus.generic import signal from mopidy_mpris.interface import Interface logger = logging.getLogger(__name__) class Player(Interface): """ """ INTERFACE = "org.mpris.MediaPlayer2.Player" # To override from tests. _CanControl = True def Next(self) -> None: logger.debug("%s.Next called", self.INTERFACE) if not self.CanGoNext: logger.debug("%s.Next not allowed", self.INTERFACE) return self.core.playback.next().get() def Previous(self) -> None: logger.debug("%s.Previous called", self.INTERFACE) if not self.CanGoPrevious: logger.debug("%s.Previous not allowed", self.INTERFACE) return self.core.playback.previous().get() def Pause(self) -> None: logger.debug("%s.Pause called", self.INTERFACE) if not self.CanPause: logger.debug("%s.Pause not allowed", self.INTERFACE) return self.core.playback.pause().get() def PlayPause(self) -> None: logger.debug("%s.PlayPause called", self.INTERFACE) if not self.CanPause: logger.debug("%s.PlayPause not allowed", self.INTERFACE) return state = self.core.playback.get_state().get() if state == PlaybackState.PLAYING: self.core.playback.pause().get() elif state == PlaybackState.PAUSED: self.core.playback.resume().get() elif state == PlaybackState.STOPPED: self.core.playback.play().get() def Stop(self) -> None: logger.debug("%s.Stop called", self.INTERFACE) if not self.CanControl: logger.debug("%s.Stop not allowed", self.INTERFACE) return self.core.playback.stop().get() def Play(self) -> None: logger.debug("%s.Play called", self.INTERFACE) if not self.CanPlay: logger.debug("%s.Play not allowed", self.INTERFACE) return state = self.core.playback.get_state().get() if state == PlaybackState.PAUSED: self.core.playback.resume().get() else: self.core.playback.play().get() def Seek(self, offset: int) -> None: logger.debug("%s.Seek called", self.INTERFACE) if not self.CanSeek: logger.debug("%s.Seek not allowed", self.INTERFACE) return offset_ms = offset // 1000 current_position = self.core.playback.get_time_position().get() new_position = current_position + offset_ms new_position = DurationMs(max(new_position, 0)) self.core.playback.seek(new_position).get() def SetPosition(self, track_id: str, position: int) -> None: logger.debug("%s.SetPosition called", self.INTERFACE) if not self.CanSeek: logger.debug("%s.SetPosition not allowed", self.INTERFACE) return position_ms = DurationMs(position // 1000) current_tl_track = self.core.playback.get_current_tl_track().get() if current_tl_track is None: return if track_id != get_track_id(current_tl_track.tlid): return if position_ms < 0: return if ( current_tl_track.track.length is None or current_tl_track.track.length < position_ms ): return self.core.playback.seek(position_ms).get() def OpenUri(self, uri: str) -> None: logger.debug("%s.OpenUri called", self.INTERFACE) if not self.CanControl: # NOTE The spec does not explicitly require this check, but # guarding the other methods doesn't help much if OpenUri is open # for use. logger.debug("%s.OpenUri not allowed", self.INTERFACE) return # NOTE Check if URI has MIME type known to the backend, if MIME support # is added to the backend. tl_tracks = self.core.tracklist.add(uris=[Uri(uri)]).get() if tl_tracks: self.core.playback.play(tlid=tl_tracks[0].tlid).get() else: logger.debug('Track with URI "%s" not found in library.', uri) Seeked = signal() @property def PlaybackStatus(self) -> Literal["Playing", "Paused", "Stopped"]: self.log_trace("Getting %s.PlaybackStatus", self.INTERFACE) state = self.core.playback.get_state().get() match state: case PlaybackState.PLAYING: return "Playing" case PlaybackState.PAUSED: return "Paused" case PlaybackState.STOPPED: return "Stopped" @property def LoopStatus(self) -> Literal["None", "Track", "Playlist"]: self.log_trace("Getting %s.LoopStatus", self.INTERFACE) repeat = self.core.tracklist.get_repeat().get() single = self.core.tracklist.get_single().get() match (repeat, single): case (False, _): return "None" case (True, True): return "Track" case (True, False): return "Playlist" @LoopStatus.setter def LoopStatus(self, value: Literal["None", "Track", "Playlist"]) -> None: if not self.CanControl: logger.debug("Setting %s.LoopStatus not allowed", self.INTERFACE) return logger.debug("Setting %s.LoopStatus to %s", self.INTERFACE, value) match value: case "None": self.core.tracklist.set_repeat(False) self.core.tracklist.set_single(False) case "Track": self.core.tracklist.set_repeat(True) self.core.tracklist.set_single(True) case "Playlist": self.core.tracklist.set_repeat(True) self.core.tracklist.set_single(False) @property def Rate(self) -> float: self.log_trace("Getting %s.Rate", self.INTERFACE) return 1.0 @Rate.setter def Rate(self, value: float) -> None: if not self.CanControl: # NOTE The spec does not explicitly require this check, but it was # added to be consistent with all the other property setters. logger.debug("Setting %s.Rate not allowed", self.INTERFACE) return logger.debug("Setting %s.Rate to %s", self.INTERFACE, value) if value == 0: self.Pause() @property def Shuffle(self) -> bool: self.log_trace("Getting %s.Shuffle", self.INTERFACE) return self.core.tracklist.get_random().get() @Shuffle.setter def Shuffle(self, value: bool) -> None: if not self.CanControl: logger.debug("Setting %s.Shuffle not allowed", self.INTERFACE) return logger.debug("Setting %s.Shuffle to %s", self.INTERFACE, value) self.core.tracklist.set_random(bool(value)) @property def Metadata(self) -> dict[str, Variant]: # noqa: C901 self.log_trace("Getting %s.Metadata", self.INTERFACE) current_tl_track = self.core.playback.get_current_tl_track().get() stream_title = self.core.playback.get_stream_title().get() if current_tl_track is None: return {} track_id = get_track_id(current_tl_track.tlid) res = {"mpris:trackid": Variant("o", track_id)} track = current_tl_track.track if track.length: res["mpris:length"] = Variant("x", track.length * 1000) if track.uri: res["xesam:url"] = Variant("s", track.uri) if stream_title or track.name: res["xesam:title"] = Variant("s", stream_title or track.name) if track.artists: artists = list(track.artists) artists.sort(key=lambda a: a.name or "") res["xesam:artist"] = Variant("as", [a.name for a in artists if a.name]) if track.album and track.album.name: res["xesam:album"] = Variant("s", track.album.name) if track.album and track.album.artists: artists = list(track.album.artists) artists.sort(key=lambda a: a.name or "") res["xesam:albumArtist"] = Variant( "as", [a.name for a in artists if a.name] ) art_url = self._get_art_url(track) if art_url: res["mpris:artUrl"] = Variant("s", art_url) if track.disc_no: res["xesam:discNumber"] = Variant("i", track.disc_no) if track.track_no: res["xesam:trackNumber"] = Variant("i", track.track_no) return res def _get_art_url(self, track: Track) -> Uri | None: if track.uri is None: return None images = self.core.library.get_images([track.uri]).get() if images[track.uri]: largest_image = sorted( images[track.uri], key=lambda i: i.width or 0, reverse=True )[0] return largest_image.uri return None @property def Volume(self) -> float: self.log_trace("Getting %s.Volume", self.INTERFACE) mute = self.core.mixer.get_mute().get() volume = self.core.mixer.get_volume().get() if volume is None or mute is True: return 0 return volume / 100.0 @Volume.setter def Volume(self, value: float | None) -> None: if not self.CanControl: logger.debug("Setting %s.Volume not allowed", self.INTERFACE) return logger.debug("Setting %s.Volume to %s", self.INTERFACE, value) if value is None: return percentage = Percentage(max(0, min(100, round(value * 100)))) self.core.mixer.set_volume(percentage) if percentage > 0: self.core.mixer.set_mute(False) @property def Position(self) -> int: self.log_trace("Getting %s.Position", self.INTERFACE) return self.core.playback.get_time_position().get() * 1000 MinimumRate: float = 1.0 MaximumRate: float = 1.0 @property def CanGoNext(self) -> bool: self.log_trace("Getting %s.CanGoNext", self.INTERFACE) if not self.CanControl: return False current_tlid = self.core.playback.get_current_tlid().get() next_tlid = self.core.tracklist.get_next_tlid().get() return next_tlid != current_tlid @property def CanGoPrevious(self) -> bool: self.log_trace("Getting %s.CanGoPrevious", self.INTERFACE) if not self.CanControl: return False current_tlid = self.core.playback.get_current_tlid().get() previous_tlid = self.core.tracklist.get_previous_tlid().get() return previous_tlid != current_tlid @property def CanPlay(self) -> bool: self.log_trace("Getting %s.CanPlay", self.INTERFACE) if not self.CanControl: return False current_tlid = self.core.playback.get_current_tlid().get() next_tlid = self.core.tracklist.get_next_tlid().get() return current_tlid is not None or next_tlid is not None @property def CanPause(self) -> bool: self.log_trace("Getting %s.CanPause", self.INTERFACE) if not self.CanControl: # noqa: SIM103 return False # NOTE Should be changed to vary based on capabilities of the current # track if Mopidy starts supporting non-seekable media, like streams. return True @property def CanSeek(self) -> bool: self.log_trace("Getting %s.CanSeek", self.INTERFACE) if not self.CanControl: # noqa: SIM103 return False # NOTE Should be changed to vary based on capabilities of the current # track if Mopidy starts supporting non-seekable media, like streams. return True @property def CanControl(self) -> bool: # NOTE This could be a setting for the end user to change. return self._CanControl def get_track_id(tlid: TracklistId) -> str: return f"/com/mopidy/track/{tlid}" def get_track_tlid(track_id: str) -> TracklistId: if not track_id.startswith("/com/mopidy/track/"): msg = f"Cannot extract track ID from {track_id!r}" raise ValueError(msg) return TracklistId(int(track_id.rsplit("/", maxsplit=1)[-1])) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1777126572.0 mopidy_mpris-4.0.0/src/mopidy_mpris/playlists.py0000644000175100017510000000761015173146254021610 0ustar00runnerrunner"""Implementation of org.mpris.MediaPlayer2.Playlists interface. https://specifications.freedesktop.org/mpris/latest/Playlists_Interface.html """ # ruff: noqa: N802 import base64 import logging from mopidy.types import Uri from pydbus.generic import signal from mopidy_mpris.interface import Interface logger = logging.getLogger(__name__) class Playlists(Interface): """ """ INTERFACE = "org.mpris.MediaPlayer2.Playlists" def ActivatePlaylist(self, playlist_id: str) -> None: logger.debug("%s.ActivatePlaylist(%r) called", self.INTERFACE, playlist_id) playlist_uri = get_playlist_uri(playlist_id) playlist = self.core.playlists.lookup(playlist_uri).get() if playlist and playlist.tracks: tl_tracks = self.core.tracklist.add(playlist.tracks).get() self.core.playback.play(tlid=tl_tracks[0].tlid).get() def GetPlaylists( self, index: int, max_count: int, order: str, reverse: bool, # noqa: FBT001 ) -> list[tuple[str, str, str]]: logger.debug( "%s.GetPlaylists(%r, %r, %r, %r) called", self.INTERFACE, index, max_count, order, reverse, ) playlists = self.core.playlists.as_list().get() if order == "Alphabetical": playlists.sort(key=lambda p: p.name or "", reverse=reverse) elif order == "User" and reverse: playlists.reverse() slice_end = index + max_count playlists = playlists[index:slice_end] return [(get_playlist_id(p.uri), p.name or "", "") for p in playlists] PlaylistChanged = signal() @property def PlaylistCount(self) -> int: self.log_trace("Getting %s.PlaylistCount", self.INTERFACE) return len(self.core.playlists.as_list().get()) @property def Orderings(self) -> list[str]: self.log_trace("Getting %s.Orderings", self.INTERFACE) return [ "Alphabetical", # Order by playlist.name "User", # Don't change order ] @property def ActivePlaylist(self) -> tuple[bool, tuple[str, str, str]]: self.log_trace("Getting %s.ActivePlaylist", self.INTERFACE) playlist_is_valid = False playlist = ("/", "None", "") return (playlist_is_valid, playlist) def get_playlist_id(playlist_uri: Uri) -> str: # Only A-Za-z0-9_ is allowed, which is 63 chars, so we can't use # base64. Luckily, D-Bus does not limit the length of object paths. # Since base32 pads trailing bytes with "=" chars, we need to replace # them with an allowed character such as "_". uri_bytes = str(playlist_uri).encode() encoded_uri = base64.b32encode(uri_bytes).decode().replace("=", "_") return f"/com/mopidy/playlist/{encoded_uri}" def get_playlist_uri(playlist_id: str | bytes) -> Uri: if isinstance(playlist_id, bytes): playlist_id = playlist_id.decode() encoded_uri = playlist_id.split("/")[-1].replace("_", "=").encode() return Uri(base64.b32decode(encoded_uri).decode()) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1777126572.0 mopidy_mpris-4.0.0/src/mopidy_mpris/root.py0000644000175100017510000000564615173146254020556 0ustar00runnerrunner"""Implementation of org.mpris.MediaPlayer2 interface. https://specifications.freedesktop.org/mpris/latest/Media_Player.html """ # ruff: noqa: N802 import logging from mopidy_mpris.interface import Interface logger = logging.getLogger(__name__) class Root(Interface): """ """ INTERFACE = "org.mpris.MediaPlayer2" def Raise(self) -> None: logger.debug("%s.Raise called", self.INTERFACE) # Do nothing, as we do not have a GUI def Quit(self) -> None: logger.debug("%s.Quit called", self.INTERFACE) # Do nothing, as we do not allow MPRIS clients to shut down Mopidy CanQuit = False @property def Fullscreen(self) -> bool: self.log_trace("Getting %s.Fullscreen", self.INTERFACE) return False @Fullscreen.setter def Fullscreen(self, value: bool) -> None: logger.debug("Setting %s.Fullscreen to %s", self.INTERFACE, value) CanSetFullscreen = False CanRaise = False HasTrackList = False # NOTE Change if adding optional track list support Identity = "Mopidy" @property def DesktopEntry(self) -> str: self.log_trace("Getting %s.DesktopEntry", self.INTERFACE) # This property is optional to expose. If we set this to "mopidy", the # basename of "mopidy.desktop", some MPRIS clients will start a new # Mopidy instance in a terminal window if one clicks outside the # buttons of the UI. This is probably never what the user wants. return "" @property def SupportedUriSchemes(self) -> list[str]: self.log_trace("Getting %s.SupportedUriSchemes", self.INTERFACE) return [str(uri_scheme) for uri_scheme in self.core.get_uri_schemes().get()] @property def SupportedMimeTypes(self) -> list[str]: # NOTE Return MIME types supported by local backend if support for # reporting supported MIME types is added. self.log_trace("Getting %s.SupportedMimeTypes", self.INTERFACE) return [ "audio/mpeg", "audio/x-ms-wma", "audio/x-ms-asf", "audio/x-flac", "audio/flac", "audio/l16;channels=2;rate=44100", "audio/l16;rate=44100;channels=2", ] ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1777126572.0 mopidy_mpris-4.0.0/src/mopidy_mpris/server.py0000644000175100017510000000263115173146254021070 0ustar00runnerrunnerfrom __future__ import annotations import logging from typing import TYPE_CHECKING import pydbus from mopidy_mpris.player import Player from mopidy_mpris.playlists import Playlists from mopidy_mpris.root import Root if TYPE_CHECKING: from mopidy.config import Config from mopidy.core import CoreProxy logger = logging.getLogger(__name__) class Server: def __init__(self, config: Config, core: CoreProxy) -> None: self.config = config self.core = core self.root = Root(config, core) self.player = Player(config, core) self.playlists = Playlists(config, core) self._publication_token = None def publish(self) -> None: bus_type = self.config["mpris"]["bus_type"] # pyright: ignore[reportGeneralTypeIssues] logger.debug("Connecting to D-Bus %s bus...", bus_type) bus = pydbus.SystemBus() if bus_type == "system" else pydbus.SessionBus() logger.info("MPRIS server connected to D-Bus %s bus", bus_type) self._publication_token = bus.publish( "org.mpris.MediaPlayer2.mopidy", ("/org/mpris/MediaPlayer2", self.root), ("/org/mpris/MediaPlayer2", self.player), ("/org/mpris/MediaPlayer2", self.playlists), ) def unpublish(self) -> None: if self._publication_token: self._publication_token.unpublish() self._publication_token = None ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1777126579.3209348 mopidy_mpris-4.0.0/src/mopidy_mpris.egg-info/0000755000175100017510000000000015173146263020700 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1777126579.0 mopidy_mpris-4.0.0/src/mopidy_mpris.egg-info/PKG-INFO0000644000175100017510000003130115173146263021773 0ustar00runnerrunnerMetadata-Version: 2.4 Name: mopidy-mpris Version: 4.0.0 Summary: Mopidy extension for controlling Mopidy through the MPRIS D-Bus interface Author-email: Stein Magnus Jodal License: Apache-2.0 Project-URL: Homepage, https://github.com/mopidy/mopidy-mpris Classifier: Environment :: No Input/Output (Daemon) Classifier: Intended Audience :: End Users/Desktop Classifier: License :: OSI Approved :: Apache Software License Classifier: Operating System :: OS Independent Classifier: Topic :: Multimedia :: Sound/Audio :: Players Requires-Python: >=3.13 Description-Content-Type: text/markdown License-File: LICENSE Requires-Dist: mopidy>=4.0.0 Requires-Dist: pydbus>=0.6.0 Requires-Dist: pygobject>=3.50 Requires-Dist: pykka>=4.1 Dynamic: license-file # mopidy-mpris [![Latest PyPI version](https://img.shields.io/pypi/v/mopidy-mpris)](https://pypi.org/p/mopidy-mpris) [![CI build status](https://img.shields.io/github/actions/workflow/status/mopidy/mopidy-mpris/ci.yml)](https://github.com/mopidy/mopidy-mpris/actions/workflows/ci.yml) [![Test coverage](https://img.shields.io/codecov/c/gh/mopidy/mopidy-mpris)](https://codecov.io/gh/mopidy/mopidy-mpris) [Mopidy](https://mopidy.com/) extension for controlling Mopidy through the MPRIS D-Bus interface. Mopidy-MPRIS supports the minimum requirements of the [MPRIS specification][1] as well as the optional [Playlists interface][2]. The [TrackList interface][3] is currently not supported. [1]: https://specifications.freedesktop.org/mpris-spec/latest/ [2]: https://specifications.freedesktop.org/mpris-spec/latest/Playlists_Interface.html [3]: https://specifications.freedesktop.org/mpris-spec/latest/Track_List_Interface.html ## Maintainer wanted Mopidy-MPRIS is currently kept on life support by the Mopidy core developers. It is in need of a more dedicated maintainer. If you want to be the maintainer of Mopidy-MPRIS, please: 1. Make 2-3 good pull requests improving any part of the project. 2. Read and get familiar with all of the project's open issues. 3. Send a pull request removing this section and adding yourself as the "Current maintainer" in the "Credits" section below. In the pull request description, please refer to the previous pull requests and state that you've familiarized yourself with the open issues. As a maintainer, you'll be given push access to the repo and the authority to make releases to PyPI when you see fit. ## Installation Install by running: ```sh python3 -m pip install mopidy-mpris ``` See https://mopidy.com/ext/mpris/ for alternative installation methods. ## Configuration No configuration is required for the MPRIS extension to work. The following configuration values are available: - `mpris/enabled`: If the MPRIS extension should be enabled or not. Defaults to ``true``. - `mpris/bus_type`: The type of D-Bus bus Mopidy-MPRIS should connect to. Choices include `session` (the default) and `system`. ## Usage Once Mopidy-MPRIS has been installed and your Mopidy server has been restarted, the Mopidy-MPRIS extension announces its presence on D-Bus so that any MPRIS compatible clients on your system can interact with it. Exactly how you control Mopidy through MPRIS depends on which MPRIS client you use. ## Clients The following clients have been tested with Mopidy-MPRIS. ### GNOME Shell builtin - State: Not working - Tested versions: - Ubuntu 18.10, - GNOME Shell 3.30.1-2ubuntu1, - Mopidy-MPRIS 2.0.0 GNOME Shell, which is the default desktop on Ubuntu 18.04 onwards, has a builtin MPRIS client. This client seems to work well with Spotify's player, but Mopidy-MPRIS does not show up here. If you have any tips on what's missing to get this working, please open an issue. ### gnome-shell-extensions-mediaplayer - State: Working - Tested versions: - Ubuntu 18.10, - GNOME Shell 3.30.1-2ubuntu1, - gnome-shell-extension-mediaplayer 63, - Mopidy-MPRIS 2.0.0 - Website: https://github.com/JasonLG1979/gnome-shell-extensions-mediaplayer gnome-shell-extensions-mediaplayer is a quite feature rich MPRIS client built as an extension to GNOME Shell. With the improvements to Mopidy-MPRIS in v2.0, this extension works very well with Mopidy. ### gnome-shell-extensions-mpris-indicator-button - State: Working - Tested versions: - Ubuntu 18.10, - GNOME Shell 3.30.1-2ubuntu1, - gnome-shell-extensions-mpris-indicator-button 5, - Mopidy-MPRIS 2.0.0 - Website: https://github.com/JasonLG1979/gnome-shell-extensions-mpris-indicator-button/ gnome-shell-extensions-mpris-indicator-button is a minimalistic version of gnome-shell-extensions-mediaplayer. It works with Mopidy-MPRIS, with the exception of the play/pause button not changing state when Mopidy starts playing. If you have any tips on what's missing to get the play/pause button display correctly, please open an issue. ### Ubuntu Sound Menu - State: Unknown Historically, Ubuntu Sound Menu was the primary target for Mopidy-MPRIS' development. Since Ubuntu 18.04 replaced Unity with GNOME Shell, this is no longer the case. It is currently unknown to what degree Mopidy-MPRIS works with old Ubuntu setups. If you run an Ubuntu setup with Unity and have tested Mopidy-MPRIS, please open an issue to share your results. ## Advanced setups ### Running as a service If you have input on how to best configure Mopidy-MPRIS when Mopidy is running as a service, please add a comment to [issue #15](https://github.com/mopidy/mopidy-mpris/issues/15). ### MPRIS on the system bus You can set the `mpris/bus_type` config value to `system`. This will lead to Mopidy-MPRIS making itself available on the system bus instead of the logged in user's session bus. > [!NOTE] > Few MPRIS clients will try to access MPRIS devices on the system bus, so > this will give you limited functionality. For example, media keys in > GNOME Shell does not work with media players that expose their MPRIS > interface on the system bus instead of the user's session bus. The default setup will often not permit Mopidy to publish its service on the D-Bus system bus, causing a warning similar to this in Mopidy's log: ``` MPRIS frontend setup failed (g-dbus-error-quark: GDBus.Error:org.freedesktop.DBus.Error.AccessDenied: Connection ":1.3071" is not allowed to own the service "org.mpris.MediaPlayer2.mopidy" due to security policies in the configuration file (9)) ``` To solve this, create the file `/etc/dbus-1/system.d/org.mpris.MediaPlayer2.mopidy.conf` with the following contents: ```xml ``` If you run Mopidy as another user than `mopidy`, you must update `user="mopidy"` in the above file accordingly. Once the file is in place, you must restart Mopidy for the change to take effect. To test the setup, you can run the following command as any user on the system to play/pause the music: ```sh dbus-send --system --print-reply \ --dest=org.mpris.MediaPlayer2.mopidy \ /org/mpris/MediaPlayer2 \ org.mpris.MediaPlayer2.Player.PlayPause ``` ### UPnP/DLNA with Rygel [Rygel](https://wiki.gnome.org/Projects/Rygel) is an application that will translate between Mopidy's MPRIS interface and UPnP. Rygel must be run on the same machine as Mopidy, but will make Mopidy controllable by any device on the local network that can control a UPnP/DLNA MediaRenderer. The setup process is approximately as follows: 1. Install Rygel. On Debian/Ubuntu: ```sh sudo apt install rygel ``` 2. Enable Rygel's MPRIS plugin. On Debian/Ubuntu, edit `/etc/rygel.conf`, find the `[MPRIS]` section, and change `enabled=false` to `enabled=true`. 3. Start Rygel. To start it as the current user:: ```sh systemctl --user start rygel ``` To make Rygel start as the current user on boot:: ```sh systemctl --user enable rygel ``` 4. Configure your system's firewall to allow the local network to reach Rygel. Exactly how is out of scope for this document. 5. Start Mopidy with Mopidy-MPRIS enabled. 6. If you view Rygel's log output with:: ```sh journalctl --user -feu rygel ``` You should see a log statement similar to:: ``` New plugin "org.mpris.MediaPlayer2.mopidy" available ``` 6. If everything went well, you should now be able to control Mopidy from a device on your local network that can control an UPnP/DLNA MediaRenderer, for example the Android app BubbleUPnP. Alternatively, [upmpdcli combined with Mopidy-MPD](https://docs.mopidy.com/en/latest/clients/upnp/) serves the same purpose as this setup. ## Project resources - [Source code](https://github.com/mopidy/mopidy-mpris) - [Issues](https://github.com/mopidy/mopidy-mpris/issues) - [Releases](https://github.com/mopidy/mopidy-mpris/releases) ## Development ### Set up development environment Clone the repo using, e.g. using [gh](https://cli.github.com/): ```sh gh repo clone mopidy/mopidy-mpris ``` Enter the directory, and install dependencies using [uv](https://docs.astral.sh/uv/): ```sh cd mopidy-mpris/ uv sync ``` ### Running tests To run all tests and linters in isolated environments, use [tox](https://tox.wiki/): ```sh tox ``` To only run tests, use [pytest](https://pytest.org/): ```sh pytest ``` To format the code, use [ruff](https://docs.astral.sh/ruff/): ```sh ruff format . ``` To check for lints with ruff, run: ```sh ruff check . ``` To check for type errors, use [pyright](https://microsoft.github.io/pyright/): ```sh pyright . ``` ### Adding features and fixing bugs Mopidy-MPRIS has an extensive test suite, so the first step for all changes or additions is to add a test exercising the new code. However, making the tests pass doesn't ensure that what comes out on the D-Bus bus is correct. To introspect this through the bus, there's a couple of useful tools. ### Browsing the MPRIS API with D-Feet D-Feet is a graphical D-Bus browser. On Debian/Ubuntu systems it can be installed by running: ```sh sudo apt install d-feet ``` Then run the `d-feet` command. In the D-Feet window, select the tab corresponding to the bus you run Mopidy-MPRIS on, usually the session bus. Then search for "MediaPlayer2" to find all available MPRIS interfaces. To get the current value of a property, double-click it. To execute a method, double-click it, provide any required arguments, and click "Execute". For more information on D-Feet, see the [GNOME wiki](https://wiki.gnome.org/Apps/DFeet). ### Testing the MPRIS API with pydbus To use the MPRIS API directly, start Mopidy, and then run the following in a Python shell to use `pydbus` as an MPRIS client: ```pycon >>> import pydbus >>> bus = pydbus.SessionBus() >>> player = bus.get('org.mpris.MediaPlayer2.mopidy', '/org/mpris/MediaPlayer2') ``` Now you can control Mopidy through the player object. To get properties from Mopidy, run for example: ```pycon >>> player.PlaybackStatus 'Playing' >>> player.Metadata {'mpris:artUrl': 'https://i.scdn.co/image/8eb49b41eeb45c1cf53e1ddfea7973d9ca257777', 'mpris:length': 342000000, 'mpris:trackid': '/com/mopidy/track/36', 'xesam:album': '65/Milo', 'xesam:albumArtist': ['Kiasmos'], 'xesam:artist': ['Rival Consoles'], 'xesam:discNumber': 1, 'xesam:title': 'Arp', 'xesam:trackNumber': 5, 'xesam:url': 'spotify:track:7CoxEEsqo3XdvUsScRV4WD'} >>> ``` To pause Mopidy's playback through D-Bus, run: ```pycon >>> player.Pause() >>> ``` For details on the API, please refer to the [MPRIS specification](https://specifications.freedesktop.org/mpris-spec/latest/). ### Making a release To make a release to PyPI, go to the project's [GitHub releases page](https://github.com/mopidy/mopidy-mpris/releases) and click the "Draft a new release" button. In the "choose a tag" dropdown, select the tag you want to release or create a new tag, e.g. `v0.1.0`. Add a title, e.g. `v0.1.0`, and a description of the changes. Decide if the release is a pre-release (alpha, beta, or release candidate) or should be marked as the latest release, and click "Publish release". Once the release is created, the `release.yml` GitHub Action will automatically build and publish the release to [PyPI](https://pypi.org/project/mopidy-mpris/). ## Credits - Original author: [Stein Magnus Jodal](https://github.com/mopidy) - Current maintainer: None. Maintainer wanted, see section above. - [Contributors](https://github.com/mopidy/mopidy-mpris/graphs/contributors) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1777126579.0 mopidy_mpris-4.0.0/src/mopidy_mpris.egg-info/SOURCES.txt0000644000175100017510000000144215173146263022565 0ustar00runnerrunner.copier-answers.yml .gitignore .mailmap LICENSE README.md pyproject.toml .github/workflows/ci.yml .github/workflows/release.yml src/mopidy_mpris/__init__.py src/mopidy_mpris/ext.conf src/mopidy_mpris/frontend.py src/mopidy_mpris/interface.py src/mopidy_mpris/player.py src/mopidy_mpris/playlists.py src/mopidy_mpris/root.py src/mopidy_mpris/server.py src/mopidy_mpris.egg-info/PKG-INFO src/mopidy_mpris.egg-info/SOURCES.txt src/mopidy_mpris.egg-info/dependency_links.txt src/mopidy_mpris.egg-info/entry_points.txt src/mopidy_mpris.egg-info/requires.txt src/mopidy_mpris.egg-info/top_level.txt tests/__init__.py tests/conftest.py tests/dummy_audio.py tests/dummy_backend.py tests/dummy_mixer.py tests/test_events.py tests/test_extension.py tests/test_player.py tests/test_playlists.py tests/test_root.py././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1777126579.0 mopidy_mpris-4.0.0/src/mopidy_mpris.egg-info/dependency_links.txt0000644000175100017510000000000115173146263024746 0ustar00runnerrunner ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1777126579.0 mopidy_mpris-4.0.0/src/mopidy_mpris.egg-info/entry_points.txt0000644000175100017510000000005415173146263024175 0ustar00runnerrunner[mopidy.ext] mpris = mopidy_mpris:Extension ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1777126579.0 mopidy_mpris-4.0.0/src/mopidy_mpris.egg-info/requires.txt0000644000175100017510000000006715173146263023303 0ustar00runnerrunnermopidy>=4.0.0 pydbus>=0.6.0 pygobject>=3.50 pykka>=4.1 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1777126579.0 mopidy_mpris-4.0.0/src/mopidy_mpris.egg-info/top_level.txt0000644000175100017510000000001515173146263023426 0ustar00runnerrunnermopidy_mpris ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1777126579.3207746 mopidy_mpris-4.0.0/tests/0000755000175100017510000000000015173146263015046 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1777126572.0 mopidy_mpris-4.0.0/tests/__init__.py0000644000175100017510000000000015173146254017145 0ustar00runnerrunner././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1777126572.0 mopidy_mpris-4.0.0/tests/conftest.py0000644000175100017510000000237715173146254017256 0ustar00runnerrunnerfrom __future__ import annotations from typing import TYPE_CHECKING, cast import pytest from mopidy.core import Core from tests import dummy_audio, dummy_backend, dummy_mixer if TYPE_CHECKING: from collections.abc import Generator from mopidy.audio import AudioProxy from mopidy.backend import BackendProxy from mopidy.core import CoreProxy from mopidy.mixer import MixerProxy @pytest.fixture def config(): return { "core": {"max_tracklist_length": 10000}, } @pytest.fixture def audio() -> Generator[AudioProxy]: actor = cast("AudioProxy", dummy_audio.create_proxy()) yield actor actor.stop() @pytest.fixture def backend(audio) -> Generator[BackendProxy]: actor = cast("BackendProxy", dummy_backend.create_proxy(audio=audio)) yield actor actor.stop() @pytest.fixture def mixer() -> Generator[MixerProxy]: actor = cast("MixerProxy", dummy_mixer.create_proxy()) yield actor actor.stop() @pytest.fixture def core(config, backend, mixer, audio) -> Generator[CoreProxy]: actor = cast( "CoreProxy", Core.start( config=config, backends=[backend], mixer=mixer, audio=audio, ).proxy(), ) yield actor actor.stop() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1777126572.0 mopidy_mpris-4.0.0/tests/dummy_audio.py0000644000175100017510000000626415173146254017744 0ustar00runnerrunner"""A dummy audio actor for use in tests. This class implements the audio API in the simplest way possible. It is used in tests of the core and backends. """ from typing import override import pykka from mopidy import audio from mopidy.types import DurationMs, PlaybackState def create_proxy(config=None, mixer=None): return DummyAudio.start(config, mixer).proxy() # TODO: reset position on track change? class DummyAudio(audio.Audio, pykka.ThreadingActor): def __init__(self, config=None, mixer=None): super().__init__() self.state = PlaybackState.STOPPED self._position = DurationMs(0) self._source_setup_callback = None self._about_to_finish_callback = None self._uri = None self._stream_changed = False self._live_stream = False self._tags = {} self._bad_uris = set() @override def set_uri(self, uri, live_stream=False, download=False): assert self._uri is None, "prepare change not called before set" self._position = DurationMs(0) self._uri = uri self._stream_changed = True self._live_stream = live_stream self._tags = {} @override def set_source_setup_callback(self, callback): self._source_setup_callback = callback @override def set_about_to_finish_callback(self, callback): self._about_to_finish_callback = callback @override def get_position(self): return self._position @override def set_position(self, position): self._position = position audio.AudioListener.send("position_changed", position=position) return True @override def start_playback(self): return self._change_state(PlaybackState.PLAYING) @override def pause_playback(self): return self._change_state(PlaybackState.PAUSED) @override def prepare_change(self): self._uri = None self._source_setup_callback = None return True @override def stop_playback(self): return self._change_state(PlaybackState.STOPPED) @override def get_current_tags(self): return self._tags def _change_state(self, new_state): if not self._uri: return False if new_state == PlaybackState.STOPPED and self._uri: self._stream_changed = True self._uri = None if self._stream_changed: self._stream_changed = False audio.AudioListener.send("stream_changed", uri=self._uri) if self._uri is not None: audio.AudioListener.send("position_changed", position=0) old_state, self.state = self.state, new_state audio.AudioListener.send( "state_changed", old_state=old_state, new_state=new_state, target_state=None, ) if new_state == PlaybackState.PLAYING: self._tags["audio-codec"] = ["fake info..."] audio.AudioListener.send("tags_changed", tags=["audio-codec"]) return self._uri not in self._bad_uris def trigger_fake_tags_changed(self, tags): self._tags.update(tags) audio.AudioListener.send("tags_changed", tags=self._tags.keys()) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1777126572.0 mopidy_mpris-4.0.0/tests/dummy_backend.py0000644000175100017510000001045215173146254020224 0ustar00runnerrunner"""A dummy backend for use in tests. This backend implements the backend API in the simplest way possible. It is used in tests of the frontends. """ import pykka from mopidy import backend from mopidy.models import Playlist, Ref, SearchResult def create_proxy(config=None, audio=None): return DummyBackend.start(config=config, audio=audio).proxy() class DummyBackend(pykka.ThreadingActor, backend.Backend): def __init__(self, config, audio): super().__init__() self.library = DummyLibraryProvider(backend=self) if audio: self.playback = backend.PlaybackProvider(audio=audio, backend=self) else: self.playback = DummyPlaybackProvider(audio=audio, backend=self) self.playlists = DummyPlaylistsProvider(backend=self) self.uri_schemes = ["dummy"] class DummyLibraryProvider(backend.LibraryProvider): root_directory = Ref.directory(uri="dummy:/", name="dummy") def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.dummy_library = [] self.dummy_get_distinct_result = {} self.dummy_get_images_result = {} self.dummy_browse_result = {} self.dummy_find_exact_result = SearchResult() self.dummy_search_result = SearchResult() def browse(self, path): return self.dummy_browse_result.get(path, []) def get_distinct(self, field, query=None): return self.dummy_get_distinct_result.get(field, set()) def get_images(self, uris): return self.dummy_get_images_result def lookup(self, uri): uri = Ref.track(uri=uri).uri return [t for t in self.dummy_library if uri == t.uri] def refresh(self, uri=None): pass def search(self, query=None, uris=None, exact=False): # noqa: FBT002 if exact: # TODO: remove uses of dummy_find_exact_result return self.dummy_find_exact_result return self.dummy_search_result class DummyPlaybackProvider(backend.PlaybackProvider): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._uri = None self._time_position = 0 def pause(self): return True def play(self): return self._uri and self._uri != "dummy:error" def change_track(self, track): """Pass a track with URI 'dummy:error' to force failure""" self._uri = track.uri self._time_position = 0 return True def prepare_change(self): pass def resume(self): return True def seek(self, time_position): self._time_position = time_position return True def stop(self): self._uri = None return True def get_time_position(self): return self._time_position class DummyPlaylistsProvider(backend.PlaylistsProvider): def __init__(self, backend): super().__init__(backend) self._playlists = [] self._allow_save = True def set_dummy_playlists(self, playlists): """For tests using the dummy provider through an actor proxy.""" self._playlists = playlists def set_allow_save(self, enabled): self._allow_save = enabled def as_list(self): return [Ref.playlist(uri=pl.uri, name=pl.name) for pl in self._playlists] def get_items(self, uri): playlist = self.lookup(uri) if playlist is None: return None return [Ref.track(uri=t.uri, name=t.name) for t in playlist.tracks] def lookup(self, uri): uri = Ref.playlist(uri=uri).uri for playlist in self._playlists: if playlist.uri == uri: return playlist return None def refresh(self): pass def create(self, name): playlist = Playlist(name=name, uri=f"dummy:{name}") self._playlists.append(playlist) return playlist def delete(self, uri): playlist = self.lookup(uri) if playlist: self._playlists.remove(playlist) def save(self, playlist): if not self._allow_save: return None old_playlist = self.lookup(playlist.uri) if old_playlist is not None: index = self._playlists.index(old_playlist) self._playlists[index] = playlist else: self._playlists.append(playlist) return playlist ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1777126572.0 mopidy_mpris-4.0.0/tests/dummy_mixer.py0000644000175100017510000000121415173146254017755 0ustar00runnerrunnerimport pykka from mopidy import mixer def create_proxy(config=None): return DummyMixer.start(config=None).proxy() class DummyMixer(pykka.ThreadingActor, mixer.Mixer): def __init__(self, config): super().__init__() self._volume = None self._mute = None def get_volume(self): return self._volume def set_volume(self, volume): self._volume = volume self.trigger_volume_changed(volume=volume) return True def get_mute(self): return self._mute def set_mute(self, mute): self._mute = mute self.trigger_mute_changed(mute=mute) return True ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1777126572.0 mopidy_mpris-4.0.0/tests/test_events.py0000644000175100017510000001312415173146254017764 0ustar00runnerrunnerfrom unittest import mock import pytest from mopidy.models import Playlist, TlTrack, Track from mopidy.types import DurationMs, PlaybackState, TracklistId, Uri from mopidy_mpris import player, playlists, root, server from mopidy_mpris.frontend import MprisFrontend @pytest.fixture def frontend() -> MprisFrontend: # As a plain class, not an actor: result = MprisFrontend(config=None, core=None) result.mpris = mock.Mock(spec=server.Server) result.mpris.root = mock.Mock(spec=root.Root) result.mpris.root.INTERFACE = root.Root.INTERFACE result.mpris.player = mock.Mock(spec=player.Player) result.mpris.player.INTERFACE = player.Player.INTERFACE result.mpris.playlists = mock.Mock(spec=playlists.Playlists) result.mpris.playlists.INTERFACE = playlists.Playlists.INTERFACE return result def test_track_playback_paused_event_changes_playback_status(frontend: MprisFrontend): frontend.mpris.player.PlaybackStatus = "Paused" frontend.track_playback_paused( tl_track=TlTrack(tlid=TracklistId(1), track=Track(uri=Uri("test:foo"))), time_position=0, ) frontend.mpris.player.PropertiesChanged.assert_called_with( player.Player.INTERFACE, {"PlaybackStatus": "Paused"}, [] ) def test_track_playback_resumed_event_changes_playback_status(frontend: MprisFrontend): frontend.mpris.player.PlaybackStatus = "Playing" frontend.track_playback_resumed( tl_track=TlTrack(tlid=TracklistId(1), track=Track(uri=Uri("test:foo"))), time_position=0, ) frontend.mpris.player.PropertiesChanged.assert_called_with( player.Player.INTERFACE, {"PlaybackStatus": "Playing"}, [] ) def test_track_playback_started_changes_playback_status_and_metadata( frontend: MprisFrontend, ): frontend.mpris.player.Metadata = "..." frontend.mpris.player.PlaybackStatus = "Playing" frontend.track_playback_started( tl_track=TlTrack(tlid=TracklistId(1), track=Track(uri=Uri("test:foo"))) ) frontend.mpris.player.PropertiesChanged.assert_called_with( player.Player.INTERFACE, {"Metadata": "...", "PlaybackStatus": "Playing"}, [], ) def test_track_playback_ended_changes_playback_status_and_metadata( frontend: MprisFrontend, ): frontend.mpris.player.Metadata = "..." frontend.mpris.player.PlaybackStatus = "Stopped" frontend.track_playback_ended( tl_track=TlTrack(tlid=TracklistId(1), track=Track(uri=Uri("test:foo"))), time_position=DurationMs(0), ) frontend.mpris.player.PropertiesChanged.assert_called_with( player.Player.INTERFACE, {"Metadata": "...", "PlaybackStatus": "Stopped"}, [], ) def test_playback_state_changed_changes_playback_status_and_metadata( frontend: MprisFrontend, ): frontend.mpris.player.Metadata = "..." frontend.mpris.player.PlaybackStatus = "Stopped" frontend.playback_state_changed(PlaybackState.PLAYING, PlaybackState.STOPPED) frontend.mpris.player.PropertiesChanged.assert_called_with( player.Player.INTERFACE, {"Metadata": "...", "PlaybackStatus": "Stopped"}, [], ) def test_playlists_loaded_event_changes_playlist_count(frontend: MprisFrontend): frontend.mpris.playlists.PlaylistCount = 17 frontend.playlists_loaded() frontend.mpris.playlists.PropertiesChanged.assert_called_with( playlists.Playlists.INTERFACE, {"PlaylistCount": 17}, [] ) def test_playlist_changed_event_causes_mpris_playlist_changed_event( frontend: MprisFrontend, ): playlist = Playlist(uri="dummy:foo", name="foo") frontend.playlist_changed(playlist=playlist) frontend.mpris.playlists.PlaylistChanged.assert_called_with( "/com/mopidy/playlist/MR2W23LZHJTG63Y_", "foo", "" ) def test_playlist_deleted_event_changes_playlist_count(frontend: MprisFrontend): frontend.mpris.playlists.PlaylistCount = 17 frontend.playlist_deleted("dummy:foo") frontend.mpris.playlists.PropertiesChanged.assert_called_with( playlists.Playlists.INTERFACE, {"PlaylistCount": 17}, [] ) def test_options_changed_event_changes_loopstatus_and_shuffle(frontend: MprisFrontend): frontend.mpris.player.CanGoPrevious = False frontend.mpris.player.CanGoNext = True frontend.mpris.player.LoopStatus = "Track" frontend.mpris.player.Shuffle = True frontend.options_changed() frontend.mpris.player.PropertiesChanged.assert_called_with( player.Player.INTERFACE, { "LoopStatus": "Track", "Shuffle": True, "CanGoPrevious": False, "CanGoNext": True, }, [], ) def test_volume_changed_event_changes_volume(frontend: MprisFrontend): frontend.mpris.player.Volume = 1.0 frontend.volume_changed(volume=100) frontend.mpris.player.PropertiesChanged.assert_called_with( player.Player.INTERFACE, {"Volume": 1.0}, [] ) def test_mute_changed_event_changes_volume(frontend: MprisFrontend): frontend.mpris.player.Volume = 0.0 frontend.mute_changed(True) frontend.mpris.player.PropertiesChanged.assert_called_with( player.Player.INTERFACE, {"Volume": 0.0}, [] ) def test_seeked_event_causes_mpris_seeked_event(frontend: MprisFrontend): frontend.seeked(time_position=31000) frontend.mpris.player.Seeked.assert_called_with(31000000) def test_stream_title_changed_changes_metadata(frontend: MprisFrontend): frontend.mpris.player.Metadata = "..." frontend.stream_title_changed("a new title") frontend.mpris.player.PropertiesChanged.assert_called_with( player.Player.INTERFACE, {"Metadata": "..."}, [] ) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1777126572.0 mopidy_mpris-4.0.0/tests/test_extension.py0000644000175100017510000000123615173146254020475 0ustar00runnerrunnerfrom unittest import mock from mopidy_mpris import Extension from mopidy_mpris import frontend as frontend_lib def test_get_default_config(): ext = Extension() config = ext.get_default_config() assert "[mpris]" in config assert "enabled = true" in config assert "bus_type = session" in config def test_get_config_schema(): ext = Extension() schema = ext.get_config_schema() assert "desktop_file" in schema assert "bus_type" in schema def test_get_frontend_classes(): ext = Extension() registry = mock.Mock() ext.setup(registry) registry.add.assert_called_once_with("frontend", frontend_lib.MprisFrontend) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1777126572.0 mopidy_mpris-4.0.0/tests/test_player.py0000644000175100017510000007652715173146254017774 0ustar00runnerrunnerfrom __future__ import annotations from typing import TYPE_CHECKING import pytest from gi.repository import GLib # pyright: ignore[reportMissingModuleSource] from mopidy.models import Album, Artist, Image, Track from mopidy.types import PlaybackState from mopidy_mpris.player import Player if TYPE_CHECKING: from mopidy.core import CoreProxy PLAYING = PlaybackState.PLAYING PAUSED = PlaybackState.PAUSED STOPPED = PlaybackState.STOPPED @pytest.fixture def player(config, core) -> Player: return Player(config, core) @pytest.mark.parametrize( ("state", "expected"), [(PLAYING, "Playing"), (PAUSED, "Paused"), (STOPPED, "Stopped")], ) def test_get_playback_status(core: CoreProxy, player: Player, state, expected): core.playback.set_state(state) assert player.PlaybackStatus == expected @pytest.mark.parametrize( ("repeat", "single", "expected"), [ (False, False, "None"), (False, True, "None"), (True, False, "Playlist"), (True, True, "Track"), ], ) def test_get_loop_status(core: CoreProxy, player: Player, repeat, single, expected): core.tracklist.set_repeat(repeat) core.tracklist.set_single(single) assert player.LoopStatus == expected @pytest.mark.parametrize( ("status", "expected_repeat", "expected_single"), [("None", False, False), ("Track", True, True), ("Playlist", True, False)], ) def test_set_loop_status( core: CoreProxy, player: Player, status, expected_repeat, expected_single ): player.LoopStatus = status assert core.tracklist.get_repeat().get() is expected_repeat assert core.tracklist.get_single().get() is expected_single def test_set_loop_status_is_ignored_if_can_control_is_false( core: CoreProxy, player: Player ): player._CanControl = False core.tracklist.set_repeat(True) core.tracklist.set_single(True) player.LoopStatus = "None" assert core.tracklist.get_repeat().get() is True assert core.tracklist.get_single().get() is True def test_get_rate_is_greater_or_equal_than_minimum_rate(player: Player): assert player.Rate >= player.MinimumRate def test_get_rate_is_less_or_equal_than_maximum_rate(player: Player): assert player.Rate <= player.MaximumRate def test_set_rate_to_zero_pauses_playback(core: CoreProxy, player: Player): core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) core.playback.play().get() assert core.playback.get_state().get() == PLAYING player.Rate = 0 assert core.playback.get_state().get() == PAUSED def test_set_rate_is_ignored_if_can_control_is_false(core: CoreProxy, player: Player): player._CanControl = False core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) core.playback.play().get() assert core.playback.get_state().get() == PLAYING player.Rate = 0 assert core.playback.get_state().get() == PLAYING @pytest.mark.parametrize("random", [True, False]) def test_get_shuffle(core: CoreProxy, player: Player, random): core.tracklist.set_random(random) assert player.Shuffle is random @pytest.mark.parametrize("value", [True, False]) def test_set_shuffle(core: CoreProxy, player: Player, value): core.tracklist.set_random(not value) assert core.tracklist.get_random().get() is not value player.Shuffle = value assert core.tracklist.get_random().get() is value def test_set_shuffle_is_ignored_if_can_control_is_false( core: CoreProxy, player: Player ): player._CanControl = False core.tracklist.set_random(False) player.Shuffle = True assert core.tracklist.get_random().get() is False def test_get_metadata_is_empty_when_no_current_track(player: Player): assert player.Metadata == {} def test_get_metadata(core: CoreProxy, player: Player): core.tracklist.add( [ Track( uri="dummy:a", length=3600000, name="a", artists=[Artist(name="b"), Artist(name="c"), Artist(name=None)], album=Album(name="d", artists=[Artist(name="e"), Artist(name=None)]), ) ] ) core.playback.play().get() (tlid, _track) = core.playback.get_current_tl_track().get() result = player.Metadata assert result["mpris:trackid"] == GLib.Variant("o", f"/com/mopidy/track/{tlid}") assert result["mpris:length"] == GLib.Variant("x", 3600000000) assert result["xesam:url"] == GLib.Variant("s", "dummy:a") assert result["xesam:title"] == GLib.Variant("s", "a") assert result["xesam:artist"] == GLib.Variant("as", ["b", "c"]) assert result["xesam:album"] == GLib.Variant("s", "d") assert result["xesam:albumArtist"] == GLib.Variant("as", ["e"]) def test_get_metadata_prefers_stream_title_over_track_name( audio, core: CoreProxy, player ): core.tracklist.add([Track(uri="dummy:a", name="Track name")]) core.playback.play().get() result = player.Metadata assert result["xesam:title"] == GLib.Variant("s", "Track name") audio.trigger_fake_tags_changed( { "organization": ["Required for Mopidy core to care about the title"], "title": ["Stream title"], } ).get() result = player.Metadata assert result["xesam:title"] == GLib.Variant("s", "Stream title") def test_get_metadata_use_library_image_as_art_url( backend, core: CoreProxy, player: Player ): backend.library.dummy_get_images_result = { "dummy:a": [ Image(uri="http://example.com/small.jpg", width=100, height=100), Image(uri="http://example.com/large.jpg", width=200, height=200), Image(uri="http://example.com/unsized.jpg"), ], } core.tracklist.add([Track(uri="dummy:a")]) core.playback.play().get() result = player.Metadata assert result["mpris:artUrl"] == GLib.Variant("s", "http://example.com/large.jpg") def test_get_metadata_has_disc_number_in_album(core: CoreProxy, player: Player): core.tracklist.add([Track(uri="dummy:a", disc_no=2)]) core.playback.play().get() assert player.Metadata["xesam:discNumber"] == GLib.Variant("i", 2) def test_get_metadata_has_track_number_in_album(core: CoreProxy, player: Player): core.tracklist.add([Track(uri="dummy:a", track_no=7)]) core.playback.play().get() assert player.Metadata["xesam:trackNumber"] == GLib.Variant("i", 7) def test_get_volume_should_return_volume_between_zero_and_one( core: CoreProxy, player: Player ): # dummy_mixer starts out with None as the volume assert player.Volume == 0 core.mixer.set_volume(0) assert player.Volume == 0 core.mixer.set_volume(50) assert player.Volume == 0.5 core.mixer.set_volume(100) assert player.Volume == 1 def test_get_volume_should_return_0_if_muted(core: CoreProxy, player: Player): assert player.Volume == 0 core.mixer.set_volume(100) assert player.Volume == 1 core.mixer.set_mute(True) assert player.Volume == 0 core.mixer.set_mute(False) assert player.Volume == 1 @pytest.mark.parametrize( ("volume", "expected"), [(-1.0, 0), (0, 0), (0.5, 50), (1.0, 100), (2.0, 100)], ) def test_set_volume(core: CoreProxy, player: Player, volume, expected): player.Volume = volume assert core.mixer.get_volume().get() == expected def test_set_volume_to_not_a_number_does_not_change_volume( core: CoreProxy, player: Player ): core.mixer.set_volume(10).get() player.Volume = None assert core.mixer.get_volume().get() == 10 def test_set_volume_is_ignored_if_can_control_is_false(core: CoreProxy, player: Player): player._CanControl = False core.mixer.set_volume(0) player.Volume = 1.0 assert core.mixer.get_volume().get() == 0 def test_set_volume_to_positive_value_unmutes_if_muted(core: CoreProxy, player: Player): core.mixer.set_volume(10).get() core.mixer.set_mute(True).get() player.Volume = 1.0 assert core.mixer.get_volume().get() == 100 assert core.mixer.get_mute().get() is False def test_set_volume_to_zero_does_not_unmute_if_muted(core: CoreProxy, player: Player): core.mixer.set_volume(10).get() core.mixer.set_mute(True).get() player.Volume = 0.0 assert core.mixer.get_volume().get() == 0 assert core.mixer.get_mute().get() is True def test_get_position_returns_time_position_in_microseconds( core: CoreProxy, player: Player ): core.tracklist.add([Track(uri="dummy:a", length=40000)]) core.playback.play().get() core.playback.seek(10000).get() result_in_microseconds = player.Position result_in_milliseconds = result_in_microseconds // 1000 assert result_in_milliseconds >= 10000 def test_get_position_when_no_current_track_should_be_zero(player: Player): result_in_microseconds = player.Position result_in_milliseconds = result_in_microseconds // 1000 assert result_in_milliseconds == 0 def test_get_minimum_rate_is_one_or_less(player: Player): assert player.MinimumRate <= 1.0 def test_get_maximum_rate_is_one_or_more(player: Player): assert player.MaximumRate >= 1.0 def test_can_go_next_is_true_if_can_control_and_other_next_track( core: CoreProxy, player ): player._CanControl = True core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) core.playback.play().get() assert player.CanGoNext def test_can_go_next_is_false_if_next_track_is_the_same( core: CoreProxy, player: Player ): player._CanControl = True core.tracklist.add([Track(uri="dummy:a")]) core.tracklist.set_repeat(True) core.playback.play().get() assert not player.CanGoNext def test_can_go_next_is_false_if_can_control_is_false(core: CoreProxy, player: Player): player._CanControl = False core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) core.playback.play().get() assert not player.CanGoNext def test_can_go_previous_is_true_if_can_control_and_previous_track( core: CoreProxy, player ): player._CanControl = True core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) core.playback.play().get() core.playback.next().get() assert player.CanGoPrevious def test_can_go_previous_is_false_if_previous_track_is_the_same( core: CoreProxy, player ): player._CanControl = True core.tracklist.add([Track(uri="dummy:a")]) core.tracklist.set_repeat(True) core.playback.play().get() assert not player.CanGoPrevious def test_can_go_previous_is_false_if_can_control_is_false( core: CoreProxy, player: Player ): player._CanControl = False core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) core.playback.play().get() core.playback.next().get() assert not player.CanGoPrevious def test_can_play_is_true_if_can_control_and_current_track( core: CoreProxy, player: Player ): player._CanControl = True core.tracklist.add([Track(uri="dummy:a")]) core.playback.play().get() assert core.playback.get_current_track().get() assert player.CanPlay def test_can_play_is_false_if_no_current_track(core: CoreProxy, player: Player): player._CanControl = True assert not core.playback.get_current_track().get() assert not player.CanPlay def test_can_play_if_false_if_can_control_is_false(core: CoreProxy, player: Player): player._CanControl = False assert not player.CanPlay def test_can_pause_is_true_if_can_control_and_track_can_be_paused( core: CoreProxy, player ): player._CanControl = True assert player.CanPause def test_can_pause_if_false_if_can_control_is_false(core: CoreProxy, player: Player): player._CanControl = False assert not player.CanPause def test_can_seek_is_true_if_can_control_is_true(core: CoreProxy, player: Player): player._CanControl = True assert player.CanSeek def test_can_seek_is_false_if_can_control_is_false(core: CoreProxy, player: Player): player._CanControl = False result = player.CanSeek assert not result def test_can_control_is_true(core: CoreProxy, player: Player): result = player.CanControl assert result def test_next_is_ignored_if_can_go_next_is_false(core: CoreProxy, player: Player): player._CanControl = False assert not player.CanGoNext core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) core.playback.play().get() assert core.playback.get_current_track().get().uri == "dummy:a" player.Next() assert core.playback.get_current_track().get().uri == "dummy:a" def test_next_when_playing_skips_to_next_track_and_keep_playing( core: CoreProxy, player ): core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) core.playback.play().get() assert core.playback.get_current_track().get().uri == "dummy:a" assert core.playback.get_state().get() == PLAYING player.Next() assert core.playback.get_current_track().get().uri == "dummy:b" assert core.playback.get_state().get() == PLAYING def test_next_when_at_end_of_list_should_stop_playback(core: CoreProxy, player: Player): core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) core.playback.play().get() core.playback.next().get() assert core.playback.get_current_track().get().uri == "dummy:b" assert core.playback.get_state().get() == PLAYING player.Next() assert core.playback.get_state().get() == STOPPED def test_next_when_paused_should_skip_to_next_track_and_stay_paused( core: CoreProxy, player ): core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) core.playback.play().get() core.playback.pause().get() assert core.playback.get_current_track().get().uri == "dummy:a" assert core.playback.get_state().get() == PAUSED player.Next() assert core.playback.get_current_track().get().uri == "dummy:b" assert core.playback.get_state().get() == PAUSED def test_next_when_stopped_skips_to_next_track_and_stay_stopped( core: CoreProxy, player ): core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) core.playback.play().get() core.playback.stop() assert core.playback.get_current_track().get().uri == "dummy:a" assert core.playback.get_state().get() == STOPPED player.Next() assert core.playback.get_current_track().get().uri == "dummy:b" assert core.playback.get_state().get() == STOPPED def test_previous_is_ignored_if_can_go_previous_is_false( core: CoreProxy, player: Player ): player._CanControl = False assert not player.CanGoPrevious core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) core.playback.play().get() core.playback.next().get() assert core.playback.get_current_track().get().uri == "dummy:b" player.Previous() assert core.playback.get_current_track().get().uri == "dummy:b" def test_previous_when_playing_skips_to_prev_track_and_keep_playing( core: CoreProxy, player: Player ): core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) core.playback.play().get() core.playback.next().get() assert core.playback.get_current_track().get().uri == "dummy:b" assert core.playback.get_state().get() == PLAYING player.Previous() assert core.playback.get_current_track().get().uri == "dummy:a" assert core.playback.get_state().get() == PLAYING def test_previous_when_at_start_of_list_should_stop_playback( core: CoreProxy, player: Player ): core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) core.playback.play().get() assert core.playback.get_current_track().get().uri == "dummy:a" assert core.playback.get_state().get() == PLAYING player.Previous() assert core.playback.get_state().get() == STOPPED def test_previous_when_paused_skips_to_previous_track_and_pause( core: CoreProxy, player: Player ): core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) core.playback.play().get() core.playback.next().get() core.playback.pause().get() assert core.playback.get_current_track().get().uri == "dummy:b" assert core.playback.get_state().get() == PAUSED player.Previous() assert core.playback.get_current_track().get().uri == "dummy:a" assert core.playback.get_state().get() == PAUSED def test_previous_when_stopped_skips_to_previous_track_and_stops( core: CoreProxy, player: Player ): core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) core.playback.play().get() core.playback.next().get() core.playback.stop() assert core.playback.get_current_track().get().uri == "dummy:b" assert core.playback.get_state().get() == STOPPED player.Previous() assert core.playback.get_current_track().get().uri == "dummy:a" assert core.playback.get_state().get() == STOPPED def test_pause_is_ignored_if_can_pause_is_false(core: CoreProxy, player: Player): player._CanControl = False assert not player.CanPause core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) core.playback.play().get() assert core.playback.get_state().get() == PLAYING player.Pause() assert core.playback.get_state().get() == PLAYING def test_pause_when_playing_should_pause_playback(core: CoreProxy, player: Player): core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) core.playback.play().get() assert core.playback.get_state().get() == PLAYING player.Pause() assert core.playback.get_state().get() == PAUSED def test_pause_when_paused_has_no_effect(core: CoreProxy, player: Player): core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) core.playback.play().get() core.playback.pause().get() assert core.playback.get_state().get() == PAUSED player.Pause() assert core.playback.get_state().get() == PAUSED def test_playpause_is_ignored_if_can_pause_is_false(core: CoreProxy, player: Player): player._CanControl = False assert not player.CanPause core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) core.playback.play().get() assert core.playback.get_state().get() == PLAYING player.PlayPause() assert core.playback.get_state().get() == PLAYING def test_playpause_when_playing_should_pause_playback(core: CoreProxy, player: Player): core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) core.playback.play().get() assert core.playback.get_state().get() == PLAYING player.PlayPause() assert core.playback.get_state().get() == PAUSED def test_playpause_when_paused_should_resume_playback(core: CoreProxy, player: Player): core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) core.playback.play().get() core.playback.pause().get() assert core.playback.get_state().get() == PAUSED at_pause = core.playback.get_time_position().get() assert at_pause >= 0 player.PlayPause() assert core.playback.get_state().get() == PLAYING after_pause = core.playback.get_time_position().get() assert after_pause >= at_pause def test_playpause_when_stopped_should_start_playback(core: CoreProxy, player: Player): core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) assert core.playback.get_state().get() == STOPPED player.PlayPause() assert core.playback.get_state().get() == PLAYING def test_stop_is_ignored_if_can_control_is_false(core: CoreProxy, player: Player): player._CanControl = False core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) core.playback.play().get() assert core.playback.get_state().get() == PLAYING player.Stop() assert core.playback.get_state().get() == PLAYING def test_stop_when_playing_should_stop_playback(core: CoreProxy, player: Player): core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) core.playback.play().get() assert core.playback.get_state().get() == PLAYING player.Stop() assert core.playback.get_state().get() == STOPPED def test_stop_when_paused_should_stop_playback(core: CoreProxy, player: Player): core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) core.playback.play().get() core.playback.pause().get() assert core.playback.get_state().get() == PAUSED player.Stop() assert core.playback.get_state().get() == STOPPED def test_play_is_ignored_if_can_play_is_false(core: CoreProxy, player: Player): player._CanControl = False assert not player.CanPlay core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) assert core.playback.get_state().get() == STOPPED player.Play() assert core.playback.get_state().get() == STOPPED def test_play_when_stopped_starts_playback(core: CoreProxy, player: Player): core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) assert core.playback.get_state().get() == STOPPED player.Play() assert core.playback.get_state().get() == PLAYING def test_play_after_pause_resumes_from_same_position(core: CoreProxy, player: Player): core.tracklist.add([Track(uri="dummy:a", length=40000)]) core.playback.play().get() before_pause = core.playback.get_time_position().get() assert before_pause >= 0 player.Pause() assert core.playback.get_state().get() == PAUSED at_pause = core.playback.get_time_position().get() assert at_pause >= before_pause player.Play() assert core.playback.get_state().get() == PLAYING after_pause = core.playback.get_time_position().get() assert after_pause >= at_pause def test_play_when_there_is_no_track_has_no_effect(core: CoreProxy, player: Player): core.tracklist.clear() assert core.playback.get_state().get() == STOPPED player.Play() assert core.playback.get_state().get() == STOPPED def test_seek_is_ignored_if_can_seek_is_false(core: CoreProxy, player: Player): player._CanControl = False assert not player.CanSeek core.tracklist.add([Track(uri="dummy:a", length=40000)]) core.playback.play().get() before_seek = core.playback.get_time_position().get() assert before_seek >= 0 milliseconds_to_seek = 10000 microseconds_to_seek = milliseconds_to_seek * 1000 player.Seek(microseconds_to_seek) after_seek = core.playback.get_time_position().get() assert before_seek <= after_seek assert after_seek < before_seek + milliseconds_to_seek def test_seek_seeks_given_microseconds_forward_in_the_current_track( core: CoreProxy, player: Player ): core.tracklist.add([Track(uri="dummy:a", length=40000)]) core.playback.play().get() before_seek = core.playback.get_time_position().get() assert before_seek >= 0 milliseconds_to_seek = 10000 microseconds_to_seek = milliseconds_to_seek * 1000 player.Seek(microseconds_to_seek) assert core.playback.get_state().get() == PLAYING after_seek = core.playback.get_time_position().get() assert after_seek >= before_seek + milliseconds_to_seek def test_seek_seeks_given_microseconds_backward_if_negative( core: CoreProxy, player: Player ): core.tracklist.add([Track(uri="dummy:a", length=40000)]) core.playback.play().get() core.playback.seek(20000).get() before_seek = core.playback.get_time_position().get() assert before_seek >= 20000 milliseconds_to_seek = -10000 microseconds_to_seek = milliseconds_to_seek * 1000 player.Seek(microseconds_to_seek) assert core.playback.get_state().get() == PLAYING after_seek = core.playback.get_time_position().get() assert after_seek >= before_seek + milliseconds_to_seek assert after_seek < before_seek def test_seek_seeks_to_start_of_track_if_new_position_is_negative( core: CoreProxy, player: Player ): core.tracklist.add([Track(uri="dummy:a", length=40000)]) core.playback.play().get() core.playback.seek(20000).get() before_seek = core.playback.get_time_position().get() assert before_seek >= 20000 milliseconds_to_seek = -30000 microseconds_to_seek = milliseconds_to_seek * 1000 player.Seek(microseconds_to_seek) assert core.playback.get_state().get() == PLAYING after_seek = core.playback.get_time_position().get() assert after_seek >= before_seek + milliseconds_to_seek assert after_seek < before_seek assert after_seek >= 0 def test_seek_skips_to_next_track_if_new_position_gt_track_length( core: CoreProxy, player: Player ): core.tracklist.add([Track(uri="dummy:a", length=40000), Track(uri="dummy:b")]) core.playback.play().get() core.playback.seek(20000).get() before_seek = core.playback.get_time_position().get() assert before_seek >= 20000 assert core.playback.get_state().get() == PLAYING assert core.playback.get_current_track().get().uri == "dummy:a" milliseconds_to_seek = 50000 microseconds_to_seek = milliseconds_to_seek * 1000 player.Seek(microseconds_to_seek) assert core.playback.get_state().get() == PLAYING assert core.playback.get_current_track().get().uri == "dummy:b" after_seek = core.playback.get_time_position().get() assert after_seek >= 0 assert after_seek < before_seek def test_set_position_is_ignored_if_can_seek_is_false(core: CoreProxy, player: Player): player.get_CanSeek = lambda *_: False core.tracklist.add([Track(uri="dummy:a", length=40000)]) core.playback.play().get() before_set_position = core.playback.get_time_position().get() assert before_set_position <= 5000 track_id = "a" position_to_set_in_millisec = 20000 position_to_set_in_microsec = position_to_set_in_millisec * 1000 player.SetPosition(track_id, position_to_set_in_microsec) after_set_position = core.playback.get_time_position().get() assert before_set_position <= after_set_position assert after_set_position < position_to_set_in_millisec def test_set_position_sets_the_current_track_position_in_microsecs( core: CoreProxy, player: Player ): core.tracklist.add([Track(uri="dummy:a", length=40000)]) core.playback.play().get() before_set_position = core.playback.get_time_position().get() assert before_set_position <= 5000 assert core.playback.get_state().get() == PLAYING track_id = "/com/mopidy/track/1" position_to_set_in_millisec = 20000 position_to_set_in_microsec = position_to_set_in_millisec * 1000 player.SetPosition(track_id, position_to_set_in_microsec) assert core.playback.get_state().get() == PLAYING after_set_position = core.playback.get_time_position().get() assert after_set_position >= position_to_set_in_millisec def test_set_position_does_nothing_if_the_position_is_negative( core: CoreProxy, player: Player ): core.tracklist.add([Track(uri="dummy:a", length=40000)]) core.playback.play().get() core.playback.seek(20000) before_set_position = core.playback.get_time_position().get() assert before_set_position >= 20000 assert before_set_position <= 25000 assert core.playback.get_state().get() == PLAYING assert core.playback.get_current_track().get().uri == "dummy:a" track_id = "/com/mopidy/track/1" position_to_set_in_millisec = -1000 position_to_set_in_microsec = position_to_set_in_millisec * 1000 player.SetPosition(track_id, position_to_set_in_microsec) after_set_position = core.playback.get_time_position().get() assert after_set_position >= before_set_position assert core.playback.get_state().get() == PLAYING assert core.playback.get_current_track().get().uri == "dummy:a" def test_set_position_does_nothing_if_position_is_gt_track_length( core: CoreProxy, player: Player ): core.tracklist.add([Track(uri="dummy:a", length=40000)]) core.playback.play().get() core.playback.seek(20000) before_set_position = core.playback.get_time_position().get() assert before_set_position >= 20000 assert before_set_position <= 25000 assert core.playback.get_state().get() == PLAYING assert core.playback.get_current_track().get().uri == "dummy:a" track_id = "a" position_to_set_in_millisec = 50000 position_to_set_in_microsec = position_to_set_in_millisec * 1000 player.SetPosition(track_id, position_to_set_in_microsec) after_set_position = core.playback.get_time_position().get() assert after_set_position >= before_set_position assert core.playback.get_state().get() == PLAYING assert core.playback.get_current_track().get().uri == "dummy:a" def test_set_position_is_noop_if_track_id_isnt_current_track( core: CoreProxy, player: Player ): core.tracklist.add([Track(uri="dummy:a", length=40000)]) core.playback.play().get() core.playback.seek(20000) before_set_position = core.playback.get_time_position().get() assert before_set_position >= 20000 assert before_set_position <= 25000 assert core.playback.get_state().get() == PLAYING assert core.playback.get_current_track().get().uri == "dummy:a" track_id = "b" position_to_set_in_millisec = 0 position_to_set_in_microsec = position_to_set_in_millisec * 1000 player.SetPosition(track_id, position_to_set_in_microsec) after_set_position = core.playback.get_time_position().get() assert after_set_position >= before_set_position assert core.playback.get_state().get() == PLAYING assert core.playback.get_current_track().get().uri == "dummy:a" def test_open_uri_is_ignored_if_can_control_is_false( backend, core: CoreProxy, player: Player ): player._CanControl = False backend.library.dummy_library = [Track(uri="dummy:/test/uri")] player.OpenUri("dummy:/test/uri") assert core.tracklist.get_length().get() == 0 def test_open_uri_ignores_uris_with_unknown_uri_scheme( backend, core: CoreProxy, player: Player ): assert core.get_uri_schemes().get() == ["dummy"] backend.library.dummy_library = [Track(uri="notdummy:/test/uri")] player.OpenUri("notdummy:/test/uri") assert core.tracklist.get_length().get() == 0 def test_open_uri_adds_uri_to_tracklist(backend, core: CoreProxy, player: Player): backend.library.dummy_library = [Track(uri="dummy:/test/uri")] player.OpenUri("dummy:/test/uri") assert core.tracklist.get_length().get() == 1 assert core.tracklist.get_tracks().get()[0].uri == "dummy:/test/uri" def test_open_uri_starts_playback_of_new_track_if_stopped( backend, core: CoreProxy, player: Player ): backend.library.dummy_library = [Track(uri="dummy:/test/uri")] core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) assert core.playback.get_state().get() == STOPPED player.OpenUri("dummy:/test/uri") assert core.playback.get_state().get() == PLAYING assert core.playback.get_current_track().get().uri == "dummy:/test/uri" def test_open_uri_starts_playback_of_new_track_if_paused( backend, core: CoreProxy, player: Player ): backend.library.dummy_library = [Track(uri="dummy:/test/uri")] core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) core.playback.play().get() core.playback.pause().get() assert core.playback.get_state().get() == PAUSED assert core.playback.get_current_track().get().uri == "dummy:a" player.OpenUri("dummy:/test/uri") assert core.playback.get_state().get() == PLAYING assert core.playback.get_current_track().get().uri == "dummy:/test/uri" def test_open_uri_starts_playback_of_new_track_if_playing( backend, core: CoreProxy, player: Player ): backend.library.dummy_library = [Track(uri="dummy:/test/uri")] core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) core.playback.play().get() assert core.playback.get_state().get() == PLAYING assert core.playback.get_current_track().get().uri == "dummy:a" player.OpenUri("dummy:/test/uri") assert core.playback.get_state().get() == PLAYING assert core.playback.get_current_track().get().uri == "dummy:/test/uri" ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1777126572.0 mopidy_mpris-4.0.0/tests/test_playlists.py0000644000175100017510000001040115173146254020477 0ustar00runnerrunnerfrom __future__ import annotations from typing import TYPE_CHECKING import pytest from mopidy.models import Playlist, Track from mopidy.types import PlaybackState from mopidy_mpris.playlists import Playlists if TYPE_CHECKING: from mopidy.config import Config from mopidy.core import CoreProxy @pytest.fixture def dummy_playlists(core: CoreProxy) -> dict[str, Playlist]: result = {} for name, lm in [("foo", 3000000), ("bar", 2000000), ("baz", 1000000)]: pl = core.playlists.create(name).get() assert pl pl = pl.replace(last_modified=lm) result[name] = core.playlists.save(pl).get() return result @pytest.fixture def playlists( config: Config, core: CoreProxy, dummy_playlists: dict[str, Playlist], ) -> Playlists: return Playlists(config, core) def test_activate_playlist_appends_tracks_to_tracklist( core: CoreProxy, playlists: Playlists, dummy_playlists: dict[str, Playlist], ): core.tracklist.add([Track(uri="dummy:old-a"), Track(uri="dummy:old-b")]) assert core.tracklist.get_length().get() == 2 pl = dummy_playlists["baz"] pl = pl.replace( tracks=[ Track(uri="dummy:baz-a"), Track(uri="dummy:baz-b"), Track(uri="dummy:baz-c"), ] ) pl = core.playlists.save(pl).get() playlist_id = playlists.GetPlaylists(0, 100, "User", False)[2][0] playlists.ActivatePlaylist(playlist_id) assert core.tracklist.get_length().get() == 5 assert core.playback.get_state().get() == PlaybackState.PLAYING assert core.playback.get_current_track().get() == pl.tracks[0] def test_activate_empty_playlist_is_harmless(core: CoreProxy, playlists: Playlists): assert core.tracklist.get_length().get() == 0 playlist_id = playlists.GetPlaylists(0, 100, "User", False)[2][0] playlists.ActivatePlaylist(playlist_id) assert core.tracklist.get_length().get() == 0 assert core.playback.get_state().get() == PlaybackState.STOPPED assert core.playback.get_current_track().get() is None def test_get_playlists_in_alphabetical_order(playlists: Playlists): result = playlists.GetPlaylists(0, 100, "Alphabetical", False) assert result == [ ("/com/mopidy/playlist/MR2W23LZHJRGC4Q_", "bar", ""), ("/com/mopidy/playlist/MR2W23LZHJRGC6Q_", "baz", ""), ("/com/mopidy/playlist/MR2W23LZHJTG63Y_", "foo", ""), ] def test_get_playlists_in_reverse_alphabetical_order(playlists: Playlists): result = playlists.GetPlaylists(0, 100, "Alphabetical", True) assert len(result) == 3 assert result[0][1] == "foo" assert result[1][1] == "baz" assert result[2][1] == "bar" def test_get_playlists_in_user_order(playlists: Playlists): result = playlists.GetPlaylists(0, 100, "User", False) assert len(result) == 3 assert result[0][1] == "foo" assert result[1][1] == "bar" assert result[2][1] == "baz" def test_get_playlists_in_reverse_user_order(playlists: Playlists): result = playlists.GetPlaylists(0, 100, "User", True) assert len(result) == 3 assert result[0][1] == "baz" assert result[1][1] == "bar" assert result[2][1] == "foo" def test_get_playlists_slice_on_start_of_list(playlists: Playlists): result = playlists.GetPlaylists(0, 2, "User", False) assert len(result) == 2 assert result[0][1] == "foo" assert result[1][1] == "bar" def test_get_playlists_slice_later_in_list(playlists: Playlists): result = playlists.GetPlaylists(2, 2, "User", False) assert len(result) == 1 assert result[0][1] == "baz" def test_get_playlist_count_returns_number_of_playlists(playlists: Playlists): assert playlists.PlaylistCount == 3 def test_get_orderings_includes_alpha_modified_and_user(playlists: Playlists): result = playlists.Orderings assert "Alphabetical" in result assert "Created" not in result assert "Modified" not in result assert "Played" not in result assert "User" in result def test_get_active_playlist_does_not_return_a_playlist(playlists: Playlists): result = playlists.ActivePlaylist valid, playlist = result playlist_id, playlist_name, playlist_icon_uri = playlist assert valid is False assert playlist_id == "/" assert playlist_name == "None" assert playlist_icon_uri == "" ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1777126572.0 mopidy_mpris-4.0.0/tests/test_root.py0000644000175100017510000000306415173146254017445 0ustar00runnerrunnerfrom __future__ import annotations from typing import TYPE_CHECKING import pytest from mopidy_mpris.root import Root if TYPE_CHECKING: from mopidy.config import Config from mopidy.core import CoreProxy @pytest.fixture def root(config: Config, core: CoreProxy) -> Root: return Root(config, core) def test_fullscreen_returns_false(root: Root): assert root.Fullscreen is False def test_setting_fullscreen_fails(root: Root): root.Fullscreen = True assert root.Fullscreen is False def test_can_set_fullscreen_returns_false(root: Root): assert root.CanSetFullscreen is False def test_can_raise_returns_false(root: Root): assert root.CanRaise is False def test_raise_does_nothing(root: Root): root.Raise() def test_can_quit_returns_false(root: Root): assert root.CanQuit is False def test_quit_does_nothing(root: Root): root.Quit() def test_has_track_list_returns_false(root: Root): assert root.HasTrackList is False def test_identify_is_mopidy(root: Root): assert root.Identity == "Mopidy" def test_desktop_entry_is_blank(root, config): assert root.DesktopEntry == "" def test_supported_uri_schemes_includes_backend_uri_schemes(root: Root): assert root.SupportedUriSchemes == ["dummy"] def test_supported_mime_types_has_hardcoded_entries(root: Root): assert root.SupportedMimeTypes == [ "audio/mpeg", "audio/x-ms-wma", "audio/x-ms-asf", "audio/x-flac", "audio/flac", "audio/l16;channels=2;rate=44100", "audio/l16;rate=44100;channels=2", ]