././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1793654069.685637 mopidy_mpd-4.1.0/0000755000175100017510000000000015272176466013343 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1793654065.961916 mopidy_mpd-4.1.0/.copier-answers.yml0000644000175100017510000000041215272176462017076 0ustar00runnerrunner_commit: v2.5.0 _src_path: gh:mopidy/mopidy-ext-template author_email: stein.magnus@jodal.no author_full_name: Stein Magnus Jodal dist_name: mopidy-mpd ext_name: mpd github_username: mopidy short_description: Mopidy extension for controlling Mopidy from MPD clients ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1793654069.6753712 mopidy_mpd-4.1.0/.github/0000755000175100017510000000000015272176466014703 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1793654069.6773303 mopidy_mpd-4.1.0/.github/workflows/0000755000175100017510000000000015272176466016740 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1793654065.9629693 mopidy_mpd-4.1.0/.github/workflows/ci.yml0000644000175100017510000000473115272176462020057 0ustar00runnerrunnername: CI on: pull_request: push: branches: - main workflow_dispatch: # No permissions by default; each job requests what it needs. permissions: {} concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: build: name: Build runs-on: ubuntu-24.04 permissions: contents: read # Check out the repository. steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - uses: hynek/build-and-inspect-python-package@2abe76da66d0a6a4a227101f9348ee855797cfa5 # v3.0.1 main: strategy: fail-fast: false matrix: include: - name: "pytest (3.13-lowest)" python: "3.13" tox: "3.13-lowest" - name: "pytest (3.13)" python: "3.13" tox: "3.13" - name: "pytest (3.14)" python: "3.14" tox: "3.14" - name: "pytest (3.15)" python: "3.15" tox: "3.15" coverage: true - name: "pyright" python: "3.15" tox: "pyright" - name: "ruff check" python: "3.15" tox: "ruff-check" - name: "ruff format" python: "3.15" tox: "ruff-format" name: ${{ matrix.name }} runs-on: ubuntu-24.04 # Deliberately unpinned: this is our own image, and we always want the # latest version of it. container: ghcr.io/mopidy/ci:latest # zizmor: ignore[unpinned-images] permissions: contents: read # Check out the repository. env: PYTHON: ${{ matrix.python }} TOX_ENV: ${{ matrix.tox }} UV_PYTHON_PREFERENCE: only-managed steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 with: enable-cache: true - run: uv python install "$PYTHON" - run: uvx --python "$PYTHON" --with tox-uv tox -e "$TOX_ENV" if: ${{ ! matrix.coverage }} - run: uvx --python "$PYTHON" --with tox-uv tox -e "$TOX_ENV" -- --cov-report=xml if: ${{ matrix.coverage }} - uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1 if: ${{ matrix.coverage }} with: token: ${{ secrets.CODECOV_TOKEN }} ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1793654065.9630392 mopidy_mpd-4.1.0/.github/workflows/release.yml0000644000175100017510000000167215272176462021105 0ustar00runnerrunnername: Release on: release: types: [published] # No permissions by default; each job requests what it needs. permissions: {} jobs: release: name: Release runs-on: ubuntu-24.04 environment: name: pypi url: https://pypi.org/project/mopidy-mpd/ permissions: contents: read # Check out the repository. id-token: write # Authenticate with PyPI using trusted publishing. steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - uses: hynek/build-and-inspect-python-package@2abe76da66d0a6a4a227101f9348ee855797cfa5 # v3.0.1 id: build - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: ${{ steps.build.outputs.artifact-name }} path: dist - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1793654065.9630392 mopidy_mpd-4.1.0/.gitignore0000644000175100017510000000012515272176462015325 0ustar00runnerrunner*.egg-info/ /*.lock /.*_cache/ /.coverage /.tox/ /.venv/ /build/ /dist/ __pycache__/ ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1793654065.9630392 mopidy_mpd-4.1.0/LICENSE0000644000175100017510000002613615272176462014354 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. ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1793654069.6856067 mopidy_mpd-4.1.0/PKG-INFO0000644000175100017510000001735215272176466014450 0ustar00runnerrunnerMetadata-Version: 2.4 Name: mopidy-mpd Version: 4.1.0 Summary: Mopidy extension for controlling Mopidy from MPD clients Author-email: Stein Magnus Jodal License: Apache-2.0 Project-URL: Homepage, https://github.com/mopidy/mopidy-mpd 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.2 Requires-Dist: pygobject>=3.50 Requires-Dist: pykka>=4.1 Requires-Dist: rich>=13.9 Dynamic: license-file # mopidy-mpd [![Latest PyPI version](https://img.shields.io/pypi/v/mopidy-mpd)](https://pypi.org/p/mopidy-mpd) [![CI build status](https://img.shields.io/github/actions/workflow/status/mopidy/mopidy-mpd/ci.yml)](https://github.com/mopidy/mopidy-mpd/actions/workflows/ci.yml) [![Test coverage](https://img.shields.io/codecov/c/gh/mopidy/mopidy-mpd)](https://codecov.io/gh/mopidy/mopidy-mpd) [Mopidy](https://mopidy.com/) extension for controlling Mopidy from MPD clients. MPD stands for Music Player Daemon, which is also the name of the [original MPD server project](https://www.musicpd.org/). Mopidy does not depend on the original MPD server, but implements the MPD protocol itself, and is thus compatible with most clients built for the original MPD server. ## Maintainer wanted Mopidy-MPD 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-MPD, 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-mpd ``` See https://mopidy.com/ext/mpd/ for alternative installation methods. ## Configuration Before starting Mopidy, you must add configuration for mopidy-mpd to your Mopidy configuration file: ```ini [mpd] hostname = :: ``` > [!WARNING] > As a simple security measure, the MPD server is by default only available from > localhost. To make it available from other computers, change the > `mpd/hostname` config value. > > Before you do so, note that the MPD server does not support any form of > encryption and only a single clear text password (see `mpd/password`) for weak > authentication. Anyone able to access the MPD server can control music > playback on your computer. Thus, you probably only want to make the MPD server > available from your local network. You have been warned. The following configuration values are available: - `mpd/enabled`: If the MPD extension should be enabled or not. - `mpd/hostname`: Which address the MPD server should bind to. This can be a network address or the path toa Unix socket: - `127.0.0.1`: Listens only on the IPv4 loopback interface (default). - `::1`: Listens only on the IPv6 loopback interface. - `0.0.0.0`: Listens on all IPv4 interfaces. - `::`: Listens on all interfaces, both IPv4 and IPv6. - `unix:/path/to/unix/socket.sock`: Listen on the Unix socket at the specified path. Must be prefixed with `unix:`. - `mpd/port`: Which TCP port the MPD server should listen to. Default: 6600. - `mpd/password`: The password required for connecting to the MPD server. If blank, no password is required. Default: blank. - `mpd/max_connections`: The maximum number of concurrent connections the MPD server will accept. Default: 20. - `mpd/connection_timeout`: Number of seconds an MPD client can stay inactive before the connection is closed by the server. Default: 60. - `mpd/zeroconf`: Name of the MPD service when published through Zeroconf. The variables `$hostname` and `$port` can be used in the name. Set to an empty string to disable Zeroconf for MPD. Default: `Mopidy MPD server on $hostname` - `mpd/command_blacklist`: List of MPD commands which are disabled by the server. By default this blacklists `listall` and `listallinfo`. These commands don't fit well with many of Mopidy's backends and are better left disabled unless you know what you are doing. - `mpd/default_playlist_scheme`: The URI scheme used if the server cannot find a backend appropriate for creating a playlist from the given tracks. Default: `m3u` ## Limitations This is a non-exhaustive list of MPD features that Mopidy doesn't support. - Only a single password is supported. It gives all-or-nothing access. - Toggling of audio outputs is not supported. - Channels for client-to-client communication are not supported. - Stickers are not supported. - Crossfade is not supported. - Replay gain is not supported. - `stats` does not provide any statistics. - `decoders` does not provide information about available decoders. - Live update of the music database is not supported. ## Clients Over the years, a huge number of MPD clients have been built for every thinkable platform. As always, the quality and state of maintenance varies between clients, so you might have to try a couple before you find one you like for your purpose. In general, they should all work with Mopidy-MPD. The [Wikipedia article on MPD](https://en.wikipedia.org/wiki/Music_Player_Daemon#Clients) has a short list of well-known clients. In the MPD wiki there is a [more complete list](https://mpd.fandom.com/wiki/Clients) of the available MPD clients. Both lists are grouped by user interface, e.g. terminal, graphical, or web-based ## Project resources - [Source code](https://github.com/mopidy/mopidy-mpd) - [Issues](https://github.com/mopidy/mopidy-mpd/issues) - [Releases](https://github.com/mopidy/mopidy-mpd/releases) ## Development ### Set up development environment Clone the repo using, e.g. using [gh](https://cli.github.com/): ```sh gh repo clone mopidy/mopidy-mpd ``` Enter the directory, and install dependencies using [uv](https://docs.astral.sh/uv/): ```sh cd mopidy-mpd/ 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 . ``` ### Making a release To make a release to PyPI, go to the project's [GitHub releases page](https://github.com/mopidy/mopidy-mpd/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-mpd/). ## Credits - Original author: [Stein Magnus Jodal](https://github.com/jodal) and [Thomas Adamcik](https://github.com/adamcik) for the Mopidy-MPD extension in Mopidy core. - Current maintainer: None. Maintainer wanted, see section above. - [Contributors](https://github.com/mopidy/mopidy-mpd/graphs/contributors) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1793654065.9630392 mopidy_mpd-4.1.0/README.md0000644000175100017510000001577515272176462014635 0ustar00runnerrunner# mopidy-mpd [![Latest PyPI version](https://img.shields.io/pypi/v/mopidy-mpd)](https://pypi.org/p/mopidy-mpd) [![CI build status](https://img.shields.io/github/actions/workflow/status/mopidy/mopidy-mpd/ci.yml)](https://github.com/mopidy/mopidy-mpd/actions/workflows/ci.yml) [![Test coverage](https://img.shields.io/codecov/c/gh/mopidy/mopidy-mpd)](https://codecov.io/gh/mopidy/mopidy-mpd) [Mopidy](https://mopidy.com/) extension for controlling Mopidy from MPD clients. MPD stands for Music Player Daemon, which is also the name of the [original MPD server project](https://www.musicpd.org/). Mopidy does not depend on the original MPD server, but implements the MPD protocol itself, and is thus compatible with most clients built for the original MPD server. ## Maintainer wanted Mopidy-MPD 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-MPD, 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-mpd ``` See https://mopidy.com/ext/mpd/ for alternative installation methods. ## Configuration Before starting Mopidy, you must add configuration for mopidy-mpd to your Mopidy configuration file: ```ini [mpd] hostname = :: ``` > [!WARNING] > As a simple security measure, the MPD server is by default only available from > localhost. To make it available from other computers, change the > `mpd/hostname` config value. > > Before you do so, note that the MPD server does not support any form of > encryption and only a single clear text password (see `mpd/password`) for weak > authentication. Anyone able to access the MPD server can control music > playback on your computer. Thus, you probably only want to make the MPD server > available from your local network. You have been warned. The following configuration values are available: - `mpd/enabled`: If the MPD extension should be enabled or not. - `mpd/hostname`: Which address the MPD server should bind to. This can be a network address or the path toa Unix socket: - `127.0.0.1`: Listens only on the IPv4 loopback interface (default). - `::1`: Listens only on the IPv6 loopback interface. - `0.0.0.0`: Listens on all IPv4 interfaces. - `::`: Listens on all interfaces, both IPv4 and IPv6. - `unix:/path/to/unix/socket.sock`: Listen on the Unix socket at the specified path. Must be prefixed with `unix:`. - `mpd/port`: Which TCP port the MPD server should listen to. Default: 6600. - `mpd/password`: The password required for connecting to the MPD server. If blank, no password is required. Default: blank. - `mpd/max_connections`: The maximum number of concurrent connections the MPD server will accept. Default: 20. - `mpd/connection_timeout`: Number of seconds an MPD client can stay inactive before the connection is closed by the server. Default: 60. - `mpd/zeroconf`: Name of the MPD service when published through Zeroconf. The variables `$hostname` and `$port` can be used in the name. Set to an empty string to disable Zeroconf for MPD. Default: `Mopidy MPD server on $hostname` - `mpd/command_blacklist`: List of MPD commands which are disabled by the server. By default this blacklists `listall` and `listallinfo`. These commands don't fit well with many of Mopidy's backends and are better left disabled unless you know what you are doing. - `mpd/default_playlist_scheme`: The URI scheme used if the server cannot find a backend appropriate for creating a playlist from the given tracks. Default: `m3u` ## Limitations This is a non-exhaustive list of MPD features that Mopidy doesn't support. - Only a single password is supported. It gives all-or-nothing access. - Toggling of audio outputs is not supported. - Channels for client-to-client communication are not supported. - Stickers are not supported. - Crossfade is not supported. - Replay gain is not supported. - `stats` does not provide any statistics. - `decoders` does not provide information about available decoders. - Live update of the music database is not supported. ## Clients Over the years, a huge number of MPD clients have been built for every thinkable platform. As always, the quality and state of maintenance varies between clients, so you might have to try a couple before you find one you like for your purpose. In general, they should all work with Mopidy-MPD. The [Wikipedia article on MPD](https://en.wikipedia.org/wiki/Music_Player_Daemon#Clients) has a short list of well-known clients. In the MPD wiki there is a [more complete list](https://mpd.fandom.com/wiki/Clients) of the available MPD clients. Both lists are grouped by user interface, e.g. terminal, graphical, or web-based ## Project resources - [Source code](https://github.com/mopidy/mopidy-mpd) - [Issues](https://github.com/mopidy/mopidy-mpd/issues) - [Releases](https://github.com/mopidy/mopidy-mpd/releases) ## Development ### Set up development environment Clone the repo using, e.g. using [gh](https://cli.github.com/): ```sh gh repo clone mopidy/mopidy-mpd ``` Enter the directory, and install dependencies using [uv](https://docs.astral.sh/uv/): ```sh cd mopidy-mpd/ 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 . ``` ### Making a release To make a release to PyPI, go to the project's [GitHub releases page](https://github.com/mopidy/mopidy-mpd/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-mpd/). ## Credits - Original author: [Stein Magnus Jodal](https://github.com/jodal) and [Thomas Adamcik](https://github.com/adamcik) for the Mopidy-MPD extension in Mopidy core. - Current maintainer: None. Maintainer wanted, see section above. - [Contributors](https://github.com/mopidy/mopidy-mpd/graphs/contributors) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1793654065.9630392 mopidy_mpd-4.1.0/pyproject.toml0000644000175100017510000000772315272176462016264 0ustar00runnerrunner[project] name = "mopidy-mpd" description = "Mopidy extension for controlling Mopidy from MPD clients" 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.2", "pygobject >= 3.50", "pykka >= 4.1", "rich >= 13.9", ] [project.urls] Homepage = "https://github.com/mopidy/mopidy-mpd" [project.entry-points."mopidy.ext"] mpd = "mopidy_mpd:Extension" [build-system] requires = ["setuptools >= 78", "setuptools-scm >= 8.2"] build-backend = "setuptools.build_meta" [dependency-groups] dev = [ "tox", "tox-uv", { include-group = "ruff" }, { include-group = "tests" }, { include-group = "typing" }, ] ruff = ["ruff"] tests = ["pytest >= 8.3", "pytest-cov >= 5.0"] typing = ["pyright"] [tool.coverage.paths] source = ["src/", "*/site-packages/"] [tool.coverage.run] source_pkgs = ["mopidy_mpd"] [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_mpd", # # Add any warnings you want to ignore here ] [tool.ruff] target-version = "py313" [tool.ruff.lint] select = ["ALL"] ignore = [ "A005", # stdlib-module-shadowing "ANN401", # any-type "CPY", # flake8-copyright "D", # pydocstyle "D203", # one-blank-line-before-class "D213", # multi-line-summary-second-line "EM101", # raw-string-in-exception # TODO "EM102", # f-string-in-exception # TODO "FIX001", # line-contains-fixme "FIX002", # line-contains-todo "G004", # logging-f-string "S101", # assert # TODO "TD002", # missing-todo-author "TD003", # missing-todo-link "TD004", # missing-todo-colon # TODO "TD005", # missing-todo-description # TODO "TRY003", # raise-vanilla-args # # Conflicting with `ruff format` "COM812", # missing-trailing-comma "ISC001", # single-line-implicit-string-concatenation ] [tool.ruff.lint.per-file-ignores] "src/mopidy_mpd/protocol/*" = [ "ARG001", # unused-function-argument ] "tests/*" = [ "ANN", # flake8-annotations "ARG", # flake8-unused-arguments "D", # pydocstyle "FBT", # flake8-boolean-trap "PLR0913", # too-many-arguments "PLR2004", # magic-value-comparison "PT027", # pytest-unittest-raises-assertion "S101", # assert "SLF001", # private-member-access "TRY002", # raise-vanilla-class ] [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-lowest", "3.13", "3.14", "3.15", "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."3.13-lowest"] base_python = ["python3.13"] uv_resolution = "lowest-direct" [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:.}"]] ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1793654069.685895 mopidy_mpd-4.1.0/setup.cfg0000644000175100017510000000004615272176466015164 0ustar00runnerrunner[egg_info] tag_build = tag_date = 0 ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1793654069.6753712 mopidy_mpd-4.1.0/src/0000755000175100017510000000000015272176466014132 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1793654069.678826 mopidy_mpd-4.1.0/src/mopidy_mpd/0000755000175100017510000000000015272176466016273 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1793654065.9633496 mopidy_mpd-4.1.0/src/mopidy_mpd/__init__.py0000644000175100017510000000210615272176462020377 0ustar00runnerrunnerimport pathlib from importlib.metadata import version from mopidy import config, ext __version__ = version("mopidy-mpd") class Extension(ext.Extension): dist_name = "mopidy-mpd" ext_name = "mpd" version = __version__ def get_default_config(self) -> str: return config.read(pathlib.Path(__file__).parent / "ext.conf") def get_config_schema(self) -> config.ConfigSchema: schema = super().get_config_schema() schema["hostname"] = config.Hostname() schema["port"] = config.Port(optional=True) schema["password"] = config.Secret(optional=True) schema["max_connections"] = config.Integer(minimum=1) schema["connection_timeout"] = config.Integer(minimum=1) schema["zeroconf"] = config.String(optional=True) schema["command_blacklist"] = config.List(optional=True) schema["default_playlist_scheme"] = config.String() return schema def setup(self, registry: ext.Registry) -> None: from .actor import MpdFrontend # noqa: PLC0415 registry.add("frontend", MpdFrontend) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1793654065.9634058 mopidy_mpd-4.1.0/src/mopidy_mpd/actor.py0000644000175100017510000000575215272176462017762 0ustar00runnerrunnerimport logging from typing import Any import pykka from mopidy import exceptions, listener, zeroconf from mopidy.core import CoreListener, CoreProxy from mopidy_mpd import network, session, types, uri_mapper logger = logging.getLogger(__name__) _CORE_EVENTS_TO_IDLE_SUBSYSTEMS = { "track_playback_paused": None, "track_playback_resumed": None, "track_playback_started": None, "track_playback_ended": None, "playback_state_changed": "player", "tracklist_changed": "playlist", "playlists_loaded": "stored_playlist", "playlist_changed": "stored_playlist", "playlist_deleted": "stored_playlist", "options_changed": "options", "volume_changed": "mixer", "mute_changed": "output", "seeked": "player", "stream_title_changed": "playlist", } class MpdFrontend(pykka.ThreadingActor, CoreListener): def __init__(self, config: types.Config, core: CoreProxy) -> None: super().__init__() self.hostname = network.format_hostname(config["mpd"]["hostname"]) self.port = config["mpd"]["port"] self.zeroconf_name = config["mpd"]["zeroconf"] self.zeroconf_service = None self.uri_map = uri_mapper.MpdUriMapper(core) self.server = self._setup_server(config, core) def _setup_server(self, config: types.Config, core: CoreProxy) -> network.Server: try: server = network.Server( config=config, core=core, uri_map=self.uri_map, protocol=session.MpdSession, host=self.hostname, port=self.port, max_connections=config["mpd"]["max_connections"], timeout=config["mpd"]["connection_timeout"], ) except OSError as exc: raise exceptions.FrontendError(f"MPD server startup failed: {exc}") from exc logger.info(f"MPD server running at {network.format_address(server.address)}") return server def on_start(self) -> None: if self.zeroconf_name and not network.is_unix_socket(self.server.server_socket): self.zeroconf_service = zeroconf.Zeroconf( name=self.zeroconf_name, stype="_mpd._tcp", port=self.port ) self.zeroconf_service.publish() def on_stop(self) -> None: if self.zeroconf_service: self.zeroconf_service.unpublish() session_actors = pykka.ActorRegistry.get_by_class(session.MpdSession) for session_actor in session_actors: session_actor.stop() self.server.stop() def on_event(self, event: str, **kwargs: Any) -> None: if event not in _CORE_EVENTS_TO_IDLE_SUBSYSTEMS: logger.warning("Got unexpected event: %s(%s)", event, ", ".join(kwargs)) else: self.send_idle(_CORE_EVENTS_TO_IDLE_SUBSYSTEMS[event]) def send_idle(self, subsystem: str | None) -> None: if subsystem: listener.send(session.MpdSession, subsystem) # pyright: ignore[reportArgumentType] ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1793654065.9634058 mopidy_mpd-4.1.0/src/mopidy_mpd/context.py0000644000175100017510000001015715272176462020331 0ustar00runnerrunnerfrom __future__ import annotations import logging import re from typing import ( TYPE_CHECKING, Any, Literal, overload, ) from mopidy_mpd import exceptions, types if TYPE_CHECKING: from collections.abc import Generator import pykka from mopidy.core import CoreProxy from mopidy.models import Ref, Track from mopidy.types import Uri from mopidy_mpd.dispatcher import MpdDispatcher from mopidy_mpd.session import MpdSession from mopidy_mpd.uri_mapper import MpdUriMapper logger = logging.getLogger(__name__) class MpdContext: """ This object is passed as the first argument to all MPD command handlers to give the command handlers access to important parts of Mopidy. """ #: The Mopidy config. config: types.Config #: The Mopidy core API. core: CoreProxy #: The current session instance. session: MpdSession #: The current dispatcher instance. dispatcher: MpdDispatcher #: Mapping of URIs to MPD names. uri_map: MpdUriMapper def __init__( self, config: types.Config, core: CoreProxy, uri_map: MpdUriMapper, session: MpdSession, dispatcher: MpdDispatcher, ) -> None: self.config = config self.core = core self.uri_map = uri_map self.session = session self.dispatcher = dispatcher @overload def browse( self, path: str | None, *, recursive: bool, lookup: Literal[True] ) -> Generator[tuple[str, pykka.Future[dict[Uri, list[Track]]] | None], Any]: ... @overload def browse( self, path: str | None, *, recursive: bool, lookup: Literal[False] ) -> Generator[tuple[str, Ref | None], Any]: ... def browse( # noqa: C901, PLR0912 self, path: str | None, *, recursive: bool = True, lookup: bool = True, ) -> Generator[Any, Any]: """ Browse the contents of a given directory path. Returns a sequence of two-tuples ``(path, data)``. If ``recursive`` is true, it returns results for all entries in the given path. If ``lookup`` is true and the ``path`` is to a track, the returned ``data`` is a future which will contain the results from looking up the URI with :meth:`mopidy.core.LibraryController.lookup`. If ``lookup`` is false and the ``path`` is to a track, the returned ``data`` will be a :class:`mopidy.models.Ref` for the track. For all entries that are not tracks, the returned ``data`` will be :class:`None`. """ path_parts: list[str] = re.findall(r"[^/]+", path or "") root_path: str = "/".join(["", *path_parts]) uri = self.uri_map.uri_from_name(root_path) if uri is None: for part in path_parts: for ref in self.core.library.browse(uri).get(): if ref.type != ref.TRACK and ref.name == part: uri = ref.uri break else: raise exceptions.MpdNoExistError("Not found") root_path = self.uri_map.insert(root_path, uri) if recursive: yield (root_path, None) path_and_futures = [(root_path, self.core.library.browse(uri))] while path_and_futures: base_path, future = path_and_futures.pop() for ref in future.get(): if ref.name is None or ref.uri is None: continue path = "/".join([base_path, ref.name.replace("/", "")]) path = self.uri_map.insert(path, ref.uri) if ref.type == ref.TRACK: if lookup: # TODO: can we lookup all the refs at once now? yield (path, self.core.library.lookup(uris=[ref.uri])) else: yield (path, ref) else: yield (path, None) if recursive: path_and_futures.append( (path, self.core.library.browse(ref.uri)) ) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1793654065.9634058 mopidy_mpd-4.1.0/src/mopidy_mpd/dispatcher.py0000644000175100017510000002164615272176462021000 0ustar00runnerrunnerfrom __future__ import annotations import logging from collections.abc import Callable from typing import ( TYPE_CHECKING, NewType, TypeVar, ) import pykka from rich.markup import escape from mopidy_mpd import context, exceptions, protocol, tokenize, types if TYPE_CHECKING: from mopidy.core import CoreProxy from mopidy_mpd.session import MpdSession from mopidy_mpd.uri_mapper import MpdUriMapper logger = logging.getLogger(__name__) protocol.load_protocol_modules() T = TypeVar("T") type Request = str Response = NewType("Response", list[str]) type Filter = Callable[[Request, Response, list["Filter"]], Response] class MpdDispatcher: """ The MPD session feeds the MPD dispatcher with requests. The dispatcher finds the correct handler, processes the request, and sends the response back to the MPD session. """ #: The active subsystems that have pending events. subsystem_events: set[str] #: The subsystems that we want to be notified about in idle mode. subsystem_subscriptions: set[str] def __init__( self, config: types.Config, core: CoreProxy, uri_map: MpdUriMapper, session: MpdSession, ) -> None: self.config = config self.session = session self.authenticated = False self.command_list_receiving = False self.command_list_ok = False self.command_list = [] self.command_list_index = None self.subsystem_events = set() self.subsystem_subscriptions = set() self.context = context.MpdContext( config=config, core=core, uri_map=uri_map, session=session, dispatcher=self, ) def handle_request( self, request: Request, current_command_list_index: int | None = None, ) -> Response: """Dispatch incoming requests to the correct handler.""" self.command_list_index = current_command_list_index response: Response = Response([]) filter_chain: list[Filter] = [ self._catch_mpd_ack_errors_filter, self._authenticate_filter, self._command_list_filter, self._idle_filter, self._add_ok_filter, self._call_handler_filter, ] return self._call_next_filter(request, response, filter_chain) def handle_idle(self, subsystem: str) -> None: # TODO: validate against mopidy_mpd.protocol.status.SUBSYSTEMS self.subsystem_events.add(subsystem) subsystems = self.subsystem_subscriptions.intersection(self.subsystem_events) if not subsystems: return response = [*[f"changed: {s}" for s in subsystems], "OK"] self.subsystem_events = set() self.subsystem_subscriptions = set() self.session.send_lines(response) def _call_next_filter( self, request: Request, response: Response, filter_chain: list[Filter] ) -> Response: if filter_chain: next_filter = filter_chain.pop(0) return next_filter(request, response, filter_chain) return response # --- Filter: catch MPD ACK errors def _catch_mpd_ack_errors_filter( self, request: Request, response: Response, filter_chain: list[Filter], ) -> Response: try: return self._call_next_filter(request, response, filter_chain) except exceptions.MpdAckError as mpd_ack_error: if self.command_list_index is not None: mpd_ack_error.index = self.command_list_index return Response([mpd_ack_error.get_mpd_ack()]) # --- Filter: authenticate def _authenticate_filter( self, request: Request, response: Response, filter_chain: list[Filter], ) -> Response: if self.authenticated: return self._call_next_filter(request, response, filter_chain) if self.config["mpd"]["password"] is None: self.authenticated = True return self._call_next_filter(request, response, filter_chain) command_name = request.split(" ")[0] command = protocol.commands.handlers.get(command_name) if command and not command.auth_required: return self._call_next_filter(request, response, filter_chain) raise exceptions.MpdPermissionError(command=command_name) # --- Filter: command list def _command_list_filter( self, request: Request, response: Response, filter_chain: list[Filter], ) -> Response: if self._is_receiving_command_list(request): self.command_list.append(request) return Response([]) response = self._call_next_filter(request, response, filter_chain) if ( ( self._is_receiving_command_list(request) or self._is_processing_command_list(request) ) and response and response[-1] == "OK" ): response = Response(response[:-1]) return response def _is_receiving_command_list(self, request: Request) -> bool: return self.command_list_receiving and request != "command_list_end" def _is_processing_command_list(self, request: Request) -> bool: return self.command_list_index is not None and request != "command_list_end" # --- Filter: idle def _idle_filter( self, request: Request, response: Response, filter_chain: list[Filter], ) -> Response: if self._is_currently_idle() and request != "noidle": logger.debug( "Client sent us %s, only %s is allowed while in the idle state", escape(repr(request)), repr("noidle"), ) self.session.close() return Response([]) if not self._is_currently_idle() and request == "noidle": return Response([]) # noidle was called before idle response = self._call_next_filter(request, response, filter_chain) if self._is_currently_idle(): return Response([]) return response def _is_currently_idle(self) -> bool: return bool(self.subsystem_subscriptions) # --- Filter: add OK def _add_ok_filter( self, request: Request, response: Response, filter_chain: list[Filter], ) -> Response: response = self._call_next_filter(request, response, filter_chain) if not self._has_error(response): response.append("OK") return response def _has_error(self, response: Response) -> bool: return bool(response) and response[-1].startswith("ACK") # --- Filter: call handler def _call_handler_filter( self, request: Request, response: Response, filter_chain: list[Filter], ) -> Response: try: result = self._call_handler(request) response = _format_response(result) return self._call_next_filter(request, response, filter_chain) except pykka.ActorDeadError as exc: logger.warning("Tried to communicate with dead actor.") raise exceptions.MpdSystemError(str(exc)) from exc def _call_handler(self, request: Request) -> protocol.Result: tokens = tokenize.split(request) # TODO: check that blacklist items are valid commands? blacklist = self.config["mpd"]["command_blacklist"] if tokens and tokens[0] in blacklist: logger.warning("MPD client used blacklisted command: %s", tokens[0]) raise exceptions.MpdDisabledError(command=tokens[0]) try: return protocol.commands.call( context=self.context, tokens=tokens, ) except exceptions.MpdAckError as exc: if exc.command is None: exc.command = tokens[0] raise def _format_response(result: protocol.Result) -> Response: response = Response([]) for element in _listify_result(result): response.extend(_format_lines(element)) return response def _listify_result(result: protocol.Result) -> protocol.ResultList: match result: case None: return [] case list(): return _flatten(result) case _: return [result] def _flatten(lst: protocol.ResultList) -> protocol.ResultList: result: protocol.ResultList = [] for element in lst: if isinstance(element, list): result.extend(_flatten(element)) else: result.append(element) return result def _format_lines( element: protocol.ResultDict | protocol.ResultTuple | str, ) -> Response: if isinstance(element, dict): return Response([f"{key}: {value}" for (key, value) in element.items()]) if isinstance(element, tuple): (key, value) = element return Response([f"{key}: {value}"]) return Response([element]) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1793654065.9634058 mopidy_mpd-4.1.0/src/mopidy_mpd/exceptions.py0000644000175100017510000001042315272176462021022 0ustar00runnerrunnerfrom typing import Any from mopidy.exceptions import MopidyException from mopidy.types import UriScheme class MpdAckError(MopidyException): """See fields on this class for available MPD error codes""" ACK_ERROR_NOT_LIST = 1 ACK_ERROR_ARG = 2 ACK_ERROR_PASSWORD = 3 ACK_ERROR_PERMISSION = 4 ACK_ERROR_UNKNOWN = 5 ACK_ERROR_NO_EXIST = 50 ACK_ERROR_PLAYLIST_MAX = 51 ACK_ERROR_SYSTEM = 52 ACK_ERROR_PLAYLIST_LOAD = 53 ACK_ERROR_UPDATE_ALREADY = 54 ACK_ERROR_PLAYER_SYNC = 55 ACK_ERROR_EXIST = 56 error_code = 0 def __init__( self, message: str = "", index: int = 0, command: str | None = None, ) -> None: super().__init__(message, index, command) self.message = message self.index = index self.command = command def get_mpd_ack(self) -> str: """ MPD error code format:: ACK [%(error_code)i@%(index)i] {%(command)s} description """ return ( f"ACK [{self.__class__.error_code:d}@{self.index:d}] " f"{{{self.command}}} {self.message}" ) class MpdArgError(MpdAckError): error_code = MpdAckError.ACK_ERROR_ARG class MpdPasswordError(MpdAckError): error_code = MpdAckError.ACK_ERROR_PASSWORD class MpdPermissionError(MpdAckError): error_code = MpdAckError.ACK_ERROR_PERMISSION def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) assert self.command is not None, "command must be given explicitly" self.message = f'you don\'t have permission for "{self.command}"' class MpdUnknownError(MpdAckError): error_code = MpdAckError.ACK_ERROR_UNKNOWN class MpdUnknownCommandError(MpdUnknownError): def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) assert self.command is not None, "command must be given explicitly" self.message = f'unknown command "{self.command}"' self.command = "" class MpdNoCommandError(MpdUnknownCommandError): def __init__(self, *args: Any, **kwargs: Any) -> None: kwargs["command"] = "" super().__init__(*args, **kwargs) self.message = "No command given" class MpdNoExistError(MpdAckError): error_code = MpdAckError.ACK_ERROR_NO_EXIST class MpdExistError(MpdAckError): error_code = MpdAckError.ACK_ERROR_EXIST class MpdSystemError(MpdAckError): error_code = MpdAckError.ACK_ERROR_SYSTEM class MpdInvalidPlaylistNameError(MpdAckError): error_code = MpdAckError.ACK_ERROR_ARG def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) self.message = ( "playlist name is invalid: playlist names may not " "contain slashes, newlines or carriage returns" ) class MpdNotImplementedError(MpdAckError): error_code = 0 def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) self.message = "Not implemented" class MpdInvalidTrackForPlaylistError(MpdAckError): # NOTE: This is a custom error for Mopidy that does not exist in MPD. error_code = 0 def __init__( self, playlist_scheme: UriScheme, track_scheme: UriScheme, *args: Any, **kwargs: Any, ) -> None: super().__init__(*args, **kwargs) self.message = ( f'Playlist with scheme "{playlist_scheme}" ' f'can\'t store track scheme "{track_scheme}"' ) class MpdFailedToSavePlaylistError(MpdAckError): # NOTE: This is a custom error for Mopidy that does not exist in MPD. error_code = 0 def __init__( self, backend_scheme: UriScheme, *args: Any, **kwargs: Any, ) -> None: super().__init__(*args, **kwargs) self.message = f'Backend with scheme "{backend_scheme}" failed to save playlist' class MpdDisabledError(MpdAckError): # NOTE: This is a custom error for Mopidy that does not exist in MPD. error_code = 0 def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) assert self.command is not None, "command must be given explicitly" self.message = f'"{self.command}" has been disabled in the server' ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1793654065.9634058 mopidy_mpd-4.1.0/src/mopidy_mpd/ext.conf0000644000175100017510000000033615272176462017740 0ustar00runnerrunner[mpd] enabled = true hostname = 127.0.0.1 port = 6600 password = max_connections = 20 connection_timeout = 60 zeroconf = Mopidy MPD server on $hostname command_blacklist = listall,listallinfo default_playlist_scheme = m3u ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1793654065.9634058 mopidy_mpd-4.1.0/src/mopidy_mpd/formatting.py0000644000175100017510000000062115272176462021012 0ustar00runnerrunnerdef indent( value: str, *, places: int = 4, linebreak: str = "\n", singles: bool = False, ) -> str: lines = value.split(linebreak) if not singles and len(lines) == 1: return value for i, line in enumerate(lines): lines[i] = " " * places + line result = linebreak.join(lines) if not singles: result = linebreak + result return result ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1793654065.9634058 mopidy_mpd-4.1.0/src/mopidy_mpd/network.py0000644000175100017510000004242215272176462020336 0ustar00runnerrunnerfrom __future__ import annotations import contextlib import errno import logging import os import re import socket import sys import threading from typing import TYPE_CHECKING, Any, Never import pykka from gi.repository import ( GLib, # pyright: ignore[reportAttributeAccessIssue, reportMissingModuleSource] ) from rich.markup import escape logger = logging.getLogger(__name__) if TYPE_CHECKING: from collections.abc import Generator from types import TracebackType from mopidy.core import CoreProxy from mopidy_mpd import types from mopidy_mpd.session import MpdSession, MpdSessionKwargs from mopidy_mpd.types import SocketAddress from mopidy_mpd.uri_mapper import MpdUriMapper CONTROL_CHARS = dict.fromkeys(range(32)) def get_systemd_socket() -> socket.socket | None: """Attempt to get a socket from systemd.""" fdnames = os.environ.get("LISTEN_FDNAMES", "").split(":") if "mpd" not in fdnames: return None fd = fdnames.index("mpd") + 3 # 3 is the first systemd file handle return socket.socket(fileno=fd) def get_unix_socket_path(socket_path: str) -> str | None: match = re.search("^unix:(.*)", socket_path) if not match: return None return match.group(1) def is_unix_socket(sock: socket.socket) -> bool: """Check if the provided socket is a Unix domain socket""" if hasattr(socket, "AF_UNIX"): return sock.family == socket.AF_UNIX return False def get_socket_address(host: str, port: int) -> tuple[str, int | None]: unix_socket_path = get_unix_socket_path(host) if unix_socket_path is not None: return (unix_socket_path, None) return (host, port) class ShouldRetrySocketCallError(Exception): """Indicate that attempted socket call should be retried""" def try_ipv6_socket() -> bool: """Determine if system really supports IPv6""" if not socket.has_ipv6: return False try: socket.socket(socket.AF_INET6).close() except OSError as exc: logger.debug( f"Platform supports IPv6, but socket creation failed, disabling: {exc}" ) return False else: return True #: Boolean value that indicates if creating an IPv6 socket will succeed. has_ipv6 = try_ipv6_socket() def create_tcp_socket() -> socket.socket: """Create a TCP socket with or without IPv6 depending on system support""" if has_ipv6: sock = socket.socket(socket.AF_INET6, socket.SOCK_STREAM) # Explicitly configure socket to work for both IPv4 and IPv6 if hasattr(socket, "IPPROTO_IPV6"): sock.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0) elif sys.platform == "win32": # also match 64bit windows. # Python 2.7 on windows does not have the IPPROTO_IPV6 constant # Use values extracted from Windows Vista/7/8's header sock.setsockopt(41, 27, 0) else: sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) return sock def create_unix_socket() -> socket.socket: """Create a Unix domain socket""" return socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) def format_address(address: SocketAddress) -> str: """Format socket address for display.""" host, port = address[:2] if port is not None: return escape(f"[{host}]:{port}") return escape(f"[{host}]") def format_hostname(hostname: str) -> str: """Format hostname for display.""" if has_ipv6 and re.match(r"\d+.\d+.\d+.\d+", hostname) is not None: hostname = f"::ffff:{hostname}" return hostname class Server: """Setup listener and register it with GLib's event loop.""" def __init__( # noqa: PLR0913 self, *, config: types.Config, core: CoreProxy, uri_map: MpdUriMapper, protocol: type[MpdSession], host: str, port: int, max_connections: int = 5, timeout: int = 30, ) -> None: self.config = config self.core = core self.uri_map = uri_map self.protocol = protocol self.max_connections = max_connections self.timeout = timeout self.server_socket = self.create_server_socket(host, port) self.address = get_socket_address(host, port) self.watcher = self.register_server_socket(self.server_socket.fileno()) def create_server_socket(self, host: str, port: int) -> socket.socket: sock = get_systemd_socket() if sock is not None: return sock socket_path = get_unix_socket_path(host) if socket_path is not None: # host is a path so use unix socket sock = create_unix_socket() sock.bind(socket_path) else: # ensure the port is supplied if not isinstance(port, int): raise TypeError(f"Expected an integer, not {port!r}") sock = create_tcp_socket() sock.bind((host, port)) sock.setblocking(False) # noqa: FBT003 sock.listen(1) return sock def stop(self) -> None: GLib.source_remove(self.watcher) if is_unix_socket(self.server_socket): unix_socket_path = self.server_socket.getsockname() else: unix_socket_path = None self.server_socket.shutdown(socket.SHUT_RDWR) self.server_socket.close() # clean up the socket file if unix_socket_path is not None: os.unlink(unix_socket_path) # noqa: PTH108 def register_server_socket(self, fileno: int) -> int: return GLib.io_add_watch(fileno, GLib.IO_IN, self.handle_connection) def handle_connection(self, _fd: int, _flags: int) -> bool: try: sock, addr = self.accept_connection() except ShouldRetrySocketCallError: return True if self.maximum_connections_exceeded(): self.reject_connection(sock, addr) else: self.init_connection(sock, addr) return True def accept_connection(self) -> tuple[socket.socket, SocketAddress]: try: sock, addr = self.server_socket.accept() if is_unix_socket(sock): addr = (sock.getsockname(), None) except OSError as exc: if exc.errno in (errno.EAGAIN, errno.EINTR): raise ShouldRetrySocketCallError from None raise else: return ( sock, addr[:2], # addr is a two-tuple for IPv4 and four-tuple for IPv6 ) def maximum_connections_exceeded(self) -> bool: return ( self.max_connections is not None and self.number_of_connections() >= self.max_connections ) def number_of_connections(self) -> int: return len(pykka.ActorRegistry.get_by_class(self.protocol)) def reject_connection(self, sock: socket.socket, addr: SocketAddress) -> None: # TODO: provide more context in logging? logger.warning("Rejected connection from %s", format_address(addr)) with contextlib.suppress(OSError): sock.close() def init_connection(self, sock: socket.socket, addr: SocketAddress) -> None: Connection( config=self.config, core=self.core, uri_map=self.uri_map, protocol=self.protocol, sock=sock, addr=addr, timeout=self.timeout, ) class Connection: # NOTE: the callback code is _not_ run in the actor's thread, but in the # same one as the event loop. If code in the callbacks blocks, the rest of # GLib code will likely be blocked as well... # # Also note that source_remove() return values are ignored on purpose, a # false return value would only tell us that what we thought was registered # is already gone, there is really nothing more we can do. host: str port: int | None def __init__( # noqa: PLR0913 self, *, config: types.Config, core: CoreProxy, uri_map: MpdUriMapper, protocol: type[MpdSession], sock: socket.socket, addr: SocketAddress, timeout: int, ) -> None: sock.setblocking(False) # noqa: FBT003 self.host, self.port = addr[:2] self._sock = sock self.protocol = protocol self.timeout = timeout self.send_lock = threading.Lock() self.send_buffer = b"" self.stopping = False self.recv_id: int | None = None self.send_id: int | None = None self.timeout_id: int | None = None protocol_kwargs: MpdSessionKwargs = { "config": config, "core": core, "uri_map": uri_map, "connection": self, } self.actor_ref = self.protocol.start(**protocol_kwargs) self.enable_recv() self.enable_timeout() def stop(self, reason: str, level: int = logging.DEBUG) -> None: if self.stopping: logger.log(level, f"Already stopping: {reason}") return self.stopping = True logger.log(level, reason) with contextlib.suppress(pykka.ActorDeadError): self.actor_ref.stop(block=False) self.disable_timeout() self.disable_recv() self.disable_send() with contextlib.suppress(OSError): self._sock.close() def queue_send(self, data: bytes) -> None: """Try to send data to client exactly as is and queue rest.""" self.send_lock.acquire(blocking=True) self.send_buffer = self.send(self.send_buffer + data) self.send_lock.release() if self.send_buffer: self.enable_send() def send(self, data: bytes) -> bytes: """Send data to client, return any unsent data.""" try: sent = self._sock.send(data) return data[sent:] except OSError as exc: if exc.errno in (errno.EWOULDBLOCK, errno.EINTR): return data self.stop(f"Unexpected client error: {exc}") return b"" def enable_timeout(self) -> None: """Reactivate timeout mechanism.""" if self.timeout <= 0: return self.disable_timeout() self.timeout_id = GLib.timeout_add_seconds(self.timeout, self.timeout_callback) def disable_timeout(self) -> None: """Deactivate timeout mechanism.""" if self.timeout_id is None: return GLib.source_remove(self.timeout_id) self.timeout_id = None def enable_recv(self) -> None: if self.recv_id is not None: return try: self.recv_id = GLib.io_add_watch( self._sock.fileno(), GLib.IO_IN | GLib.IO_ERR | GLib.IO_HUP, self.recv_callback, ) except OSError as exc: self.stop(f"Problem with connection: {exc}") def disable_recv(self) -> None: if self.recv_id is None: return GLib.source_remove(self.recv_id) self.recv_id = None def enable_send(self) -> None: if self.send_id is not None: return try: self.send_id = GLib.io_add_watch( self._sock.fileno(), GLib.IO_OUT | GLib.IO_ERR | GLib.IO_HUP, self.send_callback, ) except OSError as exc: self.stop(f"Problem with connection: {exc}") def disable_send(self) -> None: if self.send_id is None: return GLib.source_remove(self.send_id) self.send_id = None def recv_callback(self, fd: int, flags: int) -> bool: # noqa: ARG002 if flags & (GLib.IO_ERR | GLib.IO_HUP): self.stop(f"Bad client flags: {flags}") return True try: data = self._sock.recv(4096) except OSError as exc: if exc.errno not in (errno.EWOULDBLOCK, errno.EINTR): self.stop(f"Unexpected client error: {exc}") return True if not data: self.disable_recv() try: self.actor_ref.tell({"close": True}) except pykka.ActorDeadError: self.stop("Actor is dead.") return True try: self.actor_ref.tell({"received": data}) except pykka.ActorDeadError: self.stop("Actor is dead.") return True def send_callback(self, fd: int, flags: int) -> bool: # noqa: ARG002 if flags & (GLib.IO_ERR | GLib.IO_HUP): self.stop(f"Bad client flags: {flags}") return True # If with can't get the lock, simply try again next time socket is # ready for sending. if not self.send_lock.acquire(blocking=False): return True try: self.send_buffer = self.send(self.send_buffer) if not self.send_buffer: self.disable_send() finally: self.send_lock.release() return True def timeout_callback(self) -> bool: self.stop(f"Client inactive for {self.timeout:d}s; closing connection") return False def __str__(self) -> str: return format_address((self.host, self.port)) class LineProtocol(pykka.ThreadingActor): """ Base class for handling line based protocols. Takes care of receiving new data from server's client code, decoding and then splitting data along line boundaries. """ #: Line terminator to use for outputed lines. terminator = b"\n" #: Regex to use for spliting lines. delimiter = re.compile(rb"\r?\n") #: What encoding to expect incoming data to be in. encoding = "utf-8" def __init__(self, connection: Connection) -> None: super().__init__() self.connection = connection self.prevent_timeout = False self.recv_buffer = b"" @property def host(self) -> str: return self.connection.host @property def port(self) -> int | None: return self.connection.port def on_line_received(self, line: str) -> None: """ Called whenever a new line is found. Should be implemented by subclasses. """ raise NotImplementedError def on_receive(self, message: dict[str, Any]) -> None: """Handle messages with new data from server.""" if "close" in message: self.connection.stop("Client most likely disconnected.") return if "received" not in message: return self.connection.disable_timeout() self.recv_buffer += message["received"] for line in self.parse_lines(): decoded_line = self.decode(line) if decoded_line is not None: self.on_line_received(decoded_line) if not self.prevent_timeout: self.connection.enable_timeout() def on_failure( self, exception_type: type[BaseException] | None, # noqa: ARG002 exception_value: BaseException | None, # noqa: ARG002 traceback: TracebackType | None, # noqa: ARG002 ) -> None: """Clean up connection resouces when actor fails.""" self.connection.stop("Actor failed.") def on_stop(self) -> None: """Clean up connection resouces when actor stops.""" self.connection.stop("Actor is shutting down.") def parse_lines(self) -> Generator[bytes, Any]: """Consume new data and yield any lines found.""" while re.search(self.terminator, self.recv_buffer): line, self.recv_buffer = self.delimiter.split(self.recv_buffer, 1) yield line def encode(self, line: str) -> bytes: """ Handle encoding of line. Can be overridden by subclasses to change encoding behaviour. """ try: return line.encode(self.encoding) except UnicodeError: logger.warning( "Stopping actor due to encode problem, data " "supplied by client was not valid %s", self.encoding, ) self.stop() return Never # pyright: ignore[reportReturnType] def decode(self, line: bytes) -> str: """ Handle decoding of line. Can be overridden by subclasses to change decoding behaviour. """ try: return line.decode(self.encoding) except UnicodeError: logger.warning( "Stopping actor due to decode problem, data " "supplied by client was not valid %s", self.encoding, ) self.stop() return Never # pyright: ignore[reportReturnType] def join_lines(self, lines: list[str]) -> str: if not lines: return "" line_terminator = self.decode(self.terminator) return line_terminator.join(lines) + line_terminator def send_lines(self, lines: list[str]) -> None: """ Send array of lines to client via connection. Join lines using the terminator that is set for this class, encode it and send it to the client. """ if not lines: return # Remove all control characters (first 32 ASCII characters) lines = [line.translate(CONTROL_CHARS) for line in lines] data = self.join_lines(lines) self.connection.queue_send(self.encode(data)) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1793654069.6812406 mopidy_mpd-4.1.0/src/mopidy_mpd/protocol/0000755000175100017510000000000015272176466020134 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1793654065.9634058 mopidy_mpd-4.1.0/src/mopidy_mpd/protocol/__init__.py0000644000175100017510000002073115272176462022244 0ustar00runnerrunner""" This is Mopidy's MPD protocol implementation. This is partly based upon the `MPD protocol documentation `_, which is a useful resource, but it is rather incomplete with regards to data formats, both for requests and responses. Thus, we have had to talk a great deal with the the original `MPD server `_ using telnet to get the details we need to implement our own MPD server which is compatible with the numerous existing `MPD clients `_. """ from __future__ import annotations import inspect from collections.abc import Callable from typing import TYPE_CHECKING, Any from mopidy_mpd import exceptions if TYPE_CHECKING: from mopidy_mpd.context import MpdContext #: The MPD protocol uses UTF-8 for encoding all data. ENCODING = "utf-8" #: The MPD protocol uses ``\n`` as line terminator. LINE_TERMINATOR = b"\n" #: The MPD protocol version is 0.19.0. VERSION = "0.19.0" type ResultValue = str | int type ResultDict = dict[str, ResultValue] type ResultTuple = tuple[str, ResultValue] type ResultList = list[ResultTuple | ResultDict] type Result = ResultDict | ResultTuple | ResultList | None type HandlerFunc = Callable[..., Result] type ListCommand = bool | Callable[[MpdContext], bool] def load_protocol_modules() -> None: """ The protocol modules must be imported to get them registered in :attr:`commands`. """ from . import ( # noqa: F401, PLC0415 audio_output, channels, command_list, connection, current_playlist, mount, music_db, playback, reflection, status, stickers, stored_playlists, ) def INT(value: str) -> int: # noqa: N802 r"""Converts a value that matches [+-]?\d+ into an integer.""" if value is None: raise ValueError("None is not a valid integer") # TODO: check for whitespace via value != value.strip()? return int(value) def UINT(value: str) -> int: # noqa: N802 r"""Converts a value that matches \d+ into an integer.""" if value is None: raise ValueError("None is not a valid integer") if not value.isdigit(): raise ValueError("Only positive numbers are allowed") return int(value) def FLOAT(value: str) -> float: # noqa: N802 r"""Converts a value that matches [+-]\d+(.\d+)? into a float.""" if value is None: raise ValueError("None is not a valid float") return float(value) def UFLOAT(value: str) -> float: # noqa: N802 r"""Converts a value that matches \d+(.\d+)? into a float.""" if value is None: raise ValueError("None is not a valid float") result = float(value) if result < 0: raise ValueError("Only positive numbers are allowed") return result def BOOL(value: str) -> bool: # noqa: N802 """Convert the values 0 and 1 into booleans.""" if value in ("1", "0"): return bool(int(value)) raise ValueError(f"{value!r} is not 0 or 1") def RANGE(value: str) -> slice: # noqa: N802 """Convert a single integer or range spec into a slice ``n`` should become ``slice(n, n+1)`` ``n:`` should become ``slice(n, None)`` ``n:m`` should become ``slice(n, m)`` and ``m > n`` must hold """ if ":" in value: start, stop = value.split(":", 1) start = UINT(start) if stop.strip(): stop = UINT(stop) if start >= stop: raise ValueError("End must be larger than start") else: stop = None else: start = UINT(value) stop = start + 1 return slice(start, stop) class Commands: """Collection of MPD commands to expose to users. Normally used through the global instance which command handlers have been installed into. """ def __init__(self) -> None: self.handlers: dict[str, Handler] = {} # TODO: consider removing auth_required and list_command in favour of # additional command instances to register in? def add( self, name: str, *, auth_required: bool = True, list_command: ListCommand = True, **validators: Callable[[str], Any], ) -> Callable[[HandlerFunc], HandlerFunc]: """Create a decorator that registers a handler and validation rules. Additional keyword arguments are treated as converters/validators to apply to tokens converting them to proper Python types. Requirements for valid handlers: - must accept a context argument as the first arg. - may not use variable keyword arguments, ``**kwargs``. - may use variable arguments ``*args`` *or* a mix of required and optional arguments. Decorator returns the unwrapped function so that tests etc can use the functions with values with correct python types instead of strings. :param name: Name of the command being registered. :param auth_required: If authorization is required. :param list_command: If command should be listed in reflection. May be a callable taking the context, to decide per request. """ def wrapper(func: HandlerFunc) -> HandlerFunc: if name in self.handlers: raise ValueError(f"{name} already registered") self.handlers[name] = Handler( name=name, func=func, auth_required=auth_required, list_command=list_command, validators=validators, ) return func return wrapper def call( self, *, context: MpdContext, tokens: list[str], ) -> Result: """Find and run the handler registered for the given command. If the handler was registered with any converters/validators they will be run before calling the real handler. :param context: MPD context :param tokens: List of tokens to process """ if not tokens: raise exceptions.MpdNoCommandError command, tokens = tokens[0], tokens[1:] if command not in self.handlers: raise exceptions.MpdUnknownCommandError(command=command) return self.handlers[command](context, *tokens) #: Global instance to install commands into commands = Commands() class Handler: def __init__( self, *, name: str, func: HandlerFunc, auth_required: bool, list_command: ListCommand, validators: dict[str, Callable[[str], Any]], ) -> None: self.name = name self.func = func self.auth_required = auth_required self.list_command = list_command self.validators = validators self.spec = inspect.getfullargspec(func) if not self.spec.args and not self.spec.varargs: raise TypeError("Handler must accept at least one argument.") if len(self.spec.args) > 1 and self.spec.varargs: raise TypeError("*args may not be combined with regular arguments") if not set(self.validators.keys()).issubset(self.spec.args): raise TypeError("Validator for non-existent arg passed") if self.spec.varkw or self.spec.kwonlyargs: raise TypeError("Keyword arguments are not permitted") self.defaults = dict( zip( self.spec.args[-len(self.spec.defaults or []) :], self.spec.defaults or [], strict=False, ) ) def is_listed(self, context: MpdContext) -> bool: if callable(self.list_command): return self.list_command(context) return self.list_command def __call__(self, *args: Any, **kwargs: Any) -> Result: if self.spec.varargs: return self.func(*args, **kwargs) try: ba = inspect.signature(self.func).bind(*args, **kwargs) ba.apply_defaults() callargs = ba.arguments except TypeError as exc: raise exceptions.MpdArgError( f'wrong number of arguments for "{self.name}"' ) from exc for key, value in callargs.items(): if value == self.defaults.get(key, object()): continue if validator := self.validators.get(key): try: callargs[key] = validator(value) except ValueError as exc: raise exceptions.MpdArgError("incorrect arguments") from exc return self.func(**callargs) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1793654065.9639196 mopidy_mpd-4.1.0/src/mopidy_mpd/protocol/audio_output.py0000644000175100017510000000515515272176462023231 0ustar00runnerrunnerfrom __future__ import annotations from typing import TYPE_CHECKING from mopidy_mpd import exceptions, protocol if TYPE_CHECKING: from mopidy_mpd.context import MpdContext @protocol.commands.add("disableoutput", outputid=protocol.UINT) def disableoutput(context: MpdContext, outputid: int) -> None: """ *musicpd.org, audio output section:* ``disableoutput {ID}`` Turns an output off. """ if outputid == 0: success = context.core.mixer.set_mute(False).get() if not success: raise exceptions.MpdSystemError("problems disabling output") else: raise exceptions.MpdNoExistError("No such audio output") @protocol.commands.add("enableoutput", outputid=protocol.UINT) def enableoutput(context: MpdContext, outputid: int) -> None: """ *musicpd.org, audio output section:* ``enableoutput {ID}`` Turns an output on. """ if outputid == 0: success = context.core.mixer.set_mute(True).get() if not success: raise exceptions.MpdSystemError("problems enabling output") else: raise exceptions.MpdNoExistError("No such audio output") @protocol.commands.add("toggleoutput", outputid=protocol.UINT) def toggleoutput(context: MpdContext, outputid: int) -> None: """ *musicpd.org, audio output section:* ``toggleoutput {ID}`` Turns an output on or off, depending on the current state. """ if outputid == 0: mute_status = context.core.mixer.get_mute().get() success = context.core.mixer.set_mute(not mute_status) if not success: raise exceptions.MpdSystemError("problems toggling output") else: raise exceptions.MpdNoExistError("No such audio output") @protocol.commands.add("outputs") def outputs(context: MpdContext) -> protocol.Result: """ *musicpd.org, audio output section:* ``outputs`` Shows information about all outputs. """ muted = 1 if context.core.mixer.get_mute().get() else 0 return [ ("outputid", 0), ("plugin", "mute"), ("outputname", "Mute"), ("outputenabled", muted), ] @protocol.commands.add("outputset", outputid=protocol.UINT) def outputset( context: MpdContext, outputid: int, name: str, value: str ) -> protocol.Result: """ *musicpd.org, audio output section:* ``outputset {ID} {NAME} {VALUE}`` Set a runtime attribute. These are specific to the output plugin, and supported values are usually printed in the outputs response. """ raise exceptions.MpdNoExistError("Setting attributes is not supported") ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1793654065.9639196 mopidy_mpd-4.1.0/src/mopidy_mpd/protocol/channels.py0000644000175100017510000000446015272176462022301 0ustar00runnerrunnerfrom __future__ import annotations from typing import TYPE_CHECKING, Never from mopidy_mpd import exceptions, protocol if TYPE_CHECKING: from mopidy_mpd.context import MpdContext @protocol.commands.add( "subscribe", list_command=False, # Remove when implementing command ) def subscribe(context: MpdContext, channel: str) -> Never: """ *musicpd.org, client to client section:* ``subscribe {NAME}`` Subscribe to a channel. The channel is created if it does not exist already. The name may consist of alphanumeric ASCII characters plus underscore, dash, dot and colon. """ # TODO: match channel against [A-Za-z0-9:._-]+ raise exceptions.MpdNotImplementedError # TODO @protocol.commands.add( "unsubscribe", list_command=False, # Remove when implementing command ) def unsubscribe(context: MpdContext, channel: str) -> Never: """ *musicpd.org, client to client section:* ``unsubscribe {NAME}`` Unsubscribe from a channel. """ # TODO: match channel against [A-Za-z0-9:._-]+ raise exceptions.MpdNotImplementedError # TODO @protocol.commands.add( "channels", list_command=False, # Remove when implementing command ) def channels(context: MpdContext) -> Never: """ *musicpd.org, client to client section:* ``channels`` Obtain a list of all channels. The response is a list of "channel:" lines. """ raise exceptions.MpdNotImplementedError # TODO @protocol.commands.add( "readmessages", list_command=False, # Remove when implementing command ) def readmessages(context: MpdContext) -> Never: """ *musicpd.org, client to client section:* ``readmessages`` Reads messages for this client. The response is a list of "channel:" and "message:" lines. """ raise exceptions.MpdNotImplementedError # TODO @protocol.commands.add( "sendmessage", list_command=False, # Remove when implementing command ) def sendmessage(context: MpdContext, channel: str, text: str) -> Never: """ *musicpd.org, client to client section:* ``sendmessage {CHANNEL} {TEXT}`` Send a message to the specified channel. """ # TODO: match channel against [A-Za-z0-9:._-]+ raise exceptions.MpdNotImplementedError # TODO ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1793654065.9639196 mopidy_mpd-4.1.0/src/mopidy_mpd/protocol/command_list.py0000644000175100017510000000510515272176462023154 0ustar00runnerrunnerfrom __future__ import annotations from typing import TYPE_CHECKING from mopidy_mpd import exceptions, protocol if TYPE_CHECKING: from mopidy_mpd.context import MpdContext @protocol.commands.add("command_list_begin", list_command=False) def command_list_begin(context: MpdContext) -> None: """ *musicpd.org, command list section:* To facilitate faster adding of files etc. you can pass a list of commands all at once using a command list. The command list begins with ``command_list_begin`` or ``command_list_ok_begin`` and ends with ``command_list_end``. It does not execute any commands until the list has ended. The return value is whatever the return for a list of commands is. On success for all commands, ``OK`` is returned. If a command fails, no more commands are executed and the appropriate ``ACK`` error is returned. If ``command_list_ok_begin`` is used, ``list_OK`` is returned for each successful command executed in the command list. """ context.dispatcher.command_list_receiving = True context.dispatcher.command_list_ok = False context.dispatcher.command_list = [] @protocol.commands.add("command_list_end", list_command=False) def command_list_end(context: MpdContext) -> protocol.Result: """See :meth:`command_list_begin()`.""" # TODO: batch consecutive add commands if not context.dispatcher.command_list_receiving: raise exceptions.MpdUnknownCommandError(command="command_list_end") context.dispatcher.command_list_receiving = False (command_list, context.dispatcher.command_list) = ( context.dispatcher.command_list, [], ) (command_list_ok, context.dispatcher.command_list_ok) = ( context.dispatcher.command_list_ok, False, ) command_list_response = [] for index, command in enumerate(command_list): response = context.dispatcher.handle_request( command, current_command_list_index=index ) command_list_response.extend(response) if command_list_response and command_list_response[-1].startswith("ACK"): return command_list_response if command_list_ok: command_list_response.append("list_OK") return command_list_response @protocol.commands.add("command_list_ok_begin", list_command=False) def command_list_ok_begin(context: MpdContext) -> None: """See :meth:`command_list_begin()`.""" context.dispatcher.command_list_receiving = True context.dispatcher.command_list_ok = True context.dispatcher.command_list = [] ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1793654065.9639196 mopidy_mpd-4.1.0/src/mopidy_mpd/protocol/connection.py0000644000175100017510000000766615272176462022660 0ustar00runnerrunnerfrom __future__ import annotations from typing import TYPE_CHECKING, Never from mopidy_mpd import exceptions, protocol from mopidy_mpd.protocol import tagtype_list if TYPE_CHECKING: from mopidy_mpd.context import MpdContext MIN_BINARY_LIMIT = 4096 # 4KB (default minimum for MPD) MAX_BINARY_LIMIT = 8384512 # 8MB (default maximum for MPD) @protocol.commands.add("close", auth_required=False) def close(context: MpdContext) -> None: """ *musicpd.org, connection section:* ``close`` Closes the connection to MPD. """ context.session.close() @protocol.commands.add("kill", list_command=False) def kill(context: MpdContext) -> Never: """ *musicpd.org, connection section:* ``kill`` Kills MPD. """ raise exceptions.MpdPermissionError(command="kill") @protocol.commands.add("password", auth_required=False) def password(context: MpdContext, password: str) -> None: """ *musicpd.org, connection section:* ``password {PASSWORD}`` This is used for authentication with the server. ``PASSWORD`` is simply the plaintext password. """ if password == context.config["mpd"]["password"]: context.dispatcher.authenticated = True else: raise exceptions.MpdPasswordError("incorrect password") @protocol.commands.add("ping", auth_required=False) def ping(context: MpdContext) -> None: """ *musicpd.org, connection section:* ``ping`` Does nothing but return ``OK``. """ @protocol.commands.add("tagtypes") def tagtypes(context: MpdContext, *args: str) -> protocol.Result: """ *mpd.readthedocs.io, connection settings section:* ``tagtypes`` Shows a list of available song metadata. ``tagtypes disable {NAME...}`` Remove one or more tags from the list of tag types the client is interested in. ``tagtypes enable {NAME...}`` Re-enable one or more tags from the list of tag types for this client. ``tagtypes clear`` Clear the list of tag types this client is interested in. ``tagtypes all`` Announce that this client is interested in all tag types. """ parameters = list(args) if parameters: subcommand = parameters.pop(0).lower() match subcommand: case "all": context.session.tagtypes.update(tagtype_list.TAGTYPE_LIST) case "clear": context.session.tagtypes.clear() case "disable": _validate_tagtypes(parameters) context.session.tagtypes.difference_update(parameters) case "enable": _validate_tagtypes(parameters) context.session.tagtypes.update(parameters) case _: raise exceptions.MpdArgError("Unknown sub command") return None return [("tagtype", tagtype) for tagtype in context.session.tagtypes] @protocol.commands.add("binarylimit", auth_required=False, size=protocol.UINT) def binarylimit(context: MpdContext, size: int) -> None: """ *musicpd.org, connection section:* ``binarylimit SIZE`` Set the maximum binary response size for the current connection to the specified number of bytes. A bigger value means less overhead for transmitting large entities, but it also means that the connection is blocked for a longer time. """ if size < MIN_BINARY_LIMIT: raise exceptions.MpdArgError(f"'size' must be at least {MIN_BINARY_LIMIT}") if size > MAX_BINARY_LIMIT: raise exceptions.MpdArgError(f"'size' must be at most {MAX_BINARY_LIMIT}") context.session.update_binary_limit(size) def _validate_tagtypes(parameters: list[str]) -> None: param_set = set(parameters) if not param_set: raise exceptions.MpdArgError("Not enough arguments") if not param_set.issubset(tagtype_list.TAGTYPE_LIST): raise exceptions.MpdArgError("Unknown tag type") ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1793654065.9639196 mopidy_mpd-4.1.0/src/mopidy_mpd/protocol/current_playlist.py0000644000175100017510000003674315272176462024122 0ustar00runnerrunnerfrom __future__ import annotations from typing import TYPE_CHECKING from urllib.parse import urlparse from mopidy.types import TracklistId from mopidy_mpd import exceptions, protocol, translator if TYPE_CHECKING: from mopidy.types import Uri from mopidy_mpd.context import MpdContext @protocol.commands.add("add") def add(context: MpdContext, uri: Uri) -> None: """ *musicpd.org, current playlist section:* ``add {URI}`` Adds the file ``URI`` to the playlist (directories add recursively). ``URI`` can also be a single file. *Clarifications:* - ``add ""`` should add all tracks in the library to the current playlist. """ if not uri.strip("/"): return # If we have an URI just try and add it directly without bothering with # jumping through browse... if urlparse(uri).scheme != "" and context.core.tracklist.add(uris=[uri]).get(): return try: uris = [] for _path, ref in context.browse(uri, recursive=True, lookup=False): if ref: uris.append(ref.uri) except exceptions.MpdNoExistError as exc: exc.message = "directory or file not found" raise if not uris: raise exceptions.MpdNoExistError("directory or file not found") context.core.tracklist.add(uris=uris).get() @protocol.commands.add("addid", songpos=protocol.UINT) def addid(context: MpdContext, uri: Uri, songpos: int | None = None) -> protocol.Result: """ *musicpd.org, current playlist section:* ``addid {URI} [POSITION]`` Adds a song to the playlist (non-recursive) and returns the song id. ``URI`` is always a single file or URL. For example:: addid "foo.mp3" Id: 999 OK *Clarifications:* - ``addid ""`` should return an error. """ if not uri: raise exceptions.MpdNoExistError("No such song") length = context.core.tracklist.get_length() if songpos is not None and songpos > length.get(): raise exceptions.MpdArgError("Bad song index") tl_tracks = context.core.tracklist.add(uris=[uri], at_position=songpos).get() if not tl_tracks: raise exceptions.MpdNoExistError("No such song") return ("Id", tl_tracks[0].tlid) @protocol.commands.add("delete", songrange=protocol.RANGE) def delete(context: MpdContext, songrange: slice) -> None: """ *musicpd.org, current playlist section:* ``delete [{POS} | {START:END}]`` Deletes a song from the playlist. """ start = songrange.start end = songrange.stop if end is None: end = context.core.tracklist.get_length().get() tl_tracks = context.core.tracklist.slice(start, end).get() if not tl_tracks: raise exceptions.MpdArgError("Bad song index", command="delete") context.core.tracklist.remove({"tlid": [tl_track.tlid for tl_track in tl_tracks]}) @protocol.commands.add("deleteid", tlid=protocol.UINT) def deleteid(context: MpdContext, tlid: int) -> None: """ *musicpd.org, current playlist section:* ``deleteid {SONGID}`` Deletes the song ``SONGID`` from the playlist """ tl_tracks = context.core.tracklist.remove({"tlid": [tlid]}).get() if not tl_tracks: raise exceptions.MpdNoExistError("No such song") @protocol.commands.add("clear") def clear(context: MpdContext) -> None: """ *musicpd.org, current playlist section:* ``clear`` Clears the current playlist. """ context.core.tracklist.clear() @protocol.commands.add("move", songrange=protocol.RANGE, to=protocol.UINT) def move_range(context: MpdContext, songrange: slice, to: int) -> None: """ *musicpd.org, current playlist section:* ``move [{FROM} | {START:END}] {TO}`` Moves the song at ``FROM`` or range of songs at ``START:END`` to ``TO`` in the playlist. """ start = songrange.start end = songrange.stop if end is None: end = context.core.tracklist.get_length().get() context.core.tracklist.move(start, end, to) @protocol.commands.add("moveid", tlid=protocol.UINT, to=protocol.UINT) def moveid(context: MpdContext, tlid: int, to: int) -> None: """ *musicpd.org, current playlist section:* ``moveid {FROM} {TO}`` Moves the song with ``FROM`` (songid) to ``TO`` (playlist index) in the playlist. If ``TO`` is negative, it is relative to the current song in the playlist (if there is one). """ position = context.core.tracklist.index(tlid=TracklistId(tlid)).get() if position is None: raise exceptions.MpdNoExistError("No such song") context.core.tracklist.move(position, position + 1, to) @protocol.commands.add("playlist") def playlist(context: MpdContext) -> protocol.Result: """ *musicpd.org, current playlist section:* ``playlist`` Displays the current playlist. .. note:: Do not use this, instead use ``playlistinfo``. """ return playlistinfo(context) @protocol.commands.add("playlistfind") def playlistfind(context: MpdContext, tag: str, needle: str) -> protocol.Result: """ *musicpd.org, current playlist section:* ``playlistfind {TAG} {NEEDLE}`` Finds songs in the current playlist with strict matching. """ if tag == "filename": tl_tracks = context.core.tracklist.filter({"uri": [needle]}).get() if not tl_tracks: return None position = context.core.tracklist.index(tl_tracks[0]).get() return translator.track_to_mpd_format( tl_tracks[0], context.session.tagtypes, position=position ) raise exceptions.MpdNotImplementedError # TODO @protocol.commands.add("playlistid", tlid=protocol.UINT) def playlistid(context: MpdContext, tlid: int | None = None) -> protocol.Result: """ *musicpd.org, current playlist section:* ``playlistid {SONGID}`` Displays a list of songs in the playlist. ``SONGID`` is optional and specifies a single song to display info for. """ if tlid is not None: tl_tracks = context.core.tracklist.filter({"tlid": [tlid]}).get() if not tl_tracks: raise exceptions.MpdNoExistError("No such song") position = context.core.tracklist.index(tl_tracks[0]).get() return translator.track_to_mpd_format( tl_tracks[0], context.session.tagtypes, position=position ) return translator.tracks_to_mpd_format( context.core.tracklist.get_tl_tracks().get(), context.session.tagtypes, ) @protocol.commands.add("playlistinfo") def playlistinfo(context: MpdContext, parameter: str | None = None) -> protocol.Result: """ *musicpd.org, current playlist section:* ``playlistinfo [[SONGPOS] | [START:END]]`` Displays a list of all songs in the playlist, or if the optional argument is given, displays information only for the song ``SONGPOS`` or the range of songs ``START:END``. *ncmpc and mpc:* - uses negative indexes, like ``playlistinfo "-1"``, to request the entire playlist """ if parameter is None or parameter == "-1": start, end = 0, None else: tracklist_slice = protocol.RANGE(parameter) start, end = tracklist_slice.start, tracklist_slice.stop tl_tracks = context.core.tracklist.get_tl_tracks().get() if start and start > len(tl_tracks): raise exceptions.MpdArgError("Bad song index") if end and end > len(tl_tracks): end = None return translator.tracks_to_mpd_format( tl_tracks, context.session.tagtypes, start=start, end=end ) @protocol.commands.add("playlistsearch") def playlistsearch(context: MpdContext, tag: str, needle: str) -> protocol.Result: """ *musicpd.org, current playlist section:* ``playlistsearch {TAG} {NEEDLE}`` Searches case-sensitively for partial matches in the current playlist. *GMPC:* - uses ``filename`` and ``any`` as tags """ raise exceptions.MpdNotImplementedError # TODO DEFAULT_PLCHANGES_RANGE = slice(0, None) @protocol.commands.add("plchanges", version=protocol.INT, songrange=protocol.RANGE) def plchanges( context: MpdContext, version: int, songrange: slice = DEFAULT_PLCHANGES_RANGE, ) -> protocol.Result: """ *musicpd.org, current playlist section:* ``plchanges {VERSION} [START:END]`` Displays changed songs currently in the playlist since ``VERSION``. Start and end positions may be given to limit the output to changes in the given range. To detect songs that were deleted at the end of the playlist, use ``playlistlength`` returned by status command. *MPDroid:* - Calls ``plchanges "-1"`` two times per second to get the entire playlist. """ # TODO: Naive implementation that returns all tracks as changed tracklist_version = context.core.tracklist.get_version().get() if version < tracklist_version: tl_tracks = context.core.tracklist.get_tl_tracks().get() start, end, _ = songrange.indices(len(tl_tracks)) return translator.tracks_to_mpd_format( tl_tracks, context.session.tagtypes, start=start, end=end ) if version == tracklist_version: # A version match could indicate this is just a metadata update, so # check for a stream ref and let the client know about the change. stream_title = context.core.playback.get_stream_title().get() if stream_title is None: return None tl_track = context.core.playback.get_current_tl_track().get() position = context.core.tracklist.index(tl_track).get() if ( tl_track is not None and position is not None and songrange.start <= position and (songrange.stop is None or position < songrange.stop) ): return translator.track_to_mpd_format( tl_track, context.session.tagtypes, position=position, stream_title=stream_title, ) return None @protocol.commands.add("plchangesposid", version=protocol.INT, songrange=protocol.RANGE) def plchangesposid( context: MpdContext, version: int, songrange: slice = DEFAULT_PLCHANGES_RANGE, ) -> protocol.Result: """ *musicpd.org, current playlist section:* ``plchangesposid {VERSION} [START:END]`` Displays changed songs currently in the playlist since ``VERSION``. This function only returns the position and the id of the changed song, not the complete metadata. This is more bandwidth efficient. Start and end positions may be given to limit the output to changes in the given range. To detect songs that were deleted at the end of the playlist, use ``playlistlength`` returned by status command. """ # TODO: Naive implementation that returns all tracks as changed if int(version) != context.core.tracklist.get_version().get(): result = [] tl_tracks = context.core.tracklist.get_tl_tracks().get() for position, (tlid, _) in list(enumerate(tl_tracks))[songrange]: result.append(("cpos", position)) result.append(("Id", tlid)) return result return None @protocol.commands.add("prio", priority=protocol.UINT, position=protocol.RANGE) def prio(context: MpdContext, priority: int, position: slice) -> protocol.Result: """ *musicpd.org, current playlist section:* ``prio {PRIORITY} {START:END...}`` Set the priority of the specified songs. A higher priority means that it will be played first when "random" mode is enabled. A priority is an integer between 0 and 255. The default priority of new songs is 0. """ raise exceptions.MpdNotImplementedError # TODO @protocol.commands.add("prioid") def prioid(context: MpdContext, *args: str) -> protocol.Result: """ *musicpd.org, current playlist section:* ``prioid {PRIORITY} {ID...}`` Same as prio, but address the songs with their id. """ raise exceptions.MpdNotImplementedError # TODO @protocol.commands.add("rangeid", tlid=protocol.UINT, songrange=protocol.RANGE) def rangeid(context: MpdContext, tlid: int, songrange: slice) -> protocol.Result: """ *musicpd.org, current playlist section:* ``rangeid {ID} {START:END}`` Specifies the portion of the song that shall be played. START and END are offsets in seconds (fractional seconds allowed); both are optional. Omitting both (i.e. sending just ":") means "remove the range, play everything". A song that is currently playing cannot be manipulated this way. .. versionadded:: 0.19 New in MPD protocol version 0.19 """ raise exceptions.MpdNotImplementedError # TODO @protocol.commands.add("shuffle", songrange=protocol.RANGE) def shuffle(context: MpdContext, songrange: slice | None = None) -> None: """ *musicpd.org, current playlist section:* ``shuffle [START:END]`` Shuffles the current playlist. ``START:END`` is optional and specifies a range of songs. """ if songrange is None: start, end = None, None else: start, end = songrange.start, songrange.stop context.core.tracklist.shuffle(start, end) @protocol.commands.add("swap", songpos1=protocol.UINT, songpos2=protocol.UINT) def swap(context: MpdContext, songpos1: int, songpos2: int) -> None: """ *musicpd.org, current playlist section:* ``swap {SONG1} {SONG2}`` Swaps the positions of ``SONG1`` and ``SONG2``. """ if songpos2 < songpos1: songpos1, songpos2 = songpos2, songpos1 context.core.tracklist.move(songpos1, songpos1 + 1, songpos2) context.core.tracklist.move(songpos2 - 1, songpos2, songpos1) @protocol.commands.add("swapid", tlid1=protocol.UINT, tlid2=protocol.UINT) def swapid(context: MpdContext, tlid1: int, tlid2: int) -> None: """ *musicpd.org, current playlist section:* ``swapid {SONG1} {SONG2}`` Swaps the positions of ``SONG1`` and ``SONG2`` (both song ids). """ position1 = context.core.tracklist.index(tlid=TracklistId(tlid1)).get() position2 = context.core.tracklist.index(tlid=TracklistId(tlid2)).get() if position1 is None or position2 is None: raise exceptions.MpdNoExistError("No such song") swap(context, position1, position2) @protocol.commands.add("addtagid", tlid=protocol.UINT) def addtagid(context: MpdContext, tlid: int, tag: str, value: str) -> protocol.Result: """ *musicpd.org, current playlist section:* ``addtagid {SONGID} {TAG} {VALUE}`` Adds a tag to the specified song. Editing song tags is only possible for remote songs. This change is volatile: it may be overwritten by tags received from the server, and the data is gone when the song gets removed from the queue. .. versionadded:: 0.19 New in MPD protocol version 0.19 """ raise exceptions.MpdNotImplementedError # TODO @protocol.commands.add("cleartagid", tlid=protocol.UINT) def cleartagid(context: MpdContext, tlid: int, tag: str) -> protocol.Result: """ *musicpd.org, current playlist section:* ``cleartagid {SONGID} [TAG]`` Removes tags from the specified song. If TAG is not specified, then all tag values will be removed. Editing song tags is only possible for remote songs. .. versionadded:: 0.19 New in MPD protocol version 0.19 """ raise exceptions.MpdNotImplementedError # TODO ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1793654065.9639196 mopidy_mpd-4.1.0/src/mopidy_mpd/protocol/mount.py0000644000175100017510000000424615272176462021652 0ustar00runnerrunnerfrom __future__ import annotations from typing import TYPE_CHECKING, Never from mopidy_mpd import exceptions, protocol if TYPE_CHECKING: from mopidy.types import Uri from mopidy_mpd.context import MpdContext @protocol.commands.add("mount") def mount(context: MpdContext, path: str, uri: Uri) -> Never: """ *musicpd.org, mounts and neighbors section:* ``mount {PATH} {URI}`` Mount the specified remote storage URI at the given path. Example:: mount foo nfs://192.168.1.4/export/mp3 .. versionadded:: 0.19 New in MPD protocol version 0.19 """ raise exceptions.MpdNotImplementedError # TODO @protocol.commands.add("unmount") def unmount(context: MpdContext, path: str) -> Never: """ *musicpd.org, mounts and neighbors section:* ``unmount {PATH}`` Unmounts the specified path. Example:: unmount foo .. versionadded:: 0.19 New in MPD protocol version 0.19 """ raise exceptions.MpdNotImplementedError # TODO @protocol.commands.add("listmounts") def listmounts(context: MpdContext) -> Never: """ *musicpd.org, mounts and neighbors section:* ``listmounts`` Queries a list of all mounts. By default, this contains just the configured music_directory. Example:: listmounts mount: storage: /home/foo/music mount: foo storage: nfs://192.168.1.4/export/mp3 OK .. versionadded:: 0.19 New in MPD protocol version 0.19 """ raise exceptions.MpdNotImplementedError # TODO @protocol.commands.add("listneighbors") def listneighbors(context: MpdContext) -> Never: """ *musicpd.org, mounts and neighbors section:* ``listneighbors`` Queries a list of "neighbors" (e.g. accessible file servers on the local net). Items on that list may be used with the mount command. Example:: listneighbors neighbor: smb://FOO name: FOO (Samba 4.1.11-Debian) OK .. versionadded:: 0.19 New in MPD protocol version 0.19 """ raise exceptions.MpdNotImplementedError # TODO ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1793654065.9639196 mopidy_mpd-4.1.0/src/mopidy_mpd/protocol/music_db.py0000644000175100017510000004631615272176462022301 0ustar00runnerrunnerfrom __future__ import annotations import itertools from typing import TYPE_CHECKING, cast from mopidy.models import Album, Artist, SearchResult, Track from mopidy_mpd import exceptions, protocol, translator from mopidy_mpd.protocol import stored_playlists if TYPE_CHECKING: from collections.abc import Iterable, Sequence from mopidy.types import DistinctField, Query, SearchField, Uri from mopidy_mpd.context import MpdContext _LIST_MAPPING: dict[str, DistinctField] = { "album": "album", "albumartist": "albumartist", "artist": "artist", "comment": "comment", "composer": "composer", "date": "date", "disc": "disc_no", "file": "uri", "filename": "uri", "genre": "genre", "musicbrainz_albumid": "musicbrainz_albumid", "musicbrainz_artistid": "musicbrainz_artistid", "musicbrainz_trackid": "musicbrainz_trackid", "performer": "performer", "title": "track_name", "track": "track_no", } _LIST_NAME_MAPPING = { "album": "Album", "albumartist": "AlbumArtist", "artist": "Artist", "comment": "Comment", "composer": "Composer", "date": "Date", "disc_no": "Disc", "genre": "Genre", "performer": "Performer", "musicbrainz_albumid": "MUSICBRAINZ_ALBUMID", "musicbrainz_artistid": "MUSICBRAINZ_ARTISTID", "musicbrainz_trackid": "MUSICBRAINZ_TRACKID", "track_name": "Title", "track_no": "Track", "uri": "file", } _SEARCH_MAPPING = dict(_LIST_MAPPING, any="any") def _query_for_search(parameters: Sequence[str]) -> Query[SearchField]: query: dict[str, list[str]] = {} parameters = list(parameters) while parameters: # TODO: does it matter that this is now case insensitive field = _SEARCH_MAPPING.get(parameters.pop(0).lower()) if not field: raise exceptions.MpdArgError("incorrect arguments") if not parameters: raise ValueError value = parameters.pop(0) if value.strip(): query.setdefault(field, []).append(value) return cast("Query[SearchField]", query) def _split_window(args: Sequence[str]) -> tuple[Sequence[str], slice]: """Split a trailing ``window START:END`` pair off ``find``/``search`` args.""" match args: case [*rest, "window", window]: try: return rest, protocol.RANGE(window) except ValueError as exc: raise exceptions.MpdArgError("incorrect arguments") from exc case _: return args, slice(None) def _get_albums(search_results: Iterable[SearchResult]) -> list[Album]: return list(itertools.chain(*[r.albums for r in search_results])) def _get_artists(search_results: Iterable[SearchResult]) -> list[Artist]: return list(itertools.chain(*[r.artists for r in search_results])) def _get_tracks(search_results: Iterable[SearchResult]) -> list[Track]: return list(itertools.chain(*[r.tracks for r in search_results])) def _album_as_track(album: Album) -> Track | None: if album.uri is None: return None return Track( uri=album.uri, name=f"Album: {album.name}", artists=album.artists, album=album, date=album.date, ) def _artist_as_track(artist: Artist) -> Track | None: if artist.uri is None: return None return Track( uri=artist.uri, name=f"Artist: {artist.name}", artists=frozenset([artist]), ) @protocol.commands.add("count") def count(context: MpdContext, *args: str) -> protocol.Result: """ *musicpd.org, music database section:* ``count {TAG} {NEEDLE}`` Counts the number of songs and their total playtime in the db matching ``TAG`` exactly. *GMPC:* - use multiple tag-needle pairs to make more specific searches. """ try: query = _query_for_search(args) except ValueError as exc: raise exceptions.MpdArgError("incorrect arguments") from exc results = context.core.library.search(query=query, exact=True).get() result_tracks = _get_tracks(results) total_length = sum(t.length for t in result_tracks if t.length) return [ ("songs", len(result_tracks)), ("playtime", int(total_length / 1000)), ] @protocol.commands.add("find") def find(context: MpdContext, *args: str) -> protocol.Result: """ *musicpd.org, music database section:* ``find {TYPE} {WHAT} [...] [window START:END]`` Finds songs in the db that are exactly ``WHAT``. ``TYPE`` can be any tag supported by MPD, or one of the two special parameters - ``file`` to search by full path (relative to database root), and ``any`` to match against all available tags. ``WHAT`` is what to find. ``window`` can be used to query only a portion of the real response. The parameter is two zero-based record numbers; a start number and an end number. *GMPC:* - also uses ``find album "[ALBUM]" artist "[ARTIST]"`` to list album tracks. *ncmpc:* - capitalizes the type argument. *ncmpcpp:* - also uses the search type "date". - uses "file" instead of "filename". """ params, window = _split_window(args) try: query = _query_for_search(params) except ValueError: return None results = context.core.library.search(query=query, exact=True).get() result_tracks: list[Track] = [] if ( "artist" not in query and "albumartist" not in query and "composer" not in query and "performer" not in query ): result_tracks += [ track for a in _get_artists(results) if (track := _artist_as_track(a)) is not None ] if "album" not in query: result_tracks += [ track for a in _get_albums(results) if (track := _album_as_track(a)) is not None ] result_tracks += _get_tracks(results) return translator.tracks_to_mpd_format( result_tracks[window], context.session.tagtypes ) @protocol.commands.add("findadd") def findadd(context: MpdContext, *args: str) -> None: """ *musicpd.org, music database section:* ``findadd {TYPE} {WHAT}`` Finds songs in the db that are exactly ``WHAT`` and adds them to current playlist. Parameters have the same meaning as for ``find``. """ try: query = _query_for_search(args) except ValueError: return results = context.core.library.search(query=query, exact=True).get() uris = [track.uri for track in _get_tracks(results) if track.uri is not None] context.core.tracklist.add(uris=uris).get() @protocol.commands.add("list") def list_(context: MpdContext, *args: str) -> protocol.Result: """ *musicpd.org, music database section:* ``list {TYPE} [ARTIST]`` Lists all tags of the specified type. ``TYPE`` should be ``album``, ``artist``, ``albumartist``, ``date``, or ``genre``. ``ARTIST`` is an optional parameter when type is ``album``, ``date``, or ``genre``. This filters the result list by an artist. *Clarifications:* The musicpd.org documentation for ``list`` is far from complete. The command also supports the following variant: ``list {TYPE} {QUERY}`` Where ``QUERY`` applies to all ``TYPE``. ``QUERY`` is one or more pairs of a field name and a value. If the ``QUERY`` consists of more than one pair, the pairs are AND-ed together to find the result. Examples of valid queries and what they should return: ``list "artist" "artist" "ABBA"`` List artists where the artist name is "ABBA". Response:: Artist: ABBA OK ``list "album" "artist" "ABBA"`` Lists albums where the artist name is "ABBA". Response:: Album: More ABBA Gold: More ABBA Hits Album: Absolute More Christmas Album: Gold: Greatest Hits OK ``list "artist" "album" "Gold: Greatest Hits"`` Lists artists where the album name is "Gold: Greatest Hits". Response:: Artist: ABBA OK ``list "artist" "artist" "ABBA" "artist" "TLC"`` Lists artists where the artist name is "ABBA" *and* "TLC". Should never match anything. Response:: OK ``list "date" "artist" "ABBA"`` Lists dates where artist name is "ABBA". Response:: Date: Date: 1992 Date: 1993 OK ``list "date" "artist" "ABBA" "album" "Gold: Greatest Hits"`` Lists dates where artist name is "ABBA" and album name is "Gold: Greatest Hits". Response:: Date: 1992 OK ``list "genre" "artist" "The Rolling Stones"`` Lists genres where artist name is "The Rolling Stones". Response:: Genre: Genre: Rock OK *ncmpc:* - capitalizes the field argument. """ params = list(args) if not params: raise exceptions.MpdArgError('too few arguments for "list"') field_arg = params.pop(0) field = _LIST_MAPPING.get(field_arg.lower()) if field is None: raise exceptions.MpdArgError(f"Unknown tag type: {field_arg}") query: Query[SearchField] | None = None if len(params) == 1: if field != "album": raise exceptions.MpdArgError('should be "Album" for 3 arguments') if params[0].strip(): query = {"artist": params} else: try: query = _query_for_search(params) except exceptions.MpdArgError as exc: exc.message = "Unknown filter type" # B306: Our own exception raise except ValueError: return None name = _LIST_NAME_MAPPING[field] result = context.core.library.get_distinct(field, query) return [(name, value) for value in result.get()] @protocol.commands.add("listall") def listall(context: MpdContext, uri: str | None = None) -> protocol.Result: """ *musicpd.org, music database section:* ``listall [URI]`` Lists all songs and directories in ``URI``. Do not use this command. Do not manage a client-side copy of MPD's database. That is fragile and adds huge overhead. It will break with large databases. Instead, query MPD whenever you need something. .. warning:: This command is disabled by default in Mopidy installs. """ result = [] for path, track_ref in context.browse(uri, recursive=True, lookup=False): if not track_ref: result.append(("directory", path.lstrip("/"))) else: result.append(("file", track_ref.uri)) if not result: raise exceptions.MpdNoExistError("Not found") return result @protocol.commands.add("listallinfo") def listallinfo(context: MpdContext, uri: str | None = None) -> protocol.Result: """ *musicpd.org, music database section:* ``listallinfo [URI]`` Same as ``listall``, except it also returns metadata info in the same format as ``lsinfo``. Do not use this command. Do not manage a client-side copy of MPD's database. That is fragile and adds huge overhead. It will break with large databases. Instead, query MPD whenever you need something. .. warning:: This command is disabled by default in Mopidy installs. """ result: protocol.ResultList = [] for path, lookup_future in context.browse(uri, recursive=True, lookup=True): if not lookup_future: result.append(("directory", path.lstrip("/"))) else: for tracks in lookup_future.get().values(): for track in tracks: result.extend( translator.track_to_mpd_format(track, context.session.tagtypes) ) return result @protocol.commands.add("listfiles") def listfiles(context: MpdContext, uri: str | None = None) -> protocol.Result: """ *musicpd.org, music database section:* ``listfiles [URI]`` Lists the contents of the directory URI, including files are not recognized by MPD. URI can be a path relative to the music directory or an URI understood by one of the storage plugins. The response contains at least one line for each directory entry with the prefix "file: " or "directory: ", and may be followed by file attributes such as "Last-Modified" and "size". For example, "smb://SERVER" returns a list of all shares on the given SMB/CIFS server; "nfs://servername/path" obtains a directory listing from the NFS server. .. versionadded:: 0.19 New in MPD protocol version 0.19 """ raise exceptions.MpdNotImplementedError # TODO @protocol.commands.add("lsinfo") def lsinfo(context: MpdContext, uri: str | None = None) -> protocol.Result: """ *musicpd.org, music database section:* ``lsinfo [URI]`` Lists the contents of the directory ``URI``. When listing the root directory, this currently returns the list of stored playlists. This behavior is deprecated; use ``listplaylists`` instead. MPD returns the same result, including both playlists and the files and directories located at the root level, for both ``lsinfo``, ``lsinfo ""``, and ``lsinfo "/"``. """ result: protocol.ResultList = [] for path, lookup_future in context.browse(uri, recursive=False, lookup=True): if not lookup_future: result.append(("directory", path.lstrip("/"))) else: for tracks in lookup_future.get().values(): if tracks: result.extend( translator.track_to_mpd_format( tracks[0], context.session.tagtypes ) ) if uri in (None, "", "/"): result.extend( # We know that `listplaylists`` returns this specific variant of # `protocol.Result``, but this information disappears because of the # typing of the `protocol.commands.add()`` decorator. cast( "protocol.ResultList", stored_playlists.listplaylists(context), ) ) return result @protocol.commands.add("rescan") def rescan(context: MpdContext, uri: str | None = None) -> protocol.Result: """ *musicpd.org, music database section:* ``rescan [URI]`` Same as ``update``, but also rescans unmodified files. """ return {"updating_db": 0} # TODO @protocol.commands.add("search") def search(context: MpdContext, *args: str) -> protocol.Result: """ *musicpd.org, music database section:* ``search {TYPE} {WHAT} [...] [window START:END]`` Searches for any song that contains ``WHAT``. Parameters have the same meaning as for ``find``, except that search is not case sensitive. *GMPC:* - uses the undocumented field ``any``. - searches for multiple words like this:: search any "foo" any "bar" any "baz" *ncmpc:* - capitalizes the field argument. *ncmpcpp:* - also uses the search type "date". - uses "file" instead of "filename". """ params, window = _split_window(args) try: query = _query_for_search(params) except ValueError: return None results = context.core.library.search(query).get() artists = [ track for a in _get_artists(results) if (track := _artist_as_track(a)) if not None ] albums = [ track for a in _get_albums(results) if (track := _album_as_track(a)) is not None ] tracks = _get_tracks(results) return translator.tracks_to_mpd_format( (artists + albums + tracks)[window], context.session.tagtypes ) @protocol.commands.add("searchadd") def searchadd(context: MpdContext, *args: str) -> None: """ *musicpd.org, music database section:* ``searchadd {TYPE} {WHAT} [...]`` Searches for any song that contains ``WHAT`` in tag ``TYPE`` and adds them to current playlist. Parameters have the same meaning as for ``find``, except that search is not case sensitive. """ try: query = _query_for_search(args) except ValueError: return results = context.core.library.search(query).get() uris = [track.uri for track in _get_tracks(results) if track.uri is not None] context.core.tracklist.add(uris=uris).get() @protocol.commands.add("searchaddpl") def searchaddpl(context: MpdContext, *args: str) -> None: """ *musicpd.org, music database section:* ``searchaddpl {NAME} {TYPE} {WHAT} [...]`` Searches for any song that contains ``WHAT`` in tag ``TYPE`` and adds them to the playlist named ``NAME``. If a playlist by that name doesn't exist it is created. Parameters have the same meaning as for ``find``, except that search is not case sensitive. """ parameters = list(args) if not parameters: raise exceptions.MpdArgError("incorrect arguments") playlist_name = parameters.pop(0) try: query = _query_for_search(parameters) except ValueError: return uri = context.uri_map.playlist_uri_from_name(playlist_name) if uri: playlist = context.core.playlists.lookup(uri).get() else: playlist = context.core.playlists.create(playlist_name).get() if not playlist: return # TODO: Raise error about failed playlist creation? results = context.core.library.search(query).get() tracks = list(playlist.tracks) + _get_tracks(results) playlist = playlist.replace(tracks=tracks) context.core.playlists.save(playlist) @protocol.commands.add("update") def update(context: MpdContext, uri: Uri | None = None) -> protocol.Result: """ *musicpd.org, music database section:* ``update [URI]`` Updates the music database: find new files, remove deleted files, update modified files. ``URI`` is a particular directory or song/file to update. If you do not specify it, everything is updated. Prints ``updating_db: JOBID`` where ``JOBID`` is a positive number identifying the update job. You can read the current job id in the ``status`` response. """ return {"updating_db": 0} # TODO # TODO: add at least reflection tests before adding NotImplemented version # @protocol.commands.add('readcomments') def readcomments(context: MpdContext, uri: Uri) -> None: """ *musicpd.org, music database section:* ``readcomments [URI]`` Read "comments" (i.e. key-value pairs) from the file specified by "URI". This "URI" can be a path relative to the music directory or a URL in the form "file:///foo/bar.ogg". This command may be used to list metadata of remote files (e.g. URI beginning with "http://" or "smb://"). The response consists of lines in the form "KEY: VALUE". Comments with suspicious characters (e.g. newlines) are ignored silently. The meaning of these depends on the codec, and not all decoder plugins support it. For example, on Ogg files, this lists the Vorbis comments. """ ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1793654065.9639196 mopidy_mpd-4.1.0/src/mopidy_mpd/protocol/playback.py0000644000175100017510000003702715272176462022301 0ustar00runnerrunnerfrom __future__ import annotations from typing import TYPE_CHECKING, Never from mopidy.types import DurationMs, Percentage, PlaybackState from mopidy_mpd import exceptions, protocol if TYPE_CHECKING: from mopidy_mpd.context import MpdContext @protocol.commands.add("consume", state=protocol.BOOL) def consume(context: MpdContext, state: bool) -> None: # noqa: FBT001 """ *musicpd.org, playback section:* ``consume {STATE}`` Sets consume state to ``STATE``, ``STATE`` should be 0 or 1. When consume is activated, each song played is removed from playlist. """ context.core.tracklist.set_consume(state) @protocol.commands.add("crossfade", seconds=protocol.UINT) def crossfade(context: MpdContext, seconds: int) -> Never: """ *musicpd.org, playback section:* ``crossfade {SECONDS}`` Sets crossfading between songs. """ raise exceptions.MpdNotImplementedError # TODO @protocol.commands.add("mixrampdb") def mixrampdb(context: MpdContext, decibels: str) -> Never: """ *musicpd.org, playback section:* ``mixrampdb {deciBels}`` Sets the threshold at which songs will be overlapped. Like crossfading but doesn't fade the track volume, just overlaps. The songs need to have MixRamp tags added by an external tool. 0dB is the normalized maximum volume so use negative values, I prefer -17dB. In the absence of mixramp tags crossfading will be used. See https://sourceforge.net/projects/mixramp/ """ raise exceptions.MpdNotImplementedError # TODO @protocol.commands.add("mixrampdelay", seconds=protocol.UINT) def mixrampdelay(context: MpdContext, seconds: int) -> Never: """ *musicpd.org, playback section:* ``mixrampdelay {SECONDS}`` Additional time subtracted from the overlap calculated by mixrampdb. A value of "nan" disables MixRamp overlapping and falls back to crossfading. """ raise exceptions.MpdNotImplementedError # TODO @protocol.commands.add("next") def next_(context: MpdContext) -> None: """ *musicpd.org, playback section:* ``next`` Plays next song in the playlist. *MPD's behaviour when affected by repeat/random/single/consume:* Given a playlist of three tracks numbered 1, 2, 3, and a currently playing track ``c``. ``next_track`` is defined at the track that will be played upon calls to ``next``. Tests performed on MPD 0.15.4-1ubuntu3. ====== ====== ====== ======= ===== ===== ===== ===== Inputs next_track ------------------------------- ------------------- ----- repeat random single consume c = 1 c = 2 c = 3 Notes ====== ====== ====== ======= ===== ===== ===== ===== T T T T 2 3 EOPL T T T . Rand Rand Rand [1] T T . T Rand Rand Rand [4] T T . . Rand Rand Rand [4] T . T T 2 3 EOPL T . T . 2 3 1 T . . T 3 3 EOPL T . . . 2 3 1 . T T T Rand Rand Rand [3] . T T . Rand Rand Rand [3] . T . T Rand Rand Rand [2] . T . . Rand Rand Rand [2] . . T T 2 3 EOPL . . T . 2 3 EOPL . . . T 2 3 EOPL . . . . 2 3 EOPL ====== ====== ====== ======= ===== ===== ===== ===== - When end of playlist (EOPL) is reached, the current track is unset. - [1] When *random* and *single* is combined, ``next`` selects a track randomly at each invocation, and not just the next track in an internal prerandomized playlist. - [2] When *random* is active, ``next`` will skip through all tracks in the playlist in random order, and finally EOPL is reached. - [3] *single* has no effect in combination with *random* alone, or *random* and *consume*. - [4] When *random* and *repeat* is active, EOPL is never reached, but the playlist is played again, in the same random order as the first time. """ context.core.playback.next().get() @protocol.commands.add("pause", state=protocol.BOOL) def pause(context: MpdContext, state: bool | None = None) -> None: # noqa: FBT001 """ *musicpd.org, playback section:* ``pause {PAUSE}`` Toggles pause/resumes playing, ``PAUSE`` is 0 or 1. *MPDroid:* - Calls ``pause`` without any arguments to toogle pause. """ match state: case None: # Deprecated: Calling `pause` without any arguments playback_state = context.core.playback.get_state().get() if playback_state == PlaybackState.PLAYING: context.core.playback.pause().get() elif playback_state == PlaybackState.PAUSED: context.core.playback.resume().get() case True: context.core.playback.pause().get() case False: context.core.playback.resume().get() @protocol.commands.add("play", songpos=protocol.INT) def play(context: MpdContext, songpos: int | None = None) -> None: """ *musicpd.org, playback section:* ``play [SONGPOS]`` Begins playing the playlist at song number ``SONGPOS``. The original MPD server resumes from the paused state on ``play`` without arguments. *Clarifications:* - ``play "-1"`` when playing is ignored. - ``play "-1"`` when paused resumes playback. - ``play "-1"`` when stopped with a current track starts playback at the current track. - ``play "-1"`` when stopped without a current track, e.g. after playlist replacement, starts playback at the first track. *BitMPC:* - issues ``play 6`` without quotes around the argument. """ if songpos is None: context.core.playback.play().get() return if songpos == -1: _play_minus_one(context) return try: tl_track = context.core.tracklist.slice(songpos, songpos + 1).get()[0] context.core.playback.play(tlid=tl_track.tlid).get() except IndexError as exc: raise exceptions.MpdArgError("Bad song index") from exc def _play_minus_one(context: MpdContext) -> None: match context.core.playback.get_state().get(): case PlaybackState.PLAYING: pass # Nothing to do case PlaybackState.PAUSED: context.core.playback.resume().get() case PlaybackState.STOPPED: current_tlid = context.core.playback.get_current_tlid().get() if current_tlid is not None: context.core.playback.play(tlid=current_tlid).get() return tl_tracks = context.core.tracklist.slice(0, 1).get() if tl_tracks: context.core.playback.play(tlid=tl_tracks[0].tlid).get() return # No current track, empty tracklist: nothing to do @protocol.commands.add("playid", tlid=protocol.INT) def playid(context: MpdContext, tlid: int) -> None: """ *musicpd.org, playback section:* ``playid [SONGID]`` Begins playing the playlist at song ``SONGID``. *Clarifications:* - ``playid "-1"`` when playing is ignored. - ``playid "-1"`` when paused resumes playback. - ``playid "-1"`` when stopped with a current track starts playback at the current track. - ``playid "-1"`` when stopped without a current track, e.g. after playlist replacement, starts playback at the first track. """ if tlid == -1: _play_minus_one(context) return tl_tracks = context.core.tracklist.filter({"tlid": [tlid]}).get() if not tl_tracks: raise exceptions.MpdNoExistError("No such song") context.core.playback.play(tlid=tl_tracks[0].tlid).get() @protocol.commands.add("previous") def previous(context: MpdContext) -> None: """ *musicpd.org, playback section:* ``previous`` Plays previous song in the playlist. *MPD's behaviour when affected by repeat/random/single/consume:* Given a playlist of three tracks numbered 1, 2, 3, and a currently playing track ``c``. ``previous_track`` is defined at the track that will be played upon ``previous`` calls. Tests performed on MPD 0.15.4-1ubuntu3. ====== ====== ====== ======= ===== ===== ===== Inputs previous_track ------------------------------- ------------------- repeat random single consume c = 1 c = 2 c = 3 ====== ====== ====== ======= ===== ===== ===== T T T T Rand? Rand? Rand? T T T . 3 1 2 T T . T Rand? Rand? Rand? T T . . 3 1 2 T . T T 3 1 2 T . T . 3 1 2 T . . T 3 1 2 T . . . 3 1 2 . T T T c c c . T T . c c c . T . T c c c . T . . c c c . . T T 1 1 2 . . T . 1 1 2 . . . T 1 1 2 . . . . 1 1 2 ====== ====== ====== ======= ===== ===== ===== - If :attr:`time_position` of the current track is 15s or more, ``previous`` should do a seek to time position 0. """ context.core.playback.previous().get() @protocol.commands.add("random", state=protocol.BOOL) def random(context: MpdContext, state: bool) -> None: # noqa: FBT001 """ *musicpd.org, playback section:* ``random {STATE}`` Sets random state to ``STATE``, ``STATE`` should be 0 or 1. """ context.core.tracklist.set_random(state) @protocol.commands.add("repeat", state=protocol.BOOL) def repeat(context: MpdContext, state: bool) -> None: # noqa: FBT001 """ *musicpd.org, playback section:* ``repeat {STATE}`` Sets repeat state to ``STATE``, ``STATE`` should be 0 or 1. """ context.core.tracklist.set_repeat(state) @protocol.commands.add("replay_gain_mode") def replay_gain_mode(context: MpdContext, mode: str) -> Never: """ *musicpd.org, playback section:* ``replay_gain_mode {MODE}`` Sets the replay gain mode. One of ``off``, ``track``, ``album``. Changing the mode during playback may take several seconds, because the new settings does not affect the buffered data. This command triggers the options idle event. """ raise exceptions.MpdNotImplementedError # TODO @protocol.commands.add("replay_gain_status") def replay_gain_status(context: MpdContext) -> protocol.Result: """ *musicpd.org, playback section:* ``replay_gain_status`` Prints replay gain options. Currently, only the variable ``replay_gain_mode`` is returned. """ return ("replay_gain_mode", "off") # TODO @protocol.commands.add("seek", songpos=protocol.UINT, seconds=protocol.UFLOAT) def seek(context: MpdContext, songpos: int, seconds: float) -> None: """ *musicpd.org, playback section:* ``seek {SONGPOS} {TIME}`` Seeks to the position ``TIME`` (in seconds) of entry ``SONGPOS`` in the playlist. *Droid MPD:* - issues ``seek 1 120`` without quotes around the arguments. """ tl_track = context.core.playback.get_current_tl_track().get() if context.core.tracklist.index(tl_track).get() != songpos: play(context, songpos=songpos) context.core.playback.seek(DurationMs(int(seconds * 1000))).get() @protocol.commands.add("seekid", tlid=protocol.UINT, seconds=protocol.UFLOAT) def seekid(context: MpdContext, tlid: int, seconds: float) -> None: """ *musicpd.org, playback section:* ``seekid {SONGID} {TIME}`` Seeks to the position ``TIME`` (in seconds) of song ``SONGID``. """ tl_track = context.core.playback.get_current_tl_track().get() if not tl_track or tl_track.tlid != tlid: playid(context, tlid=tlid) context.core.playback.seek(DurationMs(int(seconds * 1000))).get() @protocol.commands.add("seekcur") def seekcur(context: MpdContext, time: str) -> None: """ *musicpd.org, playback section:* ``seekcur {TIME}`` Seeks to the position ``TIME`` within the current song. If prefixed by '+' or '-', then the time is relative to the current playing position. """ if time.startswith(("+", "-")): position = context.core.playback.get_time_position().get() position = DurationMs(position + int(protocol.FLOAT(time) * 1000)) context.core.playback.seek(position).get() else: position = DurationMs(int(protocol.UFLOAT(time) * 1000)) context.core.playback.seek(position).get() @protocol.commands.add("setvol", volume=protocol.INT) def setvol(context: MpdContext, volume: int) -> None: """ *musicpd.org, playback section:* ``setvol {VOL}`` Sets volume to ``VOL``, the range of volume is 0-100. *Droid MPD:* - issues ``setvol 50`` without quotes around the argument. """ # NOTE: we use INT as clients can pass in +N etc. value = Percentage(min(max(0, volume), 100)) success = context.core.mixer.set_volume(value).get() if not success: raise exceptions.MpdSystemError("problems setting volume") @protocol.commands.add("single", state=protocol.BOOL) def single(context: MpdContext, state: bool) -> None: # noqa: FBT001 """ *musicpd.org, playback section:* ``single {STATE}`` Sets single state to ``STATE``, ``STATE`` should be 0 or 1. When single is activated, playback is stopped after current song, or song is repeated if the ``repeat`` mode is enabled. """ context.core.tracklist.set_single(state) @protocol.commands.add("stop") def stop(context: MpdContext) -> None: """ *musicpd.org, playback section:* ``stop`` Stops playing. """ context.core.playback.stop() @protocol.commands.add("volume", change=protocol.INT) def volume(context: MpdContext, change: int) -> None: """ *musicpd.org, playback section:* ``volume {CHANGE}`` Changes volume by amount ``CHANGE``. Note: ``volume`` is deprecated, use ``setvol`` instead. """ min_volume_change = -100 max_volume_change = 100 min_volume = 0 max_volume = 100 if change < min_volume_change or change > max_volume_change: raise exceptions.MpdArgError("Invalid volume value") old_volume = context.core.mixer.get_volume().get() if old_volume is None: raise exceptions.MpdSystemError("problems setting volume") new_volume = Percentage(min(max(min_volume, old_volume + change), max_volume)) success = context.core.mixer.set_volume(new_volume).get() if not success: raise exceptions.MpdSystemError("problems setting volume") ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1793654065.9639196 mopidy_mpd-4.1.0/src/mopidy_mpd/protocol/reflection.py0000644000175100017510000000561315272176462022641 0ustar00runnerrunnerfrom __future__ import annotations from typing import TYPE_CHECKING, Never from mopidy_mpd import exceptions, protocol if TYPE_CHECKING: from mopidy_mpd.context import MpdContext @protocol.commands.add("config", list_command=False) def config(context: MpdContext) -> Never: """ *musicpd.org, reflection section:* ``config`` Dumps configuration values that may be interesting for the client. This command is only permitted to "local" clients (connected via UNIX domain socket). """ raise exceptions.MpdPermissionError(command="config") @protocol.commands.add("commands", auth_required=False) def commands(context: MpdContext) -> protocol.Result: """ *musicpd.org, reflection section:* ``commands`` Shows which commands the current user has access to. """ command_names = set() for name, handler in protocol.commands.handlers.items(): if not handler.is_listed(context): continue if context.dispatcher.authenticated or not handler.auth_required: command_names.add(name) return [("command", command_name) for command_name in sorted(command_names)] @protocol.commands.add("decoders") def decoders(context: MpdContext) -> None: """ *musicpd.org, reflection section:* ``decoders`` Print a list of decoder plugins, followed by their supported suffixes and MIME types. Example response:: plugin: mad suffix: mp3 suffix: mp2 mime_type: audio/mpeg plugin: mpcdec suffix: mpc *Clarifications:* - ncmpcpp asks for decoders the first time you open the browse view. By returning nothing and OK instead of an not implemented error, we avoid "Not implemented" showing up in the ncmpcpp interface, and we get the list of playlists without having to enter the browse interface twice. """ return # TODO @protocol.commands.add("notcommands", auth_required=False) def notcommands(context: MpdContext) -> protocol.Result: """ *musicpd.org, reflection section:* ``notcommands`` Shows which commands the current user does not have access to. """ command_names = {"config", "kill"} # No permission to use for name, handler in protocol.commands.handlers.items(): if not handler.is_listed(context): continue if not context.dispatcher.authenticated and handler.auth_required: command_names.add(name) return [("command", command_name) for command_name in sorted(command_names)] @protocol.commands.add("urlhandlers") def urlhandlers(context: MpdContext) -> protocol.Result: """ *musicpd.org, reflection section:* ``urlhandlers`` Gets a list of available URL handlers. """ return [ ("handler", uri_scheme) for uri_scheme in context.core.get_uri_schemes().get() ] ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1793654065.9639196 mopidy_mpd-4.1.0/src/mopidy_mpd/protocol/status.py0000644000175100017510000002365215272176462022035 0ustar00runnerrunnerfrom __future__ import annotations from typing import TYPE_CHECKING from mopidy.types import PlaybackState from mopidy_mpd import exceptions, protocol, translator if TYPE_CHECKING: from mopidy.models import Track from mopidy.types import DurationMs from mopidy_mpd.context import MpdContext #: Subsystems that can be registered with idle command. SUBSYSTEMS = [ "database", "mixer", "options", "output", "player", "playlist", "stored_playlist", "update", ] @protocol.commands.add("clearerror") def clearerror(context: MpdContext) -> protocol.Result: """ *musicpd.org, status section:* ``clearerror`` Clears the current error message in status (this is also accomplished by any command that starts playback). """ raise exceptions.MpdNotImplementedError # TODO @protocol.commands.add("currentsong") def currentsong(context: MpdContext) -> protocol.Result: """ *musicpd.org, status section:* ``currentsong`` Displays the song info of the current song (same song that is identified in status). """ tl_track = context.core.playback.get_current_tl_track().get() stream_title = context.core.playback.get_stream_title().get() if tl_track is not None: position = context.core.tracklist.index(tl_track).get() return translator.track_to_mpd_format( tl_track, position=position, stream_title=stream_title, tagtypes=context.session.tagtypes, ) return None @protocol.commands.add("idle") def idle(context: MpdContext, *args: str) -> protocol.Result: """ *musicpd.org, status section:* ``idle [SUBSYSTEMS...]`` Waits until there is a noteworthy change in one or more of MPD's subsystems. As soon as there is one, it lists all changed systems in a line in the format ``changed: SUBSYSTEM``, where ``SUBSYSTEM`` is one of the following: - ``database``: the song database has been modified after update. - ``update``: a database update has started or finished. If the database was modified during the update, the database event is also emitted. - ``stored_playlist``: a stored playlist has been modified, renamed, created or deleted - ``playlist``: the current playlist has been modified - ``player``: the player has been started, stopped or seeked - ``mixer``: the volume has been changed - ``output``: an audio output has been enabled or disabled - ``options``: options like repeat, random, crossfade, replay gain While a client is waiting for idle results, the server disables timeouts, allowing a client to wait for events as long as MPD runs. The idle command can be canceled by sending the command ``noidle`` (no other commands are allowed). MPD will then leave idle mode and print results immediately; might be empty at this time. If the optional ``SUBSYSTEMS`` argument is used, MPD will only send notifications when something changed in one of the specified subsystems. """ # TODO: test against valid subsystems subsystems = list(args) if args else SUBSYSTEMS for subsystem in subsystems: context.dispatcher.subsystem_subscriptions.add(subsystem) active = context.dispatcher.subsystem_subscriptions.intersection( context.dispatcher.subsystem_events ) if not active: context.session.prevent_timeout = True return None response: protocol.ResultList = [("changed", subsystem) for subsystem in active] context.dispatcher.subsystem_events = set() context.dispatcher.subsystem_subscriptions = set() return response @protocol.commands.add("noidle", list_command=False) def noidle(context: MpdContext) -> None: """See :meth:`_status_idle`.""" if not context.dispatcher.subsystem_subscriptions: return context.dispatcher.subsystem_subscriptions = set() context.dispatcher.subsystem_events = set() context.session.prevent_timeout = False @protocol.commands.add("stats") def stats(context: MpdContext) -> protocol.Result: """ *musicpd.org, status section:* ``stats`` Displays statistics. - ``artists``: number of artists - ``songs``: number of albums - ``uptime``: daemon uptime in seconds - ``db_playtime``: sum of all song times in the db - ``db_update``: last db update in UNIX time - ``playtime``: time length of music played """ return { "artists": 0, # TODO "albums": 0, # TODO "songs": 0, # TODO "uptime": 0, # TODO "db_playtime": 0, # TODO "db_update": 0, # TODO "playtime": 0, # TODO } @protocol.commands.add("status") def status(context: MpdContext) -> protocol.Result: """ *musicpd.org, status section:* ``status`` Reports the current status of the player and the volume level. - ``volume``: 0-100 or -1 - ``repeat``: 0 or 1 - ``single``: 0 or 1 - ``consume``: 0 or 1 - ``playlist``: 31-bit unsigned integer, the playlist version number - ``playlistlength``: integer, the length of the playlist - ``state``: play, stop, or pause - ``song``: playlist song number of the current song stopped on or playing - ``songid``: playlist songid of the current song stopped on or playing - ``nextsong``: playlist song number of the next song to be played - ``nextsongid``: playlist songid of the next song to be played - ``time``: total time elapsed (of current playing/paused song) - ``duration``: total duration of the track in seconds (with milliseconds as fractional component) - ``elapsed``: Total time elapsed within the current song, but with higher resolution. - ``bitrate``: instantaneous bitrate in kbps - ``xfade``: crossfade in seconds - ``audio``: sampleRate``:bits``:channels - ``updatings_db``: job id - ``error``: if there is an error, returns message here *Clarifications based on experience implementing* - ``volume``: can also be -1 if no output is set. - ``elapsed``: Higher resolution means time in seconds with three decimal places for millisecond precision. """ # Fire these off first, as other futures depends on them f_current_tl_track = context.core.playback.get_current_tl_track() f_next_tlid = context.core.tracklist.get_next_tlid() # ...and wait for them to complete current_tl_track = f_current_tl_track.get() current_tlid = current_tl_track.tlid if current_tl_track else None current_track = current_tl_track.track if current_tl_track else None next_tlid = f_next_tlid.get() # Then fire off the rest... f_current_index = context.core.tracklist.index(tlid=current_tlid) f_mixer_volume = context.core.mixer.get_volume() f_next_index = context.core.tracklist.index(tlid=next_tlid) f_playback_state = context.core.playback.get_state() f_playback_time_position = context.core.playback.get_time_position() f_tracklist_consume = context.core.tracklist.get_consume() f_tracklist_length = context.core.tracklist.get_length() f_tracklist_random = context.core.tracklist.get_random() f_tracklist_repeat = context.core.tracklist.get_repeat() f_tracklist_single = context.core.tracklist.get_single() f_tracklist_version = context.core.tracklist.get_version() # ...and wait for them to complete current_index = f_current_index.get() mixer_volume = f_mixer_volume.get() next_index = f_next_index.get() playback_state = f_playback_state.get() playback_time_position = f_playback_time_position.get() tracklist_consume = f_tracklist_consume.get() tracklist_length = f_tracklist_length.get() tracklist_random = f_tracklist_random.get() tracklist_repeat = f_tracklist_repeat.get() tracklist_single = f_tracklist_single.get() tracklist_version = f_tracklist_version.get() result = [ ("volume", mixer_volume if mixer_volume is not None else -1), ("repeat", int(tracklist_repeat)), ("random", int(tracklist_random)), ("single", int(tracklist_single)), ("consume", int(tracklist_consume)), ("playlist", tracklist_version), ("playlistlength", tracklist_length), ("xfade", 0), # Not supported ("state", _status_state(playback_state)), ] if current_tlid is not None and current_index is not None: result.append(("song", current_index)) result.append(("songid", current_tlid)) if next_tlid is not None and next_index is not None: result.append(("nextsong", next_index)) result.append(("nextsongid", next_tlid)) if ( playback_state in (PlaybackState.PLAYING, PlaybackState.PAUSED) and current_track is not None ): result.append(("time", _status_time(playback_time_position, current_track))) if current_track.length is not None: result.append(("duration", f"{current_track.length / 1000:.3f}")) result.append(("elapsed", _status_time_elapsed(playback_time_position))) result.append(("bitrate", current_track.bitrate or 0)) return result def _status_state(playback_state: PlaybackState) -> str: match playback_state: case PlaybackState.PLAYING: return "play" case PlaybackState.STOPPED: return "stop" case PlaybackState.PAUSED: return "pause" def _status_time(playback_time_position: DurationMs, current_track: Track) -> str: position = playback_time_position // 1000 total = (current_track.length or 0) // 1000 return f"{position:d}:{total:d}" def _status_time_elapsed(playback_time_position: DurationMs) -> str: elapsed = playback_time_position / 1000.0 return f"{elapsed:.3f}" ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1793654065.9639196 mopidy_mpd-4.1.0/src/mopidy_mpd/protocol/stickers.py0000644000175100017510000000274015272176462022334 0ustar00runnerrunnerfrom __future__ import annotations from typing import TYPE_CHECKING, Never from mopidy_mpd import exceptions, protocol if TYPE_CHECKING: from mopidy.types import Uri from mopidy_mpd.context import MpdContext @protocol.commands.add("sticker", list_command=False) def sticker( # noqa: PLR0913, PLR0917 context: MpdContext, action: str, field: str, uri: Uri, name: str | None = None, value: str | None = None, ) -> Never: """ *musicpd.org, sticker section:* ``sticker list {TYPE} {URI}`` Lists the stickers for the specified object. ``sticker find {TYPE} {URI} {NAME}`` Searches the sticker database for stickers with the specified name, below the specified directory (``URI``). For each matching song, it prints the ``URI`` and that one sticker's value. ``sticker get {TYPE} {URI} {NAME}`` Reads a sticker value for the specified object. ``sticker set {TYPE} {URI} {NAME} {VALUE}`` Adds a sticker value to the specified object. If a sticker item with that name already exists, it is replaced. ``sticker delete {TYPE} {URI} [NAME]`` Deletes a sticker value from the specified object. If you do not specify a sticker name, all sticker values are deleted. """ # TODO: check that action in ('list', 'find', 'get', 'set', 'delete') # TODO: check name/value matches with action raise exceptions.MpdNotImplementedError # TODO ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1793654065.9639196 mopidy_mpd-4.1.0/src/mopidy_mpd/protocol/stored_playlists.py0000644000175100017510000003507515272176462024120 0ustar00runnerrunnerfrom __future__ import annotations import datetime as dt import logging import re from typing import TYPE_CHECKING, Literal, cast, overload from urllib.parse import urlparse from mopidy.types import Uri, UriScheme from mopidy_mpd import exceptions, protocol, translator if TYPE_CHECKING: from collections.abc import Iterable from mopidy.models import Playlist, Track from mopidy_mpd.context import MpdContext logger = logging.getLogger(__name__) def _playlists_available(context: MpdContext) -> bool: """MPD protocol hides the playlist commands when no playlists are configured. In Mopidy, that's equivalent to having no configured URI schemes.""" return bool(context.core.playlists.get_uri_schemes().get()) def _check_playlist_name(name: str) -> None: if re.search("[/\n\r]", name): raise exceptions.MpdInvalidPlaylistNameError @overload def _get_playlist( context: MpdContext, name: str, *, must_exist: Literal[True] ) -> Playlist: ... @overload def _get_playlist( context: MpdContext, name: str, *, must_exist: Literal[False] ) -> Playlist | None: ... def _get_playlist( context: MpdContext, name: str, *, must_exist: bool ) -> Playlist | None: playlist = None uri = context.uri_map.playlist_uri_from_name(name) if uri: playlist = context.core.playlists.lookup(uri).get() if must_exist and playlist is None: raise exceptions.MpdNoExistError("No such playlist") return playlist @protocol.commands.add("listplaylist") def listplaylist(context: MpdContext, name: str) -> protocol.Result: """ *musicpd.org, stored playlists section:* ``listplaylist {NAME}`` Lists the files in the playlist ``NAME.m3u``. Output format:: file: relative/path/to/file1.flac file: relative/path/to/file2.ogg file: relative/path/to/file3.mp3 """ playlist = _get_playlist(context, name, must_exist=True) return [("file", track.uri) for track in playlist.tracks if track.uri is not None] @protocol.commands.add("listplaylistinfo") def listplaylistinfo(context: MpdContext, name: str) -> protocol.Result: """ *musicpd.org, stored playlists section:* ``listplaylistinfo {NAME}`` Lists songs in the playlist ``NAME.m3u``. Output format: Standard track listing, with fields: file, Time, Title, Date, Album, Artist, Track """ playlist = _get_playlist(context, name, must_exist=True) track_uris = [track.uri for track in playlist.tracks if track.uri is not None] tracks_map = context.core.library.lookup(uris=track_uris).get() tracks = [] for uri in track_uris: tracks.extend(tracks_map[uri]) playlist = playlist.replace(tracks=tracks) return translator.playlist_to_mpd_format(playlist, context.session.tagtypes) @protocol.commands.add("listplaylists", list_command=_playlists_available) def listplaylists(context: MpdContext) -> protocol.ResultList: """ *musicpd.org, stored playlists section:* ``listplaylists`` Prints a list of the playlist directory. After each playlist name the server sends its last modification time as attribute ``Last-Modified`` in ISO 8601 format. To avoid problems due to clock differences between clients and the server, clients should not compare this value with their local clock. Output format:: playlist: a Last-Modified: 2010-02-06T02:10:25Z playlist: b Last-Modified: 2010-02-06T02:11:08Z *Clarifications:* - ncmpcpp 0.5.10 segfaults if we return 'playlist: ' on a line, so we must ignore playlists without names, which isn't very useful anyway. """ last_modified = _get_last_modified() result: protocol.ResultList = [] for playlist_ref in context.core.playlists.as_list().get(): if not playlist_ref.name: continue name = context.uri_map.playlist_name_from_uri(playlist_ref.uri) if name is None: continue result.append(("playlist", name)) result.append(("Last-Modified", last_modified)) return result # TODO: move to translators? def _get_last_modified(last_modified: int | None = None) -> str: """Formats last modified timestamp of a playlist for MPD. Time in UTC with second precision, formatted in the ISO 8601 format, with the "Z" time zone marker for UTC. For example, "1970-01-01T00:00:00Z". """ if last_modified is None: # If unknown, assume the playlist is modified modified = dt.datetime.now(tz=dt.UTC) else: modified = dt.datetime.fromtimestamp(last_modified / 1000.0, tz=dt.UTC) return modified.isoformat(timespec="seconds").replace("+00:00", "Z") DEFAULT_PLAYLIST_SLICE = slice(0, None) @protocol.commands.add("load", playlist_slice=protocol.RANGE) def load( context: MpdContext, name: str, playlist_slice: slice = DEFAULT_PLAYLIST_SLICE, ) -> None: """ *musicpd.org, stored playlists section:* ``load {NAME} [START:END]`` Loads the playlist into the current queue. Playlist plugins are supported. A range may be specified to load only a part of the playlist. *Clarifications:* - ``load`` appends the given playlist to the current playlist. - MPD 0.17.1 does not support open-ended ranges, i.e. without end specified, for the ``load`` command, even though MPD's general range docs allows open-ended ranges. - MPD 0.17.1 does not fail if the specified range is outside the playlist, in either or both ends. """ playlist = _get_playlist(context, name, must_exist=True) tracks = cast( # TODO(type): Improve typing of models to avoid cast. "tuple[Track]", playlist.tracks[playlist_slice], # pyright: ignore[reportIndexIssue] ) track_uris = [track.uri for track in tracks if track.uri is not None] context.core.tracklist.add(uris=track_uris).get() @protocol.commands.add("playlistadd", list_command=_playlists_available) def playlistadd(context: MpdContext, name: str, track_uri: Uri) -> None: """ *musicpd.org, stored playlists section:* ``playlistadd {NAME} {URI}`` Adds ``URI`` to the playlist ``NAME.m3u``. ``NAME.m3u`` will be created if it does not exist. """ _check_playlist_name(name) old_playlist = _get_playlist(context, name, must_exist=False) if not old_playlist: # Create new playlist with this single track lookup_res = context.core.library.lookup(uris=[track_uri]).get() tracks = [track for uri_tracks in lookup_res.values() for track in uri_tracks] _create_playlist(context, name, tracks) else: # Add track to existing playlist lookup_res = context.core.library.lookup(uris=[track_uri]).get() new_tracks = [ track for uri_tracks in lookup_res.values() for track in uri_tracks ] new_playlist = old_playlist.replace( tracks=list(old_playlist.tracks) + new_tracks ) saved_playlist = context.core.playlists.save(new_playlist).get() if saved_playlist is None: playlist_scheme = UriScheme(str(urlparse(old_playlist.uri).scheme)) uri_scheme = UriScheme(urlparse(track_uri).scheme) raise exceptions.MpdInvalidTrackForPlaylistError( playlist_scheme, uri_scheme ) def _create_playlist(context: MpdContext, name: str, tracks: Iterable[Track]) -> None: """ Creates new playlist using backend appropriate for the given tracks """ uri_schemes = {UriScheme(str(urlparse(t.uri).scheme)) for t in tracks} for scheme in uri_schemes: new_playlist = context.core.playlists.create(name, scheme).get() if new_playlist is None: logger.debug("Backend for scheme %s can't create playlists", scheme) continue # Backend can't create playlists at all new_playlist = new_playlist.replace(tracks=tracks) saved_playlist = context.core.playlists.save(new_playlist).get() if saved_playlist is not None: return # Created and saved continue # Failed to save using this backend # Can't use backend appropriate for passed URI schemes, use default one default_scheme = context.config["mpd"]["default_playlist_scheme"] new_playlist = context.core.playlists.create(name, default_scheme).get() if new_playlist is None: # If even MPD's default backend can't save playlist, everything is lost logger.warning("MPD's default backend can't create playlists") raise exceptions.MpdFailedToSavePlaylistError(default_scheme) new_playlist = new_playlist.replace(tracks=tracks) saved_playlist = context.core.playlists.save(new_playlist).get() if saved_playlist is None: uri_scheme = UriScheme(str(urlparse(new_playlist.uri).scheme)) raise exceptions.MpdFailedToSavePlaylistError(uri_scheme) @protocol.commands.add("playlistclear", list_command=_playlists_available) def playlistclear(context: MpdContext, name: str) -> None: """ *musicpd.org, stored playlists section:* ``playlistclear {NAME}`` Clears the playlist ``NAME.m3u``. The playlist will be created if it does not exist. """ _check_playlist_name(name) playlist = _get_playlist(context, name, must_exist=False) if not playlist: playlist = context.core.playlists.create(name).get() # TODO(type): Handle the failure to create a playlist assert playlist # Just replace tracks with empty list and save playlist = playlist.replace(tracks=[]) if context.core.playlists.save(playlist).get() is None: raise exceptions.MpdFailedToSavePlaylistError( UriScheme(str(urlparse(playlist.uri).scheme)) ) @protocol.commands.add( "playlistdelete", list_command=_playlists_available, songpos=protocol.UINT ) def playlistdelete(context: MpdContext, name: str, songpos: int) -> None: """ *musicpd.org, stored playlists section:* ``playlistdelete {NAME} {SONGPOS}`` Deletes ``SONGPOS`` from the playlist ``NAME.m3u``. """ _check_playlist_name(name) playlist = _get_playlist(context, name, must_exist=True) try: # Convert tracks to list and remove requested tracks = list(playlist.tracks) tracks.pop(songpos) except IndexError as exc: raise exceptions.MpdArgError("Bad song index") from exc # Replace tracks and save playlist playlist = playlist.replace(tracks=tracks) saved_playlist = context.core.playlists.save(playlist).get() if saved_playlist is None: raise exceptions.MpdFailedToSavePlaylistError( UriScheme(str(urlparse(playlist.uri).scheme)) ) @protocol.commands.add( "playlistmove", list_command=_playlists_available, from_pos=protocol.UINT, to_pos=protocol.UINT, ) def playlistmove(context: MpdContext, name: str, from_pos: int, to_pos: int) -> None: """ *musicpd.org, stored playlists section:* ``playlistmove {NAME} {SONGID} {SONGPOS}`` Moves ``SONGID`` in the playlist ``NAME.m3u`` to the position ``SONGPOS``. *Clarifications:* - The second argument is not a ``SONGID`` as used elsewhere in the protocol documentation, but just the ``SONGPOS`` to move *from*, i.e. ``playlistmove {NAME} {FROM_SONGPOS} {TO_SONGPOS}``. """ if from_pos == to_pos: return _check_playlist_name(name) playlist = _get_playlist(context, name, must_exist=True) if from_pos == to_pos: return # Nothing to do try: # Convert tracks to list and perform move tracks = list(playlist.tracks) track = tracks.pop(from_pos) tracks.insert(to_pos, track) except IndexError as exc: raise exceptions.MpdArgError("Bad song index") from exc # Replace tracks and save playlist playlist = playlist.replace(tracks=tracks) saved_playlist = context.core.playlists.save(playlist).get() if saved_playlist is None: raise exceptions.MpdFailedToSavePlaylistError( UriScheme(str(urlparse(playlist.uri).scheme)) ) @protocol.commands.add("rename", list_command=_playlists_available) def rename(context: MpdContext, old_name: str, new_name: str) -> None: """ *musicpd.org, stored playlists section:* ``rename {NAME} {NEW_NAME}`` Renames the playlist ``NAME.m3u`` to ``NEW_NAME.m3u``. """ _check_playlist_name(old_name) _check_playlist_name(new_name) old_playlist = _get_playlist(context, old_name, must_exist=True) assert old_playlist.uri if _get_playlist(context, new_name, must_exist=False): raise exceptions.MpdExistError("Playlist already exists") # TODO: should we purge the mapping in an else? # Create copy of the playlist and remove original uri_scheme = UriScheme(str(urlparse(old_playlist.uri).scheme)) empty_playlist = context.core.playlists.create(new_name, uri_scheme).get() if empty_playlist is None: raise exceptions.MpdFailedToSavePlaylistError(uri_scheme) filled_playlist = empty_playlist.replace(tracks=old_playlist.tracks) saved_playlist = context.core.playlists.save(filled_playlist).get() if saved_playlist is None: raise exceptions.MpdFailedToSavePlaylistError(uri_scheme) context.core.playlists.delete(old_playlist.uri).get() @protocol.commands.add("rm", list_command=_playlists_available) def rm(context: MpdContext, name: str) -> None: """ *musicpd.org, stored playlists section:* ``rm {NAME}`` Removes the playlist ``NAME.m3u`` from the playlist directory. """ _check_playlist_name(name) uri = context.uri_map.playlist_uri_from_name(name) if not uri: raise exceptions.MpdNoExistError("No such playlist") context.core.playlists.delete(uri).get() @protocol.commands.add("save", list_command=_playlists_available) def save(context: MpdContext, name: str) -> None: """ *musicpd.org, stored playlists section:* ``save {NAME}`` Saves the current playlist to ``NAME.m3u`` in the playlist directory. """ _check_playlist_name(name) tracks = context.core.tracklist.get_tracks().get() playlist = _get_playlist(context, name, must_exist=False) if not playlist: # Create new playlist _create_playlist(context, name, tracks) else: # Overwrite existing playlist new_playlist = playlist.replace(tracks=tracks) saved_playlist = context.core.playlists.save(new_playlist).get() if saved_playlist is None: raise exceptions.MpdFailedToSavePlaylistError( UriScheme(str(urlparse(playlist.uri).scheme)) ) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1793654065.9639196 mopidy_mpd-4.1.0/src/mopidy_mpd/protocol/tagtype_list.py0000644000175100017510000000055215272176462023214 0ustar00runnerrunnerTAGTYPE_LIST = { "Artist", "ArtistSort", "Album", "AlbumArtist", "AlbumArtistSort", "Title", "Track", "Name", "Genre", "Date", "Composer", "Performer", "Comment", "Disc", "MUSICBRAINZ_ARTISTID", "MUSICBRAINZ_ALBUMID", "MUSICBRAINZ_ALBUMARTISTID", "MUSICBRAINZ_TRACKID", "X-AlbumUri", } ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1793654065.9639196 mopidy_mpd-4.1.0/src/mopidy_mpd/session.py0000644000175100017510000000613315272176462020327 0ustar00runnerrunnerfrom __future__ import annotations import logging from typing import TYPE_CHECKING, Never, TypedDict from rich.markup import escape from mopidy_mpd import dispatcher, formatting, network, protocol, types from mopidy_mpd.protocol import tagtype_list if TYPE_CHECKING: from mopidy.core import CoreProxy from mopidy_mpd.uri_mapper import MpdUriMapper logger = logging.getLogger(__name__) class MpdSessionKwargs(TypedDict): config: types.Config core: CoreProxy uri_map: MpdUriMapper connection: network.Connection class MpdSession(network.LineProtocol): """ The MPD client session. Keeps track of a single client session. Any requests from the client is passed on to the MPD request dispatcher. """ terminator = protocol.LINE_TERMINATOR encoding = protocol.ENCODING # TODO: Nothing uses binarylimit today as we do not send binary responses. # However clients such as rmpc expect this to exist, and we can use this in # the future. binary_limit: int = 8192 """Max size for binary responses. Set via the `binarylimit` command.""" def __init__( self, *, config: types.Config, core: CoreProxy, uri_map: MpdUriMapper, connection: network.Connection, ) -> None: super().__init__(connection) self.dispatcher = dispatcher.MpdDispatcher( config=config, core=core, uri_map=uri_map, session=self, ) self.tagtypes = tagtype_list.TAGTYPE_LIST.copy() def update_binary_limit(self, binary_limit: int) -> None: logger.debug("Binary limit set to %s", binary_limit) self.binary_limit = binary_limit def on_start(self) -> None: logger.info("New MPD connection from %s", self.connection) self.send_lines([f"OK MPD {protocol.VERSION}"]) def on_line_received(self, line: str) -> None: logger.debug("Request from %s: %s", self.connection, escape(line)) # All mpd commands start with a lowercase alphabetic character # To prevent CSRF attacks, requests starting with an invalid # character are immediately dropped. if len(line) == 0 or not (line[0].islower() and line[0].isalpha()): self.connection.stop("Malformed command") return response = self.dispatcher.handle_request(line) if not response: return logger.debug( "Response to %s: %s", self.connection, escape(formatting.indent(self.decode(self.terminator).join(response))), ) self.send_lines(response) def on_event(self, subsystem: str) -> None: self.dispatcher.handle_idle(subsystem) def decode(self, line: bytes) -> str: try: return super().decode(line) except ValueError: logger.warning( "Stopping actor due to unescaping error, data " "supplied by client was not valid." ) self.stop() return Never # pyright: ignore[reportReturnType] def close(self) -> None: self.stop() ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1793654065.9639196 mopidy_mpd-4.1.0/src/mopidy_mpd/tokenize.py0000644000175100017510000000626715272176462020504 0ustar00runnerrunnerimport re from mopidy_mpd import exceptions WORD_RE = re.compile( r""" ^ (\s*) # Leading whitespace not allowed, capture it to report. ([a-z][a-z0-9_]*) # A command name (?:\s+|$) # trailing whitespace or EOS (.*) # Possibly a remainder to be parsed """, re.VERBOSE, ) # Quotes matching is an unrolled version of "(?:[^"\\]|\\.)*" PARAM_RE = re.compile( r""" ^ # Leading whitespace is not allowed (?: ([^{unprintable}"']+) # ord(char) < 0x20, not ", not ' | # or "([^"\\]*(?:\\.[^"\\]*)*)" # anything surrounded by quotes ) (?:\s+|$) # trailing whitespace or EOS (.*) # Possibly a remainder to be parsed """.format(unprintable="".join(map(chr, range(0x21)))), re.VERBOSE, ) BAD_QUOTED_PARAM_RE = re.compile( r""" ^ "[^"\\]*(?:\\.[^"\\]*)* # start of a quoted value (?: # followed by: ("[^\s]) # non-escaped quote, followed by non-whitespace | # or ([^"]) # anything that is not a quote ) """, re.VERBOSE, ) UNESCAPE_RE = re.compile(r"\\(.)") # Backslash escapes any following char. def split(line: str) -> list[str]: """Splits a line into tokens using same rules as MPD. - Lines may not start with whitespace - Tokens are split by arbitrary amount of spaces or tabs - First token must match `[a-z][a-z0-9_]*` - Remaining tokens can be unquoted or quoted tokens. - Unquoted tokens consist of all printable characters except double quotes, single quotes, spaces and tabs. - Quoted tokens are surrounded by a matching pair of double quotes. - The closing quote must be followed by space, tab or end of line. - Any value is allowed inside a quoted token. Including double quotes, assuming it is correctly escaped. - Backslash inside a quoted token is used to escape the following character. For examples see the tests for this function. """ if not line.strip(): raise exceptions.MpdNoCommandError("No command given") match = WORD_RE.match(line) if not match: raise exceptions.MpdUnknownError("Invalid word character") whitespace, command, remainder = match.groups() if whitespace: raise exceptions.MpdUnknownError("Letter expected") result: list[str] = [command] while remainder: match = PARAM_RE.match(remainder) if not match: msg = _determine_error_message(remainder) raise exceptions.MpdArgError(msg, command=command) unquoted, quoted, remainder = match.groups() result.append(unquoted or UNESCAPE_RE.sub(r"\g<1>", quoted)) return result def _determine_error_message(remainder: str) -> str: """Helper to emulate MPD errors.""" # Following checks are simply to match MPD error messages: match = BAD_QUOTED_PARAM_RE.match(remainder) if not match: return "Invalid unquoted character" if match.group(1): return "Space expected after closing '\"'" return "Missing closing '\"'" ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1793654065.9639196 mopidy_mpd-4.1.0/src/mopidy_mpd/translator.py0000644000175100017510000001506115272176462021035 0ustar00runnerrunnerfrom __future__ import annotations import datetime as dt import logging from typing import TYPE_CHECKING from mopidy.models import Album, Artist, Playlist, TlTrack, Track from mopidy_mpd.protocol import tagtype_list if TYPE_CHECKING: from collections.abc import Iterable, Sequence from mopidy_mpd import protocol logger = logging.getLogger(__name__) def track_to_mpd_format( # noqa: C901, PLR0912, PLR0915 obj: Track | TlTrack, tagtypes: set[str], *, position: int | None = None, stream_title: str | None = None, ) -> protocol.ResultList: """ Format track for output to MPD client. :param obj: the track :param tagtypes: the MPD tagtypes enabled by the client :param position: track's position in playlist :param stream_title: the current streams title """ match obj: case TlTrack() as tl_track: tlid = tl_track.tlid track = tl_track.track case Track() as track: tlid = None if not track.uri: logger.warning("Ignoring track without uri") return [] result: list[protocol.ResultTuple] = [ ("file", track.uri), ("Time", (track.length and (track.length // 1000)) or 0), *multi_tag_list(track.artists, "name", "Artist"), ("Album", (track.album and track.album.name) or ""), ] if track.length is not None: result.append(("duration", f"{track.length / 1000:.3f}")) if stream_title is not None: result.append(("Title", stream_title)) if track.name: result.append(("Name", track.name)) else: result.append(("Title", track.name or "")) if track.date: result.append(("Date", track.date)) if track.track_no: result.append(("Track", track.track_no)) if position is not None and tlid is not None: result.append(("Pos", position)) result.append(("Id", tlid)) if track.album is not None and track.album.musicbrainz_id is not None: result.append(("MUSICBRAINZ_ALBUMID", str(track.album.musicbrainz_id))) if track.album is not None and track.album.artists: result += multi_tag_list(track.album.artists, "name", "AlbumArtist") musicbrainz_ids = concat_multi_values(track.album.artists, "musicbrainz_id") if musicbrainz_ids: result.append(("MUSICBRAINZ_ALBUMARTISTID", musicbrainz_ids)) if track.artists: musicbrainz_ids = concat_multi_values(track.artists, "musicbrainz_id") if musicbrainz_ids: result.append(("MUSICBRAINZ_ARTISTID", musicbrainz_ids)) if track.composers: result += multi_tag_list(track.composers, "name", "Composer") if track.performers: result += multi_tag_list(track.performers, "name", "Performer") if track.genre: result.append(("Genre", track.genre)) if track.disc_no: result.append(("Disc", track.disc_no)) if track.last_modified: datestring = dt.datetime.fromtimestamp( track.last_modified // 1000, tz=dt.UTC ).isoformat(timespec="seconds") result.append(("Last-Modified", datestring.replace("+00:00", "Z"))) if track.musicbrainz_id is not None: result.append(("MUSICBRAINZ_TRACKID", str(track.musicbrainz_id))) if track.album and track.album.uri: result.append(("X-AlbumUri", track.album.uri)) return [ (tagtype, value) for (tagtype, value) in result if _has_value(tagtypes, tagtype, value) ] def _has_value( tagtypes: set[str], tagtype: str, value: protocol.ResultValue, ) -> bool: """ Determine whether to add the tagtype to the output or not. The tagtype must be in the list of tagtypes configured for the client. :param tagtypes: the MPD tagtypes enabled by the client :param tagtype: the MPD tagtype :param value: the tag value """ if tagtype in tagtype_list.TAGTYPE_LIST: if tagtype not in tagtypes: return False return bool(value) return True def concat_multi_values( models: Iterable[Artist | Album | Track], attribute: str, ) -> str: """ Format Mopidy model values for output to MPD client. :param models: the models :param attribute: the attribute to use """ # Don't sort the values. MPD doesn't appear to (or if it does it's not # strict alphabetical). If we just use them in the order in which they come # in then the musicbrainz ids have a higher chance of staying in sync return ";".join( str(value) for m in models if (value := getattr(m, attribute, None)) is not None ) def multi_tag_list( models: Iterable[Artist | Album | Track], attribute: str, tag: str, ) -> list[protocol.ResultTuple]: """ Format multiple objects for output to MPD client in a list with one tag per value. :param models: the model objects :param attribute: the attribute to use :param tag: the name of the tag """ return [ (tag, value if isinstance(value, int) else str(value)) for obj in models if (value := getattr(obj, attribute, None)) is not None ] def tracks_to_mpd_format( tracks: Sequence[Track | TlTrack], tagtypes: set[str], *, start: int = 0, end: int | None = None, ) -> protocol.ResultList: """ Format list of tracks for output to MPD client. Optionally limit output to the slice ``[start:end]`` of the list. :param tracks: the tracks :param tagtypes: the MPD tagtypes enabled by the client :param start: position of first track to include in output :param end: position after last track to include in output, or ``None`` for end of list """ if end is None: end = len(tracks) tracks = tracks[start:end] positions = range(start, end) assert len(tracks) == len(positions) result: protocol.ResultList = [] for track, position in zip(tracks, positions, strict=True): formatted_track = track_to_mpd_format(track, tagtypes, position=position) if formatted_track: result.extend(formatted_track) return result def playlist_to_mpd_format( playlist: Playlist, tagtypes: set[str], *, start: int = 0, end: int | None = None, ) -> protocol.ResultList: """ Format playlist for output to MPD client. :param playlist: the playlist :param tagtypes: the MPD tagtypes enabled by the client :param start: position of first track to include in output :param end: position after last track to include in output """ return tracks_to_mpd_format(list(playlist.tracks), tagtypes, start=start, end=end) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1793654065.9639196 mopidy_mpd-4.1.0/src/mopidy_mpd/types.py0000644000175100017510000000100115272176462017775 0ustar00runnerrunnerfrom __future__ import annotations from typing import TYPE_CHECKING, TypedDict from mopidy.config import Config as MopidyConfig if TYPE_CHECKING: from mopidy.types import UriScheme class Config(MopidyConfig): mpd: MpdConfig class MpdConfig(TypedDict): hostname: str port: int password: str | None max_connections: int connection_timeout: int zeroconf: str command_blacklist: list[str] default_playlist_scheme: UriScheme type SocketAddress = tuple[str, int | None] ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1793654065.9639196 mopidy_mpd-4.1.0/src/mopidy_mpd/uri_mapper.py0000644000175100017510000000552415272176462021012 0ustar00runnerrunnerfrom __future__ import annotations import re from typing import TYPE_CHECKING if TYPE_CHECKING: from mopidy.core import CoreProxy from mopidy.types import Uri class MpdUriMapper: """ Maintains the mappings between uniquified MPD names and URIs. """ # TODO: refactor this into a generic mapper that does not know about browse # or playlists and then use one instance for each case? #: The Mopidy core API. core: CoreProxy _invalid_browse_chars = re.compile(r"[\n\r]") _invalid_playlist_chars = re.compile(r"[/]") def __init__(self, core: CoreProxy) -> None: self.core = core self._uri_from_name: dict[str, Uri | None] = {} self._browse_name_from_uri: dict[Uri | None, str] = {} self._playlist_name_from_uri: dict[Uri | None, str] = {} def _create_unique_name(self, name: str, uri: Uri | None) -> str: stripped_name = self._invalid_browse_chars.sub(" ", name) name = stripped_name i = 2 while name in self._uri_from_name: if self._uri_from_name[name] == uri: return name name = f"{stripped_name} [{i:d}]" i += 1 return name def insert(self, name: str, uri: Uri | None, *, playlist: bool = False) -> str: """ Create a unique and MPD compatible name that maps to the given URI. """ name = self._create_unique_name(name, uri) self._uri_from_name[name] = uri if playlist: self._playlist_name_from_uri[uri] = name else: self._browse_name_from_uri[uri] = name return name def uri_from_name(self, name: str) -> Uri | None: """ Return the URI for the given MPD name. """ return self._uri_from_name.get(name) def refresh_playlists_mapping(self) -> None: """ Maintain map between playlists and unique playlist names to be used by MPD. """ if self.core is None: return for playlist_ref in self.core.playlists.as_list().get(): if not playlist_ref.name: continue name = self._invalid_playlist_chars.sub("|", playlist_ref.name) self.insert(name, playlist_ref.uri, playlist=True) def playlist_uri_from_name(self, name: str) -> Uri | None: """ Helper function to retrieve a playlist URI from its unique MPD name. """ if name not in self._uri_from_name: self.refresh_playlists_mapping() return self._uri_from_name.get(name) def playlist_name_from_uri(self, uri: Uri) -> str: """ Helper function to retrieve the unique MPD playlist name from its URI. """ if uri not in self._playlist_name_from_uri: self.refresh_playlists_mapping() return self._playlist_name_from_uri[uri] ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1793654069.6796694 mopidy_mpd-4.1.0/src/mopidy_mpd.egg-info/0000755000175100017510000000000015272176466017765 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1793654069.6630995 mopidy_mpd-4.1.0/src/mopidy_mpd.egg-info/PKG-INFO0000644000175100017510000001735215272176466021072 0ustar00runnerrunnerMetadata-Version: 2.4 Name: mopidy-mpd Version: 4.1.0 Summary: Mopidy extension for controlling Mopidy from MPD clients Author-email: Stein Magnus Jodal License: Apache-2.0 Project-URL: Homepage, https://github.com/mopidy/mopidy-mpd 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.2 Requires-Dist: pygobject>=3.50 Requires-Dist: pykka>=4.1 Requires-Dist: rich>=13.9 Dynamic: license-file # mopidy-mpd [![Latest PyPI version](https://img.shields.io/pypi/v/mopidy-mpd)](https://pypi.org/p/mopidy-mpd) [![CI build status](https://img.shields.io/github/actions/workflow/status/mopidy/mopidy-mpd/ci.yml)](https://github.com/mopidy/mopidy-mpd/actions/workflows/ci.yml) [![Test coverage](https://img.shields.io/codecov/c/gh/mopidy/mopidy-mpd)](https://codecov.io/gh/mopidy/mopidy-mpd) [Mopidy](https://mopidy.com/) extension for controlling Mopidy from MPD clients. MPD stands for Music Player Daemon, which is also the name of the [original MPD server project](https://www.musicpd.org/). Mopidy does not depend on the original MPD server, but implements the MPD protocol itself, and is thus compatible with most clients built for the original MPD server. ## Maintainer wanted Mopidy-MPD 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-MPD, 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-mpd ``` See https://mopidy.com/ext/mpd/ for alternative installation methods. ## Configuration Before starting Mopidy, you must add configuration for mopidy-mpd to your Mopidy configuration file: ```ini [mpd] hostname = :: ``` > [!WARNING] > As a simple security measure, the MPD server is by default only available from > localhost. To make it available from other computers, change the > `mpd/hostname` config value. > > Before you do so, note that the MPD server does not support any form of > encryption and only a single clear text password (see `mpd/password`) for weak > authentication. Anyone able to access the MPD server can control music > playback on your computer. Thus, you probably only want to make the MPD server > available from your local network. You have been warned. The following configuration values are available: - `mpd/enabled`: If the MPD extension should be enabled or not. - `mpd/hostname`: Which address the MPD server should bind to. This can be a network address or the path toa Unix socket: - `127.0.0.1`: Listens only on the IPv4 loopback interface (default). - `::1`: Listens only on the IPv6 loopback interface. - `0.0.0.0`: Listens on all IPv4 interfaces. - `::`: Listens on all interfaces, both IPv4 and IPv6. - `unix:/path/to/unix/socket.sock`: Listen on the Unix socket at the specified path. Must be prefixed with `unix:`. - `mpd/port`: Which TCP port the MPD server should listen to. Default: 6600. - `mpd/password`: The password required for connecting to the MPD server. If blank, no password is required. Default: blank. - `mpd/max_connections`: The maximum number of concurrent connections the MPD server will accept. Default: 20. - `mpd/connection_timeout`: Number of seconds an MPD client can stay inactive before the connection is closed by the server. Default: 60. - `mpd/zeroconf`: Name of the MPD service when published through Zeroconf. The variables `$hostname` and `$port` can be used in the name. Set to an empty string to disable Zeroconf for MPD. Default: `Mopidy MPD server on $hostname` - `mpd/command_blacklist`: List of MPD commands which are disabled by the server. By default this blacklists `listall` and `listallinfo`. These commands don't fit well with many of Mopidy's backends and are better left disabled unless you know what you are doing. - `mpd/default_playlist_scheme`: The URI scheme used if the server cannot find a backend appropriate for creating a playlist from the given tracks. Default: `m3u` ## Limitations This is a non-exhaustive list of MPD features that Mopidy doesn't support. - Only a single password is supported. It gives all-or-nothing access. - Toggling of audio outputs is not supported. - Channels for client-to-client communication are not supported. - Stickers are not supported. - Crossfade is not supported. - Replay gain is not supported. - `stats` does not provide any statistics. - `decoders` does not provide information about available decoders. - Live update of the music database is not supported. ## Clients Over the years, a huge number of MPD clients have been built for every thinkable platform. As always, the quality and state of maintenance varies between clients, so you might have to try a couple before you find one you like for your purpose. In general, they should all work with Mopidy-MPD. The [Wikipedia article on MPD](https://en.wikipedia.org/wiki/Music_Player_Daemon#Clients) has a short list of well-known clients. In the MPD wiki there is a [more complete list](https://mpd.fandom.com/wiki/Clients) of the available MPD clients. Both lists are grouped by user interface, e.g. terminal, graphical, or web-based ## Project resources - [Source code](https://github.com/mopidy/mopidy-mpd) - [Issues](https://github.com/mopidy/mopidy-mpd/issues) - [Releases](https://github.com/mopidy/mopidy-mpd/releases) ## Development ### Set up development environment Clone the repo using, e.g. using [gh](https://cli.github.com/): ```sh gh repo clone mopidy/mopidy-mpd ``` Enter the directory, and install dependencies using [uv](https://docs.astral.sh/uv/): ```sh cd mopidy-mpd/ 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 . ``` ### Making a release To make a release to PyPI, go to the project's [GitHub releases page](https://github.com/mopidy/mopidy-mpd/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-mpd/). ## Credits - Original author: [Stein Magnus Jodal](https://github.com/jodal) and [Thomas Adamcik](https://github.com/adamcik) for the Mopidy-MPD extension in Mopidy core. - Current maintainer: None. Maintainer wanted, see section above. - [Contributors](https://github.com/mopidy/mopidy-mpd/graphs/contributors) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1793654069.6732943 mopidy_mpd-4.1.0/src/mopidy_mpd.egg-info/SOURCES.txt0000644000175100017510000000446415272176466021661 0ustar00runnerrunner.copier-answers.yml .gitignore LICENSE README.md pyproject.toml .github/workflows/ci.yml .github/workflows/release.yml src/mopidy_mpd/__init__.py src/mopidy_mpd/actor.py src/mopidy_mpd/context.py src/mopidy_mpd/dispatcher.py src/mopidy_mpd/exceptions.py src/mopidy_mpd/ext.conf src/mopidy_mpd/formatting.py src/mopidy_mpd/network.py src/mopidy_mpd/session.py src/mopidy_mpd/tokenize.py src/mopidy_mpd/translator.py src/mopidy_mpd/types.py src/mopidy_mpd/uri_mapper.py src/mopidy_mpd.egg-info/PKG-INFO src/mopidy_mpd.egg-info/SOURCES.txt src/mopidy_mpd.egg-info/dependency_links.txt src/mopidy_mpd.egg-info/entry_points.txt src/mopidy_mpd.egg-info/requires.txt src/mopidy_mpd.egg-info/scm_file_list.json src/mopidy_mpd.egg-info/scm_version.json src/mopidy_mpd.egg-info/top_level.txt src/mopidy_mpd/protocol/__init__.py src/mopidy_mpd/protocol/audio_output.py src/mopidy_mpd/protocol/channels.py src/mopidy_mpd/protocol/command_list.py src/mopidy_mpd/protocol/connection.py src/mopidy_mpd/protocol/current_playlist.py src/mopidy_mpd/protocol/mount.py src/mopidy_mpd/protocol/music_db.py src/mopidy_mpd/protocol/playback.py src/mopidy_mpd/protocol/reflection.py src/mopidy_mpd/protocol/status.py src/mopidy_mpd/protocol/stickers.py src/mopidy_mpd/protocol/stored_playlists.py src/mopidy_mpd/protocol/tagtype_list.py tests/__init__.py tests/dummy_audio.py tests/dummy_backend.py tests/dummy_mixer.py tests/path_utils.py tests/test_actor.py tests/test_commands.py tests/test_context.py tests/test_dispatcher.py tests/test_exceptions.py tests/test_extension.py tests/test_path_utils.py tests/test_session.py tests/test_status.py tests/test_tokenizer.py tests/test_translator.py tests/network/__init__.py tests/network/test_connection.py tests/network/test_lineprotocol.py tests/network/test_server.py tests/network/test_utils.py tests/protocol/__init__.py tests/protocol/test_audio_output.py tests/protocol/test_authentication.py tests/protocol/test_channels.py tests/protocol/test_command_list.py tests/protocol/test_connection.py tests/protocol/test_current_playlist.py tests/protocol/test_idle.py tests/protocol/test_mount.py tests/protocol/test_music_db.py tests/protocol/test_playback.py tests/protocol/test_reflection.py tests/protocol/test_regression.py tests/protocol/test_status.py tests/protocol/test_stickers.py tests/protocol/test_stored_playlists.py././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1793654069.6633122 mopidy_mpd-4.1.0/src/mopidy_mpd.egg-info/dependency_links.txt0000644000175100017510000000000115272176466024033 0ustar00runnerrunner ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1793654069.6635773 mopidy_mpd-4.1.0/src/mopidy_mpd.egg-info/entry_points.txt0000644000175100017510000000005015272176466023256 0ustar00runnerrunner[mopidy.ext] mpd = mopidy_mpd:Extension ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1793654069.6637805 mopidy_mpd-4.1.0/src/mopidy_mpd.egg-info/requires.txt0000644000175100017510000000006415272176466022365 0ustar00runnerrunnermopidy>=4.0.2 pygobject>=3.50 pykka>=4.1 rich>=13.9 ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1793654069.6618795 mopidy_mpd-4.1.0/src/mopidy_mpd.egg-info/scm_file_list.json0000644000175100017510000000500015272176466023467 0ustar00runnerrunner{ "files": [ ".copier-answers.yml", ".github/workflows/ci.yml", ".github/workflows/release.yml", ".gitignore", "LICENSE", "README.md", "pyproject.toml", "src/mopidy_mpd/__init__.py", "src/mopidy_mpd/actor.py", "src/mopidy_mpd/context.py", "src/mopidy_mpd/dispatcher.py", "src/mopidy_mpd/exceptions.py", "src/mopidy_mpd/ext.conf", "src/mopidy_mpd/formatting.py", "src/mopidy_mpd/network.py", "src/mopidy_mpd/protocol/__init__.py", "src/mopidy_mpd/protocol/audio_output.py", "src/mopidy_mpd/protocol/channels.py", "src/mopidy_mpd/protocol/command_list.py", "src/mopidy_mpd/protocol/connection.py", "src/mopidy_mpd/protocol/current_playlist.py", "src/mopidy_mpd/protocol/mount.py", "src/mopidy_mpd/protocol/music_db.py", "src/mopidy_mpd/protocol/playback.py", "src/mopidy_mpd/protocol/reflection.py", "src/mopidy_mpd/protocol/status.py", "src/mopidy_mpd/protocol/stickers.py", "src/mopidy_mpd/protocol/stored_playlists.py", "src/mopidy_mpd/protocol/tagtype_list.py", "src/mopidy_mpd/session.py", "src/mopidy_mpd/tokenize.py", "src/mopidy_mpd/translator.py", "src/mopidy_mpd/types.py", "src/mopidy_mpd/uri_mapper.py", "tests/__init__.py", "tests/dummy_audio.py", "tests/dummy_backend.py", "tests/dummy_mixer.py", "tests/network/__init__.py", "tests/network/test_connection.py", "tests/network/test_lineprotocol.py", "tests/network/test_server.py", "tests/network/test_utils.py", "tests/path_utils.py", "tests/protocol/__init__.py", "tests/protocol/test_audio_output.py", "tests/protocol/test_authentication.py", "tests/protocol/test_channels.py", "tests/protocol/test_command_list.py", "tests/protocol/test_connection.py", "tests/protocol/test_current_playlist.py", "tests/protocol/test_idle.py", "tests/protocol/test_mount.py", "tests/protocol/test_music_db.py", "tests/protocol/test_playback.py", "tests/protocol/test_reflection.py", "tests/protocol/test_regression.py", "tests/protocol/test_status.py", "tests/protocol/test_stickers.py", "tests/protocol/test_stored_playlists.py", "tests/test_actor.py", "tests/test_commands.py", "tests/test_context.py", "tests/test_dispatcher.py", "tests/test_exceptions.py", "tests/test_extension.py", "tests/test_path_utils.py", "tests/test_session.py", "tests/test_status.py", "tests/test_tokenizer.py", "tests/test_translator.py" ] } ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1793654069.6548495 mopidy_mpd-4.1.0/src/mopidy_mpd.egg-info/scm_version.json0000644000175100017510000000024015272176466023203 0ustar00runnerrunner{ "tag": "4.1.0", "distance": 0, "node": "gf6122370063c986d9ed261df9c37ea173d01d338", "dirty": false, "branch": "HEAD", "node_date": "2026-11-02" } ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1793654069.6639032 mopidy_mpd-4.1.0/src/mopidy_mpd.egg-info/top_level.txt0000644000175100017510000000001315272176466022511 0ustar00runnerrunnermopidy_mpd ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1793654069.6829581 mopidy_mpd-4.1.0/tests/0000755000175100017510000000000015272176466014505 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1793654065.9639196 mopidy_mpd-4.1.0/tests/__init__.py0000644000175100017510000000071415272176462016614 0ustar00runnerrunnerclass IsA: # noqa: PLW1641 def __init__(self, klass): self.klass = klass def __eq__(self, rhs): try: return isinstance(rhs, self.klass) except TypeError: return type(rhs) == type(self.klass) # noqa: E721 def __ne__(self, rhs): return not self.__eq__(rhs) def __repr__(self): return str(self.klass) any_int = IsA(int) any_str = IsA(str) any_unicode = any_str # TODO remove ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1793654065.964977 mopidy_mpd-4.1.0/tests/dummy_audio.py0000644000175100017510000000640715272176462017376 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_playback_failure(self, uri): self._bad_uris.add(uri) def trigger_fake_tags_changed(self, tags): self._tags.update(tags) audio.AudioListener.send("tags_changed", tags=self._tags.keys()) ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1793654065.964977 mopidy_mpd-4.1.0/tests/dummy_backend.py0000644000175100017510000001024315272176462017655 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_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 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): 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 ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1793654065.964977 mopidy_mpd-4.1.0/tests/dummy_mixer.py0000644000175100017510000000121415272176462017410 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 ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1793654069.6834893 mopidy_mpd-4.1.0/tests/network/0000755000175100017510000000000015272176466016176 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1793654065.964977 mopidy_mpd-4.1.0/tests/network/__init__.py0000644000175100017510000000000015272176462020271 0ustar00runnerrunner././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1793654065.965161 mopidy_mpd-4.1.0/tests/network/test_connection.py0000644000175100017510000005411515272176462021750 0ustar00runnerrunnerimport errno import logging import socket import unittest from unittest.mock import Mock, call, patch, sentinel import pykka from gi.repository import GLib from mopidy_mpd import network, uri_mapper from tests import any_int, any_unicode class ConnectionTest(unittest.TestCase): def setUp(self): self.mock = Mock(spec=network.Connection) def test_init_ensure_nonblocking_io(self): sock = Mock(spec=socket.SocketType) network.Connection.__init__( self.mock, config={}, core=Mock(), uri_map=Mock(spec=uri_mapper.MpdUriMapper), protocol=Mock(spec=network.LineProtocol), sock=sock, addr=(sentinel.host, sentinel.port), timeout=sentinel.timeout, ) sock.setblocking.assert_called_once_with(False) def test_init_starts_actor(self): protocol = Mock(spec=network.LineProtocol) network.Connection.__init__( self.mock, config={}, core=Mock(), uri_map=Mock(spec=uri_mapper.MpdUriMapper), protocol=protocol, sock=Mock(spec=socket.SocketType), addr=(sentinel.host, sentinel.port), timeout=sentinel.timeout, ) protocol.start.assert_called_once() def test_init_enables_recv_and_timeout(self): network.Connection.__init__( self.mock, config={}, core=Mock(), uri_map=Mock(spec=uri_mapper.MpdUriMapper), protocol=Mock(spec=network.LineProtocol), sock=Mock(spec=socket.SocketType), addr=(sentinel.host, sentinel.port), timeout=sentinel.timeout, ) self.mock.enable_recv.assert_called_once_with() self.mock.enable_timeout.assert_called_once_with() def test_init_stores_values_in_attributes(self): addr = (sentinel.host, sentinel.port) protocol = Mock(spec=network.LineProtocol) sock = Mock(spec=socket.SocketType) network.Connection.__init__( self.mock, config={}, core=Mock(), uri_map=Mock(spec=uri_mapper.MpdUriMapper), protocol=protocol, sock=sock, addr=addr, timeout=sentinel.timeout, ) assert sock == self.mock._sock assert protocol == self.mock.protocol assert sentinel.timeout == self.mock.timeout assert sentinel.host == self.mock.host assert sentinel.port == self.mock.port def test_init_handles_ipv6_addr(self): addr = ( sentinel.host, sentinel.port, sentinel.flowinfo, sentinel.scopeid, ) protocol = Mock(spec=network.LineProtocol) sock = Mock(spec=socket.SocketType) network.Connection.__init__( self.mock, config={}, core=Mock(), uri_map=Mock(spec=uri_mapper.MpdUriMapper), protocol=protocol, sock=sock, addr=addr, timeout=sentinel.timeout, ) assert sentinel.host == self.mock.host assert sentinel.port == self.mock.port def test_stop_disables_recv_send_and_timeout(self): self.mock.stopping = False self.mock.actor_ref = Mock() self.mock._sock = Mock(spec=socket.SocketType) network.Connection.stop(self.mock, sentinel.reason) self.mock.disable_timeout.assert_called_once_with() self.mock.disable_recv.assert_called_once_with() self.mock.disable_send.assert_called_once_with() def test_stop_closes_socket(self): self.mock.stopping = False self.mock.actor_ref = Mock() self.mock._sock = Mock(spec=socket.SocketType) network.Connection.stop(self.mock, sentinel.reason) self.mock._sock.close.assert_called_once_with() def test_stop_closes_socket_error(self): self.mock.stopping = False self.mock.actor_ref = Mock() self.mock._sock = Mock(spec=socket.SocketType) self.mock._sock.close.side_effect = socket.error network.Connection.stop(self.mock, sentinel.reason) self.mock._sock.close.assert_called_once_with() def test_stop_stops_actor(self): self.mock.stopping = False self.mock.actor_ref = Mock() self.mock._sock = Mock(spec=socket.SocketType) network.Connection.stop(self.mock, sentinel.reason) self.mock.actor_ref.stop.assert_called_once_with(block=False) def test_stop_handles_actor_already_being_stopped(self): self.mock.stopping = False self.mock.actor_ref = Mock() self.mock.actor_ref.stop.side_effect = pykka.ActorDeadError() self.mock._sock = Mock(spec=socket.SocketType) network.Connection.stop(self.mock, sentinel.reason) self.mock.actor_ref.stop.assert_called_once_with(block=False) def test_stop_sets_stopping_to_true(self): self.mock.stopping = False self.mock.actor_ref = Mock() self.mock._sock = Mock(spec=socket.SocketType) network.Connection.stop(self.mock, sentinel.reason) assert self.mock.stopping is True def test_stop_does_not_proceed_when_already_stopping(self): self.mock.stopping = True self.mock.actor_ref = Mock() self.mock._sock = Mock(spec=socket.SocketType) network.Connection.stop(self.mock, sentinel.reason) assert self.mock.actor_ref.stop.call_count == 0 assert self.mock._sock.close.call_count == 0 @patch.object(network.logger, "log", new=Mock()) def test_stop_logs_reason(self): self.mock.stopping = False self.mock.actor_ref = Mock() self.mock._sock = Mock(spec=socket.SocketType) network.Connection.stop(self.mock, sentinel.reason) network.logger.log.assert_called_once_with(logging.DEBUG, sentinel.reason) @patch.object(network.logger, "log", new=Mock()) def test_stop_logs_reason_with_level(self): self.mock.stopping = False self.mock.actor_ref = Mock() self.mock._sock = Mock(spec=socket.SocketType) network.Connection.stop(self.mock, sentinel.reason, level=sentinel.level) network.logger.log.assert_called_once_with(sentinel.level, sentinel.reason) @patch.object(network.logger, "log", new=Mock()) def test_stop_logs_that_it_is_calling_itself(self): self.mock.stopping = True self.mock.actor_ref = Mock() self.mock._sock = Mock(spec=socket.SocketType) network.Connection.stop(self.mock, sentinel.reason) network.logger.log(any_int, any_unicode) @patch.object(GLib, "io_add_watch", new=Mock()) def test_enable_recv_registers_with_glib(self): self.mock.recv_id = None self.mock._sock = Mock(spec=socket.SocketType) self.mock._sock.fileno.return_value = sentinel.fileno GLib.io_add_watch.return_value = sentinel.tag network.Connection.enable_recv(self.mock) GLib.io_add_watch.assert_called_once_with( sentinel.fileno, GLib.IO_IN | GLib.IO_ERR | GLib.IO_HUP, self.mock.recv_callback, ) assert sentinel.tag == self.mock.recv_id @patch.object(GLib, "io_add_watch", new=Mock()) def test_enable_recv_already_registered(self): self.mock._sock = Mock(spec=socket.SocketType) self.mock.recv_id = sentinel.tag network.Connection.enable_recv(self.mock) assert GLib.io_add_watch.call_count == 0 def test_enable_recv_does_not_change_tag(self): self.mock.recv_id = sentinel.tag self.mock._sock = Mock(spec=socket.SocketType) network.Connection.enable_recv(self.mock) assert sentinel.tag == self.mock.recv_id @patch.object(GLib, "source_remove", new=Mock()) def test_disable_recv_deregisters(self): self.mock.recv_id = sentinel.tag network.Connection.disable_recv(self.mock) GLib.source_remove.assert_called_once_with(sentinel.tag) assert self.mock.recv_id is None @patch.object(GLib, "source_remove", new=Mock()) def test_disable_recv_already_deregistered(self): self.mock.recv_id = None network.Connection.disable_recv(self.mock) assert GLib.source_remove.call_count == 0 assert self.mock.recv_id is None def test_enable_recv_on_closed_socket(self): self.mock.recv_id = None self.mock._sock = Mock(spec=socket.SocketType) self.mock._sock.fileno.side_effect = OSError(errno.EBADF, "") network.Connection.enable_recv(self.mock) self.mock.stop.assert_called_once_with(any_unicode) assert self.mock.recv_id is None @patch.object(GLib, "io_add_watch", new=Mock()) def test_enable_send_registers_with_glib(self): self.mock.send_id = None self.mock._sock = Mock(spec=socket.SocketType) self.mock._sock.fileno.return_value = sentinel.fileno GLib.io_add_watch.return_value = sentinel.tag network.Connection.enable_send(self.mock) GLib.io_add_watch.assert_called_once_with( sentinel.fileno, GLib.IO_OUT | GLib.IO_ERR | GLib.IO_HUP, self.mock.send_callback, ) assert sentinel.tag == self.mock.send_id @patch.object(GLib, "io_add_watch", new=Mock()) def test_enable_send_already_registered(self): self.mock._sock = Mock(spec=socket.SocketType) self.mock.send_id = sentinel.tag network.Connection.enable_send(self.mock) assert GLib.io_add_watch.call_count == 0 def test_enable_send_does_not_change_tag(self): self.mock.send_id = sentinel.tag self.mock._sock = Mock(spec=socket.SocketType) network.Connection.enable_send(self.mock) assert sentinel.tag == self.mock.send_id @patch.object(GLib, "source_remove", new=Mock()) def test_disable_send_deregisters(self): self.mock.send_id = sentinel.tag network.Connection.disable_send(self.mock) GLib.source_remove.assert_called_once_with(sentinel.tag) assert self.mock.send_id is None @patch.object(GLib, "source_remove", new=Mock()) def test_disable_send_already_deregistered(self): self.mock.send_id = None network.Connection.disable_send(self.mock) assert GLib.source_remove.call_count == 0 assert self.mock.send_id is None def test_enable_send_on_closed_socket(self): self.mock.send_id = None self.mock._sock = Mock(spec=socket.SocketType) self.mock._sock.fileno.side_effect = OSError(errno.EBADF, "") network.Connection.enable_send(self.mock) assert self.mock.send_id is None @patch.object(GLib, "timeout_add_seconds", new=Mock()) def test_enable_timeout_clears_existing_timeouts(self): self.mock.timeout = 10 network.Connection.enable_timeout(self.mock) self.mock.disable_timeout.assert_called_once_with() @patch.object(GLib, "timeout_add_seconds", new=Mock()) def test_enable_timeout_add_glib_timeout(self): self.mock.timeout = 10 GLib.timeout_add_seconds.return_value = sentinel.tag network.Connection.enable_timeout(self.mock) GLib.timeout_add_seconds.assert_called_once_with(10, self.mock.timeout_callback) assert sentinel.tag == self.mock.timeout_id @patch.object(GLib, "timeout_add_seconds", new=Mock()) def test_enable_timeout_does_not_add_timeout(self): self.mock.timeout = 0 network.Connection.enable_timeout(self.mock) assert GLib.timeout_add_seconds.call_count == 0 self.mock.timeout = -1 network.Connection.enable_timeout(self.mock) assert GLib.timeout_add_seconds.call_count == 0 def test_enable_timeout_does_not_call_disable_for_invalid_timeout(self): self.mock.timeout = 0 network.Connection.enable_timeout(self.mock) assert self.mock.disable_timeout.call_count == 0 self.mock.timeout = -1 network.Connection.enable_timeout(self.mock) assert self.mock.disable_timeout.call_count == 0 @patch.object(GLib, "source_remove", new=Mock()) def test_disable_timeout_deregisters(self): self.mock.timeout_id = sentinel.tag network.Connection.disable_timeout(self.mock) GLib.source_remove.assert_called_once_with(sentinel.tag) assert self.mock.timeout_id is None @patch.object(GLib, "source_remove", new=Mock()) def test_disable_timeout_already_deregistered(self): self.mock.timeout_id = None network.Connection.disable_timeout(self.mock) assert GLib.source_remove.call_count == 0 assert self.mock.timeout_id is None def test_queue_send_acquires_and_releases_lock(self): self.mock.send_lock = Mock() self.mock.send_buffer = b"" network.Connection.queue_send(self.mock, b"data") self.mock.send_lock.acquire.assert_called_once_with(blocking=True) self.mock.send_lock.release.assert_called_once_with() def test_queue_send_calls_send(self): self.mock.send_buffer = b"" self.mock.send_lock = Mock() self.mock.send.return_value = b"" network.Connection.queue_send(self.mock, b"data") self.mock.send.assert_called_once_with(b"data") assert self.mock.enable_send.call_count == 0 assert self.mock.send_buffer == b"" def test_queue_send_calls_enable_send_for_partial_send(self): self.mock.send_buffer = b"" self.mock.send_lock = Mock() self.mock.send.return_value = b"ta" network.Connection.queue_send(self.mock, b"data") self.mock.send.assert_called_once_with(b"data") self.mock.enable_send.assert_called_once_with() assert self.mock.send_buffer == b"ta" def test_queue_send_calls_send_with_existing_buffer(self): self.mock.send_buffer = b"foo" self.mock.send_lock = Mock() self.mock.send.return_value = b"" network.Connection.queue_send(self.mock, b"bar") self.mock.send.assert_called_once_with(b"foobar") assert self.mock.enable_send.call_count == 0 assert self.mock.send_buffer == b"" def test_recv_callback_respects_io_err(self): self.mock._sock = Mock(spec=socket.SocketType) self.mock.actor_ref = Mock() assert network.Connection.recv_callback( self.mock, sentinel.fd, (GLib.IO_IN | GLib.IO_ERR) ) self.mock.stop.assert_called_once_with(any_unicode) def test_recv_callback_respects_io_hup(self): self.mock._sock = Mock(spec=socket.SocketType) self.mock.actor_ref = Mock() assert network.Connection.recv_callback( self.mock, sentinel.fd, (GLib.IO_IN | GLib.IO_HUP) ) self.mock.stop.assert_called_once_with(any_unicode) def test_recv_callback_respects_io_hup_and_io_err(self): self.mock._sock = Mock(spec=socket.SocketType) self.mock.actor_ref = Mock() assert network.Connection.recv_callback( self.mock, sentinel.fd, ((GLib.IO_IN | GLib.IO_HUP) | GLib.IO_ERR) ) self.mock.stop.assert_called_once_with(any_unicode) def test_recv_callback_sends_data_to_actor(self): self.mock._sock = Mock(spec=socket.SocketType) self.mock._sock.recv.return_value = b"data" self.mock.actor_ref = Mock() assert network.Connection.recv_callback(self.mock, sentinel.fd, GLib.IO_IN) self.mock.actor_ref.tell.assert_called_once_with({"received": b"data"}) def test_recv_callback_handles_dead_actors(self): self.mock._sock = Mock(spec=socket.SocketType) self.mock._sock.recv.return_value = b"data" self.mock.actor_ref = Mock() self.mock.actor_ref.tell.side_effect = pykka.ActorDeadError() assert network.Connection.recv_callback(self.mock, sentinel.fd, GLib.IO_IN) self.mock.stop.assert_called_once_with(any_unicode) def test_recv_callback_gets_no_data(self): self.mock._sock = Mock(spec=socket.SocketType) self.mock._sock.recv.return_value = b"" self.mock.actor_ref = Mock() assert network.Connection.recv_callback(self.mock, sentinel.fd, GLib.IO_IN) assert self.mock.mock_calls == [ call._sock.recv(any_int), call.disable_recv(), call.actor_ref.tell({"close": True}), ] def test_recv_callback_handles_dead_actors_on_close(self): self.mock._sock = Mock(spec=socket.SocketType) self.mock._sock.recv.return_value = b"" self.mock.actor_ref = Mock() self.mock.actor_ref.tell.side_effect = pykka.ActorDeadError() assert network.Connection.recv_callback(self.mock, sentinel.fd, GLib.IO_IN) self.mock.stop.assert_called_once_with(any_unicode) def test_recv_callback_recoverable_error(self): self.mock._sock = Mock(spec=socket.SocketType) for error in (errno.EWOULDBLOCK, errno.EINTR): self.mock._sock.recv.side_effect = OSError(error, "") assert network.Connection.recv_callback(self.mock, sentinel.fd, GLib.IO_IN) assert self.mock.stop.call_count == 0 def test_recv_callback_unrecoverable_error(self): self.mock._sock = Mock(spec=socket.SocketType) self.mock._sock.recv.side_effect = socket.error assert network.Connection.recv_callback(self.mock, sentinel.fd, GLib.IO_IN) self.mock.stop.assert_called_once_with(any_unicode) def test_send_callback_respects_io_err(self): self.mock._sock = Mock(spec=socket.SocketType) self.mock._sock.send.return_value = 1 self.mock.send_lock = Mock() self.mock.actor_ref = Mock() self.mock.send_buffer = b"" assert network.Connection.send_callback( self.mock, sentinel.fd, (GLib.IO_IN | GLib.IO_ERR) ) self.mock.stop.assert_called_once_with(any_unicode) def test_send_callback_respects_io_hup(self): self.mock._sock = Mock(spec=socket.SocketType) self.mock._sock.send.return_value = 1 self.mock.send_lock = Mock() self.mock.actor_ref = Mock() self.mock.send_buffer = b"" assert network.Connection.send_callback( self.mock, sentinel.fd, (GLib.IO_IN | GLib.IO_HUP) ) self.mock.stop.assert_called_once_with(any_unicode) def test_send_callback_respects_io_hup_and_io_err(self): self.mock._sock = Mock(spec=socket.SocketType) self.mock._sock.send.return_value = 1 self.mock.send_lock = Mock() self.mock.actor_ref = Mock() self.mock.send_buffer = b"" assert network.Connection.send_callback( self.mock, sentinel.fd, ((GLib.IO_IN | GLib.IO_HUP) | GLib.IO_ERR) ) self.mock.stop.assert_called_once_with(any_unicode) def test_send_callback_acquires_and_releases_lock(self): self.mock.send_lock = Mock() self.mock.send_lock.acquire.return_value = True self.mock.send_buffer = b"" self.mock._sock = Mock(spec=socket.SocketType) self.mock._sock.send.return_value = 0 assert network.Connection.send_callback(self.mock, sentinel.fd, GLib.IO_IN) self.mock.send_lock.acquire.assert_called_once_with(blocking=False) self.mock.send_lock.release.assert_called_once_with() def test_send_callback_fails_to_acquire_lock(self): self.mock.send_lock = Mock() self.mock.send_lock.acquire.return_value = False self.mock.send_buffer = b"" self.mock._sock = Mock(spec=socket.SocketType) self.mock._sock.send.return_value = 0 assert network.Connection.send_callback(self.mock, sentinel.fd, GLib.IO_IN) self.mock.send_lock.acquire.assert_called_once_with(blocking=False) assert self.mock._sock.send.call_count == 0 def test_send_callback_sends_all_data(self): self.mock.send_lock = Mock() self.mock.send_lock.acquire.return_value = True self.mock.send_buffer = b"data" self.mock.send.return_value = b"" assert network.Connection.send_callback(self.mock, sentinel.fd, GLib.IO_IN) self.mock.disable_send.assert_called_once_with() self.mock.send.assert_called_once_with(b"data") assert self.mock.send_buffer == b"" def test_send_callback_sends_partial_data(self): self.mock.send_lock = Mock() self.mock.send_lock.acquire.return_value = True self.mock.send_buffer = b"data" self.mock.send.return_value = b"ta" assert network.Connection.send_callback(self.mock, sentinel.fd, GLib.IO_IN) self.mock.send.assert_called_once_with(b"data") assert self.mock.send_buffer == b"ta" def test_send_recoverable_error(self): self.mock._sock = Mock(spec=socket.SocketType) for error in (errno.EWOULDBLOCK, errno.EINTR): self.mock._sock.send.side_effect = OSError(error, "") network.Connection.send(self.mock, b"data") assert self.mock.stop.call_count == 0 def test_send_calls_socket_send(self): self.mock._sock = Mock(spec=socket.SocketType) self.mock._sock.send.return_value = 4 assert network.Connection.send(self.mock, b"data") == b"" self.mock._sock.send.assert_called_once_with(b"data") def test_send_calls_socket_send_partial_send(self): self.mock._sock = Mock(spec=socket.SocketType) self.mock._sock.send.return_value = 2 assert network.Connection.send(self.mock, b"data") == b"ta" self.mock._sock.send.assert_called_once_with(b"data") def test_send_unrecoverable_error(self): self.mock._sock = Mock(spec=socket.SocketType) self.mock._sock.send.side_effect = socket.error assert network.Connection.send(self.mock, b"data") == b"" self.mock.stop.assert_called_once_with(any_unicode) def test_timeout_callback(self): self.mock.timeout = 10 assert not network.Connection.timeout_callback(self.mock) self.mock.stop.assert_called_once_with(any_unicode) def test_str(self): self.mock.host = "foo" self.mock.port = 999 assert network.Connection.__str__(self.mock) == "\\[foo]:999" def test_str_without_port(self): self.mock.host = "foo" self.mock.port = None assert network.Connection.__str__(self.mock) == "\\[foo]" ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1793654065.965161 mopidy_mpd-4.1.0/tests/network/test_lineprotocol.py0000644000175100017510000002506215272176462022321 0ustar00runnerrunnerimport re import unittest from unittest.mock import Mock, sentinel from mopidy_mpd import network from tests import any_unicode class LineProtocolTest(unittest.TestCase): def setUp(self): self.mock = Mock(spec=network.LineProtocol) self.mock.terminator = network.LineProtocol.terminator self.mock.encoding = network.LineProtocol.encoding self.mock.delimiter = network.LineProtocol.delimiter self.mock.prevent_timeout = False def prepare_on_receive_test(self, return_value=None): self.mock.connection = Mock(spec=network.Connection) self.mock.recv_buffer = b"" self.mock.parse_lines.return_value = return_value or [] def test_init_stores_values_in_attributes(self): network.LineProtocol.__init__(self.mock, sentinel.connection) assert sentinel.connection == self.mock.connection assert self.mock.recv_buffer == b"" assert not self.mock.prevent_timeout def test_on_receive_close_calls_stop(self): self.prepare_on_receive_test() network.LineProtocol.on_receive(self.mock, {"close": True}) self.mock.connection.stop.assert_called_once_with(any_unicode) def test_on_receive_no_new_lines_adds_to_recv_buffer(self): self.prepare_on_receive_test() network.LineProtocol.on_receive(self.mock, {"received": b"data"}) assert self.mock.recv_buffer == b"data" self.mock.parse_lines.assert_called_once_with() assert self.mock.on_line_received.call_count == 0 def test_on_receive_toggles_timeout(self): self.prepare_on_receive_test() network.LineProtocol.on_receive(self.mock, {"received": b"data"}) self.mock.connection.disable_timeout.assert_called_once_with() self.mock.connection.enable_timeout.assert_called_once_with() def test_on_receive_toggles_unless_prevent_timeout_is_set(self): self.prepare_on_receive_test() self.mock.prevent_timeout = True network.LineProtocol.on_receive(self.mock, {"received": b"data"}) self.mock.connection.disable_timeout.assert_called_once_with() assert self.mock.connection.enable_timeout.call_count == 0 def test_on_receive_no_new_lines_calls_parse_lines(self): self.prepare_on_receive_test() network.LineProtocol.on_receive(self.mock, {"received": b"data"}) self.mock.parse_lines.assert_called_once_with() assert self.mock.on_line_received.call_count == 0 def test_on_receive_with_new_line_calls_decode(self): self.prepare_on_receive_test([sentinel.line]) network.LineProtocol.on_receive(self.mock, {"received": b"data\n"}) self.mock.parse_lines.assert_called_once_with() self.mock.decode.assert_called_once_with(sentinel.line) def test_on_receive_with_new_line_calls_on_recieve(self): self.prepare_on_receive_test([sentinel.line]) self.mock.decode.return_value = sentinel.decoded network.LineProtocol.on_receive(self.mock, {"received": b"data\n"}) self.mock.on_line_received.assert_called_once_with(sentinel.decoded) def test_on_receive_with_new_line_with_failed_decode(self): self.prepare_on_receive_test([sentinel.line]) self.mock.decode.return_value = None network.LineProtocol.on_receive(self.mock, {"received": b"data\n"}) assert self.mock.on_line_received.call_count == 0 def test_on_receive_with_new_lines_calls_on_recieve(self): self.prepare_on_receive_test(["line1", "line2"]) self.mock.decode.return_value = sentinel.decoded network.LineProtocol.on_receive(self.mock, {"received": b"line1\nline2\n"}) assert self.mock.on_line_received.call_count == 2 def test_on_failure_calls_stop(self): self.mock.connection = Mock(spec=network.Connection) network.LineProtocol.on_failure(self.mock, None, None, None) self.mock.connection.stop.assert_called_once_with("Actor failed.") def prepare_parse_lines_test(self, recv_data=""): self.mock.terminator = b"\n" self.mock.delimiter = re.compile(rb"\n") self.mock.recv_buffer = recv_data.encode() def test_parse_lines_emtpy_buffer(self): self.prepare_parse_lines_test() lines = network.LineProtocol.parse_lines(self.mock) with self.assertRaises(StopIteration): next(lines) def test_parse_lines_no_terminator(self): self.prepare_parse_lines_test("data") lines = network.LineProtocol.parse_lines(self.mock) with self.assertRaises(StopIteration): next(lines) def test_parse_lines_terminator(self): self.prepare_parse_lines_test("data\n") lines = network.LineProtocol.parse_lines(self.mock) assert next(lines) == b"data" with self.assertRaises(StopIteration): next(lines) assert self.mock.recv_buffer == b"" def test_parse_lines_terminator_with_carriage_return(self): self.prepare_parse_lines_test("data\r\n") self.mock.delimiter = re.compile(rb"\r?\n") lines = network.LineProtocol.parse_lines(self.mock) assert next(lines) == b"data" with self.assertRaises(StopIteration): next(lines) assert self.mock.recv_buffer == b"" def test_parse_lines_no_data_before_terminator(self): self.prepare_parse_lines_test("\n") lines = network.LineProtocol.parse_lines(self.mock) assert next(lines) == b"" with self.assertRaises(StopIteration): next(lines) assert self.mock.recv_buffer == b"" def test_parse_lines_extra_data_after_terminator(self): self.prepare_parse_lines_test("data1\ndata2") lines = network.LineProtocol.parse_lines(self.mock) assert next(lines) == b"data1" with self.assertRaises(StopIteration): next(lines) assert self.mock.recv_buffer == b"data2" def test_parse_lines_non_ascii(self): self.prepare_parse_lines_test("æøå\n") lines = network.LineProtocol.parse_lines(self.mock) assert "æøå".encode() == next(lines) with self.assertRaises(StopIteration): next(lines) assert self.mock.recv_buffer == b"" def test_parse_lines_multiple_lines(self): self.prepare_parse_lines_test("abc\ndef\nghi\njkl") lines = network.LineProtocol.parse_lines(self.mock) assert next(lines) == b"abc" assert next(lines) == b"def" assert next(lines) == b"ghi" with self.assertRaises(StopIteration): next(lines) assert self.mock.recv_buffer == b"jkl" def test_parse_lines_multiple_calls(self): self.prepare_parse_lines_test("data1") lines = network.LineProtocol.parse_lines(self.mock) with self.assertRaises(StopIteration): next(lines) assert self.mock.recv_buffer == b"data1" self.mock.recv_buffer += b"\ndata2" lines = network.LineProtocol.parse_lines(self.mock) assert next(lines) == b"data1" with self.assertRaises(StopIteration): next(lines) assert self.mock.recv_buffer == b"data2" def test_send_lines_called_with_no_lines(self): self.mock.connection = Mock(spec=network.Connection) network.LineProtocol.send_lines(self.mock, []) assert self.mock.encode.call_count == 0 assert self.mock.connection.queue_send.call_count == 0 def test_send_lines_calls_join_lines(self): self.mock.connection = Mock(spec=network.Connection) self.mock.join_lines.return_value = "lines" network.LineProtocol.send_lines(self.mock, ["line 1", "line 2"]) self.mock.join_lines.assert_called_once_with(["line 1", "line 2"]) def test_send_line_encodes_joined_lines_with_final_terminator(self): self.mock.connection = Mock(spec=network.Connection) self.mock.join_lines.return_value = "lines\n" network.LineProtocol.send_lines(self.mock, ["line 1", "line 2"]) self.mock.encode.assert_called_once_with("lines\n") def test_send_lines_sends_encoded_string(self): self.mock.connection = Mock(spec=network.Connection) self.mock.join_lines.return_value = "lines" self.mock.encode.return_value = sentinel.data network.LineProtocol.send_lines(self.mock, ["line 1", "line 2"]) self.mock.connection.queue_send.assert_called_once_with(sentinel.data) def test_join_lines_returns_empty_string_for_no_lines(self): assert network.LineProtocol.join_lines(self.mock, []) == "" def test_join_lines_returns_joined_lines(self): self.mock.decode.return_value = "\n" assert network.LineProtocol.join_lines(self.mock, ["1", "2"]) == "1\n2\n" def test_decode_calls_decode_on_string(self): string = Mock() network.LineProtocol.decode(self.mock, string) string.decode.assert_called_once_with(self.mock.encoding) def test_decode_plain_ascii(self): result = network.LineProtocol.decode(self.mock, b"abc") assert result == "abc" assert isinstance(result, str) def test_decode_utf8(self): result = network.LineProtocol.decode(self.mock, "æøå".encode()) assert result == "æøå" assert isinstance(result, str) def test_decode_invalid_data(self): string = Mock() string.decode.side_effect = UnicodeError network.LineProtocol.decode(self.mock, string) self.mock.stop.assert_called_once_with() def test_encode_calls_encode_on_string(self): string = Mock() network.LineProtocol.encode(self.mock, string) string.encode.assert_called_once_with(self.mock.encoding) def test_encode_plain_ascii(self): result = network.LineProtocol.encode(self.mock, "abc") assert result == b"abc" assert isinstance(result, bytes) def test_encode_utf8(self): result = network.LineProtocol.encode(self.mock, "æøå") assert "æøå".encode() == result assert isinstance(result, bytes) def test_encode_invalid_data(self): string = Mock() string.encode.side_effect = UnicodeError network.LineProtocol.encode(self.mock, string) self.mock.stop.assert_called_once_with() def test_host_property(self): mock = Mock(spec=network.Connection) mock.host = sentinel.host lineprotocol = network.LineProtocol(mock) assert sentinel.host == lineprotocol.host def test_port_property(self): mock = Mock(spec=network.Connection) mock.port = sentinel.port lineprotocol = network.LineProtocol(mock) assert sentinel.port == lineprotocol.port ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1793654065.965161 mopidy_mpd-4.1.0/tests/network/test_server.py0000644000175100017510000003075715272176462021125 0ustar00runnerrunnerimport errno import os import socket import unittest from unittest.mock import Mock, patch, sentinel from gi.repository import GLib from mopidy.core import CoreProxy from mopidy_mpd import network, uri_mapper from tests import any_int class ServerTest(unittest.TestCase): def setUp(self): self.mock = Mock(spec=network.Server) @patch.object(network, "get_socket_address", new=Mock()) def test_init_calls_create_server_socket(self): network.Server.__init__( self.mock, config={}, core=Mock(spec=CoreProxy), uri_map=Mock(uri_mapper.MpdUriMapper), protocol=sentinel.protocol, host=sentinel.host, port=sentinel.port, ) self.mock.create_server_socket.assert_called_once_with( sentinel.host, sentinel.port ) self.mock.stop() @patch.object(network, "get_socket_address", new=Mock()) def test_init_calls_get_socket_address(self): network.Server.__init__( self.mock, config={}, core=Mock(spec=CoreProxy), uri_map=Mock(uri_mapper.MpdUriMapper), protocol=sentinel.protocol, host=sentinel.host, port=sentinel.port, ) self.mock.create_server_socket.return_value = None network.get_socket_address.assert_called_once_with(sentinel.host, sentinel.port) self.mock.stop() @patch.object(network, "get_socket_address", new=Mock()) def test_init_calls_register_server(self): sock = Mock(spec=socket.socket) sock.fileno.return_value = sentinel.fileno self.mock.create_server_socket.return_value = sock network.Server.__init__( self.mock, config={}, core=Mock(spec=CoreProxy), uri_map=Mock(uri_mapper.MpdUriMapper), protocol=sentinel.protocol, host=sentinel.host, port=sentinel.port, ) self.mock.register_server_socket.assert_called_once_with(sentinel.fileno) @patch.object(network, "get_socket_address", new=Mock()) def test_init_fails_on_fileno_call(self): sock = Mock(spec=socket.socket) sock.fileno.side_effect = socket.error self.mock.create_server_socket.return_value = sock with self.assertRaises(socket.error): network.Server.__init__( self.mock, config={}, core=Mock(spec=CoreProxy), uri_map=Mock(uri_mapper.MpdUriMapper), protocol=sentinel.protocol, host=sentinel.host, port=sentinel.port, ) def test_init_stores_values_in_attributes(self): # This need to be a mock and no a sentinel as fileno() is called on it sock = Mock(spec=socket.socket) self.mock.create_server_socket.return_value = sock network.Server.__init__( self.mock, config={}, core=Mock(spec=CoreProxy), uri_map=Mock(uri_mapper.MpdUriMapper), protocol=sentinel.protocol, host=str(sentinel.host), port=sentinel.port, max_connections=sentinel.max_connections, timeout=sentinel.timeout, ) assert sentinel.protocol == self.mock.protocol assert sentinel.max_connections == self.mock.max_connections assert sentinel.timeout == self.mock.timeout assert sock == self.mock.server_socket assert (str(sentinel.host), sentinel.port) == self.mock.address def test_create_server_socket_no_port(self): with self.assertRaises(TypeError): network.Server.create_server_socket(self.mock, str(sentinel.host), None) def test_create_server_socket_invalid_port(self): with self.assertRaises(TypeError): network.Server.create_server_socket( self.mock, str(sentinel.host), str(sentinel.port) ) @patch.object(network, "create_tcp_socket", spec=socket.socket) def test_create_server_socket_sets_up_listener(self, create_tcp_socket): sock = create_tcp_socket.return_value network.Server.create_server_socket(self.mock, str(sentinel.host), 1234) sock.setblocking.assert_called_once_with(False) sock.bind.assert_called_once_with((str(sentinel.host), 1234)) sock.listen.assert_called_once_with(any_int) create_tcp_socket.assert_called_once() @patch.object(network, "create_unix_socket", spec=socket.socket) def test_create_server_socket_sets_up_listener_unix(self, create_unix_socket): sock = create_unix_socket.return_value network.Server.create_server_socket( self.mock, "unix:" + str(sentinel.host), sentinel.port ) sock.setblocking.assert_called_once_with(False) sock.bind.assert_called_once_with(str(sentinel.host)) sock.listen.assert_called_once_with(any_int) create_unix_socket.assert_called_once() @patch.object(network, "create_tcp_socket", new=Mock()) def test_create_server_socket_fails(self): network.create_tcp_socket.side_effect = socket.error with self.assertRaises(socket.error): network.Server.create_server_socket(self.mock, str(sentinel.host), 1234) @patch.object(network, "create_unix_socket", new=Mock()) def test_create_server_socket_fails_unix(self): network.create_unix_socket.side_effect = socket.error with self.assertRaises(socket.error): network.Server.create_server_socket( self.mock, "unix:" + str(sentinel.host), sentinel.port ) @patch.object(network, "create_tcp_socket", new=Mock()) def test_create_server_bind_fails(self): sock = network.create_tcp_socket.return_value sock.bind.side_effect = socket.error with self.assertRaises(socket.error): network.Server.create_server_socket(self.mock, str(sentinel.host), 1234) @patch.object(network, "create_unix_socket", new=Mock()) def test_create_server_bind_fails_unix(self): sock = network.create_unix_socket.return_value sock.bind.side_effect = socket.error with self.assertRaises(socket.error): network.Server.create_server_socket( self.mock, "unix:" + str(sentinel.host), sentinel.port ) @patch.object(network, "create_tcp_socket", new=Mock()) def test_create_server_listen_fails(self): sock = network.create_tcp_socket.return_value sock.listen.side_effect = socket.error with self.assertRaises(socket.error): network.Server.create_server_socket(self.mock, str(sentinel.host), 1234) @patch.object(network, "create_unix_socket", new=Mock()) def test_create_server_listen_fails_unix(self): sock = network.create_unix_socket.return_value sock.listen.side_effect = socket.error with self.assertRaises(socket.error): network.Server.create_server_socket( self.mock, "unix:" + str(sentinel.host), sentinel.port ) @patch.object(os, "unlink", new=Mock()) @patch.object(GLib, "source_remove", new=Mock()) def test_stop_server_cleans_unix_socket(self): self.mock.watcher = Mock() sock = Mock() sock.family = socket.AF_UNIX self.mock.server_socket = sock network.Server.stop(self.mock) os.unlink.assert_called_once_with(sock.getsockname()) @patch.object(GLib, "io_add_watch", new=Mock()) def test_register_server_socket_sets_up_io_watch(self): network.Server.register_server_socket(self.mock, sentinel.fileno) GLib.io_add_watch.assert_called_once_with( sentinel.fileno, GLib.IO_IN, self.mock.handle_connection ) def test_handle_connection(self): self.mock.accept_connection.return_value = ( sentinel.sock, sentinel.addr, ) self.mock.maximum_connections_exceeded.return_value = False assert network.Server.handle_connection(self.mock, sentinel.fileno, GLib.IO_IN) self.mock.accept_connection.assert_called_once_with() self.mock.maximum_connections_exceeded.assert_called_once_with() self.mock.init_connection.assert_called_once_with(sentinel.sock, sentinel.addr) assert self.mock.reject_connection.call_count == 0 def test_handle_connection_exceeded_connections(self): self.mock.accept_connection.return_value = ( sentinel.sock, sentinel.addr, ) self.mock.maximum_connections_exceeded.return_value = True assert network.Server.handle_connection(self.mock, sentinel.fileno, GLib.IO_IN) self.mock.accept_connection.assert_called_once_with() self.mock.maximum_connections_exceeded.assert_called_once_with() self.mock.reject_connection.assert_called_once_with( sentinel.sock, sentinel.addr ) assert self.mock.init_connection.call_count == 0 def test_accept_connection(self): sock = Mock(spec=socket.socket) connected_sock = Mock(spec=socket.socket) sock.accept.return_value = ( connected_sock, (sentinel.host, sentinel.port, sentinel.flow, sentinel.scope), ) self.mock.server_socket = sock sock, addr = network.Server.accept_connection(self.mock) assert sock == connected_sock assert addr == (sentinel.host, sentinel.port) def test_accept_connection_unix(self): sock = Mock(spec=socket.socket) connected_sock = Mock(spec=socket.socket) connected_sock.family = socket.AF_UNIX connected_sock.getsockname.return_value = sentinel.sockname sock.accept.return_value = (connected_sock, sentinel.addr) self.mock.server_socket = sock sock, addr = network.Server.accept_connection(self.mock) assert connected_sock == sock assert (sentinel.sockname, None) == addr def test_accept_connection_recoverable_error(self): sock = Mock(spec=socket.socket) self.mock.server_socket = sock for error in (errno.EAGAIN, errno.EINTR): sock.accept.side_effect = OSError(error, "") with self.assertRaises(network.ShouldRetrySocketCallError): network.Server.accept_connection(self.mock) # TODO: decide if this should be allowed to propegate def test_accept_connection_unrecoverable_error(self): sock = Mock(spec=socket.socket) self.mock.server_socket = sock sock.accept.side_effect = socket.error with self.assertRaises(socket.error): network.Server.accept_connection(self.mock) def test_maximum_connections_exceeded(self): self.mock.max_connections = 10 self.mock.number_of_connections.return_value = 11 assert network.Server.maximum_connections_exceeded(self.mock) self.mock.number_of_connections.return_value = 10 assert network.Server.maximum_connections_exceeded(self.mock) self.mock.number_of_connections.return_value = 9 assert not network.Server.maximum_connections_exceeded(self.mock) @patch("pykka.ActorRegistry.get_by_class") def test_number_of_connections(self, get_by_class): self.mock.protocol = sentinel.protocol get_by_class.return_value = [1, 2, 3] assert network.Server.number_of_connections(self.mock) == 3 get_by_class.return_value = [] assert network.Server.number_of_connections(self.mock) == 0 def test_reject_connection(self): sock = Mock(spec=socket.socket) network.Server.reject_connection( self.mock, sock, (sentinel.host, sentinel.port) ) sock.close.assert_called_once_with() @patch.object(network, "format_address", new=Mock()) @patch.object(network.logger, "warning", new=Mock()) def test_reject_connection_message(self): sock = Mock(spec=socket.socket) network.format_address.return_value = sentinel.formatted network.Server.reject_connection( self.mock, sock, (sentinel.host, sentinel.port) ) network.format_address.assert_called_once_with((sentinel.host, sentinel.port)) network.logger.warning.assert_called_once_with( "Rejected connection from %s", sentinel.formatted ) def test_reject_connection_error(self): sock = Mock(spec=socket.socket) sock.close.side_effect = socket.error network.Server.reject_connection( self.mock, sock, (sentinel.host, sentinel.port) ) sock.close.assert_called_once_with() ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1793654065.965161 mopidy_mpd-4.1.0/tests/network/test_utils.py0000644000175100017510000000624415272176462020751 0ustar00runnerrunnerimport socket import unittest from unittest.mock import Mock, patch, sentinel from rich.text import Text from mopidy_mpd import network class FormatHostnameTest(unittest.TestCase): @patch("mopidy_mpd.network.has_ipv6", True) def test_format_hostname_prefixes_ipv4_addresses_when_ipv6_available(self): network.has_ipv6 = True assert network.format_hostname("0.0.0.0") == "::ffff:0.0.0.0" # noqa: S104 assert network.format_hostname("1.0.0.1") == "::ffff:1.0.0.1" @patch("mopidy_mpd.network.has_ipv6", False) def test_format_hostname_does_nothing_when_only_ipv4_available(self): network.has_ipv6 = False assert network.format_hostname("0.0.0.0") == "0.0.0.0" # noqa: S104 class FormatAddressTest(unittest.TestCase): def test_format_address_ipv4(self): address = ("127.0.0.1", 6600) assert network.format_address(address) == "[127.0.0.1]:6600" def test_format_address_ipv6(self): address = ("::1", 6600, 0, 0) assert network.format_address(address) == "[::1]:6600" def test_format_address_hostname(self): address = ("localhost", 6600) assert network.format_address(address) == "\\[localhost]:6600" def test_format_address_unix(self): address = ("/run/mopidy.sock", None) assert network.format_address(address) == "\\[/run/mopidy.sock]" def test_format_address_unix_renders_as_rich_markup(self): address = ("/run/mopidy.sock", None) text = Text.from_markup(network.format_address(address)) assert text.plain == "[/run/mopidy.sock]" class GetSocketAddress(unittest.TestCase): def test_get_socket_address(self): host = str(sentinel.host) port = sentinel.port assert network.get_socket_address(host, port) == (host, port) def test_get_socket_address_unix(self): host = str(sentinel.host) port = sentinel.port assert network.get_socket_address(("unix:" + host), port) == ( host, None, ) class TryIPv6SocketTest(unittest.TestCase): @patch("socket.has_ipv6", False) def test_system_that_claims_no_ipv6_support(self): assert not network.try_ipv6_socket() @patch("socket.has_ipv6", True) @patch("socket.socket") def test_system_with_broken_ipv6(self, socket_mock): socket_mock.side_effect = OSError() assert not network.try_ipv6_socket() @patch("socket.has_ipv6", True) @patch("socket.socket") def test_with_working_ipv6(self, socket_mock): socket_mock.return_value = Mock() assert network.try_ipv6_socket() class CreateSocketTest(unittest.TestCase): @patch("mopidy_mpd.network.has_ipv6", False) @patch("socket.socket") def test_ipv4_socket(self, socket_mock): network.create_tcp_socket() assert socket_mock.call_args[0] == (socket.AF_INET, socket.SOCK_STREAM) @patch("mopidy_mpd.network.has_ipv6", True) @patch("socket.socket") def test_ipv6_socket(self, socket_mock): network.create_tcp_socket() assert socket_mock.call_args[0] == (socket.AF_INET6, socket.SOCK_STREAM) @unittest.SkipTest def test_ipv6_only_is_set(self): pass ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1793654065.965161 mopidy_mpd-4.1.0/tests/path_utils.py0000644000175100017510000000061715272176462017233 0ustar00runnerrunnerimport os # TODO: replace with mock usage in tests. class Mtime: def __init__(self): self.fake = None def __call__(self, path): if self.fake is not None: return self.fake return int(os.stat(path).st_mtime) # noqa: PTH116 def set_fake_time(self, time): self.fake = time def undo_fake(self): self.fake = None mtime = Mtime() ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1793654069.6852584 mopidy_mpd-4.1.0/tests/protocol/0000755000175100017510000000000015272176466016346 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1793654065.965161 mopidy_mpd-4.1.0/tests/protocol/__init__.py0000644000175100017510000000577715272176462020473 0ustar00runnerrunnerimport unittest from typing import cast from unittest import mock import pykka from mopidy import core from mopidy_mpd import session, uri_mapper from tests import dummy_audio, dummy_backend, dummy_mixer class MockConnection(mock.Mock): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.host = mock.sentinel.host self.port = mock.sentinel.port self.response = [] def queue_send(self, data): data = data.decode() lines = (line for line in data.split("\n") if line) self.response.extend(lines) class BaseTestCase(unittest.TestCase): enable_mixer = True def get_config(self): return { "core": {"max_tracklist_length": 10000}, "mpd": { "command_blacklist": [], "default_playlist_scheme": "dummy", "password": None, }, } def setUp(self): if self.enable_mixer: self.mixer = dummy_mixer.create_proxy() else: self.mixer = None self.audio = dummy_audio.create_proxy() self.backend = dummy_backend.create_proxy(audio=self.audio) self.core = cast( "core.CoreProxy", core.Core.start( self.get_config(), audio=self.audio, mixer=self.mixer, backends=[self.backend], ).proxy(), ) self.connection = MockConnection() self.session = session.MpdSession( config=self.get_config(), core=self.core, uri_map=uri_mapper.MpdUriMapper(self.core), connection=self.connection, ) self.dispatcher = self.session.dispatcher self.context = self.dispatcher.context def tearDown(self): pykka.ActorRegistry.stop_all() def send_request(self, request): self.connection.response = [] request = f"{request}\n".encode() self.session.on_receive({"received": request}) return self.connection.response def assertNoResponse(self): # noqa: N802 assert self.connection.response == [] def assertInResponse(self, value): # noqa: N802 assert value in self.connection.response, ( f"Did not find {value!r} in {self.connection.response!r}" ) def assertOnceInResponse(self, value): # noqa: N802 matched = len([r for r in self.connection.response if r == value]) assert matched == 1, ( f"Expected to find {value!r} once in {self.connection.response!r}" ) def assertNotInResponse(self, value): # noqa: N802 assert value not in self.connection.response, ( f"Found {value!r} in {self.connection.response!r}" ) def assertEqualResponse(self, value): # noqa: N802 assert len(self.connection.response) == 1 assert value == self.connection.response[0] def assertResponseLength(self, value): # noqa: N802 assert value == len(self.connection.response) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1793654065.9655182 mopidy_mpd-4.1.0/tests/protocol/test_audio_output.py0000644000175100017510000001162415272176462022500 0ustar00runnerrunnerfrom tests import protocol class AudioOutputHandlerTest(protocol.BaseTestCase): def test_enableoutput(self): self.core.mixer.set_mute(False) self.send_request('enableoutput "0"') self.assertInResponse("OK") assert self.core.mixer.get_mute().get() is True def test_enableoutput_unknown_outputid(self): self.send_request('enableoutput "7"') self.assertInResponse("ACK [50@0] {enableoutput} No such audio output") def test_disableoutput(self): self.core.mixer.set_mute(True) self.send_request('disableoutput "0"') self.assertInResponse("OK") assert self.core.mixer.get_mute().get() is False def test_disableoutput_unknown_outputid(self): self.send_request('disableoutput "7"') self.assertInResponse("ACK [50@0] {disableoutput} No such audio output") def test_outputs_when_unmuted(self): self.core.mixer.set_mute(False) self.send_request("outputs") self.assertInResponse("outputid: 0") self.assertInResponse("outputname: Mute") self.assertInResponse("plugin: mute") self.assertInResponse("outputenabled: 0") self.assertInResponse("OK") def test_outputs_when_muted(self): self.core.mixer.set_mute(True) self.send_request("outputs") self.assertInResponse("outputid: 0") self.assertInResponse("outputname: Mute") self.assertInResponse("plugin: mute") self.assertInResponse("outputenabled: 1") self.assertInResponse("OK") def test_outputs_toggleoutput(self): self.core.mixer.set_mute(False) self.send_request('toggleoutput "0"') self.send_request("outputs") self.assertInResponse("outputid: 0") self.assertInResponse("outputname: Mute") self.assertInResponse("plugin: mute") self.assertInResponse("outputenabled: 1") self.assertInResponse("OK") self.send_request('toggleoutput "0"') self.send_request("outputs") self.assertInResponse("outputid: 0") self.assertInResponse("outputname: Mute") self.assertInResponse("plugin: mute") self.assertInResponse("outputenabled: 0") self.assertInResponse("OK") self.send_request('toggleoutput "0"') self.send_request("outputs") self.assertInResponse("outputid: 0") self.assertInResponse("outputname: Mute") self.assertInResponse("plugin: mute") self.assertInResponse("outputenabled: 1") self.assertInResponse("OK") def test_outputs_toggleoutput_unknown_outputid(self): self.send_request('toggleoutput "7"') self.assertInResponse("ACK [50@0] {toggleoutput} No such audio output") class AudioOutputHandlerNoneMixerTest(protocol.BaseTestCase): enable_mixer = False def test_enableoutput(self): assert self.core.mixer.get_mute().get() is None self.send_request('enableoutput "0"') self.assertInResponse("ACK [52@0] {enableoutput} problems enabling output") assert self.core.mixer.get_mute().get() is None def test_disableoutput(self): assert self.core.mixer.get_mute().get() is None self.send_request('disableoutput "0"') self.assertInResponse("ACK [52@0] {disableoutput} problems disabling output") assert self.core.mixer.get_mute().get() is None def test_outputs_when_unmuted(self): self.core.mixer.set_mute(False) self.send_request("outputs") self.assertInResponse("outputid: 0") self.assertInResponse("outputname: Mute") self.assertInResponse("plugin: mute") self.assertInResponse("outputenabled: 0") self.assertInResponse("OK") def test_outputs_when_muted(self): self.core.mixer.set_mute(True) self.send_request("outputs") self.assertInResponse("outputid: 0") self.assertInResponse("outputname: Mute") self.assertInResponse("plugin: mute") self.assertInResponse("outputenabled: 0") self.assertInResponse("OK") def test_outputs_toggleoutput(self): self.core.mixer.set_mute(False) self.send_request('toggleoutput "0"') self.send_request("outputs") self.assertInResponse("outputid: 0") self.assertInResponse("outputname: Mute") self.assertInResponse("plugin: mute") self.assertInResponse("outputenabled: 0") self.assertInResponse("OK") self.send_request('toggleoutput "0"') self.send_request("outputs") self.assertInResponse("outputid: 0") self.assertInResponse("outputname: Mute") self.assertInResponse("plugin: mute") self.assertInResponse("outputenabled: 0") self.assertInResponse("OK") def test_outputset(self): self.send_request('outputset "0" foo bar') self.assertInResponse( "ACK [50@0] {outputset} Setting attributes is not supported" ) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1793654065.9655182 mopidy_mpd-4.1.0/tests/protocol/test_authentication.py0000644000175100017510000000505715272176462023001 0ustar00runnerrunnerfrom tests import protocol class AuthenticationActiveTest(protocol.BaseTestCase): def get_config(self): config = super().get_config() config["mpd"]["password"] = "topsecret" # noqa: S105 return config def test_authentication_with_valid_password_is_accepted(self): self.send_request('password "topsecret"') assert self.dispatcher.authenticated self.assertInResponse("OK") def test_authentication_with_invalid_password_is_not_accepted(self): self.send_request('password "secret"') assert not self.dispatcher.authenticated self.assertEqualResponse("ACK [3@0] {password} incorrect password") def test_authentication_without_password_fails(self): self.send_request("password") assert not self.dispatcher.authenticated self.assertEqualResponse( 'ACK [2@0] {password} wrong number of arguments for "password"' ) def test_anything_when_not_authenticated_should_fail(self): self.send_request("any request at all") assert not self.dispatcher.authenticated self.assertEqualResponse('ACK [4@0] {any} you don\'t have permission for "any"') def test_close_is_allowed_without_authentication(self): self.send_request("close") assert not self.dispatcher.authenticated def test_commands_is_allowed_without_authentication(self): self.send_request("commands") assert not self.dispatcher.authenticated self.assertInResponse("OK") def test_binarylimit_is_allowed_without_authentication(self): self.send_request("binarylimit 16384") assert not self.dispatcher.authenticated self.assertEqualResponse("OK") def test_notcommands_is_allowed_without_authentication(self): self.send_request("notcommands") assert not self.dispatcher.authenticated self.assertInResponse("OK") def test_ping_is_allowed_without_authentication(self): self.send_request("ping") assert not self.dispatcher.authenticated self.assertInResponse("OK") class AuthenticationInactiveTest(protocol.BaseTestCase): def test_authentication_with_anything_when_password_check_turned_off(self): self.send_request("any request at all") assert self.dispatcher.authenticated self.assertEqualResponse('ACK [5@0] {} unknown command "any"') def test_any_password_is_not_accepted_when_password_check_turned_off(self): self.send_request('password "secret"') self.assertEqualResponse("ACK [3@0] {password} incorrect password") ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1793654065.9655182 mopidy_mpd-4.1.0/tests/protocol/test_channels.py0000644000175100017510000000152415272176462021550 0ustar00runnerrunnerfrom tests import protocol class ChannelsHandlerTest(protocol.BaseTestCase): def test_subscribe(self): self.send_request('subscribe "topic"') self.assertEqualResponse("ACK [0@0] {subscribe} Not implemented") def test_unsubscribe(self): self.send_request('unsubscribe "topic"') self.assertEqualResponse("ACK [0@0] {unsubscribe} Not implemented") def test_channels(self): self.send_request("channels") self.assertEqualResponse("ACK [0@0] {channels} Not implemented") def test_readmessages(self): self.send_request("readmessages") self.assertEqualResponse("ACK [0@0] {readmessages} Not implemented") def test_sendmessage(self): self.send_request('sendmessage "topic" "a message"') self.assertEqualResponse("ACK [0@0] {sendmessage} Not implemented") ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1793654065.9655182 mopidy_mpd-4.1.0/tests/protocol/test_command_list.py0000644000175100017510000000465515272176462022436 0ustar00runnerrunnerfrom tests import protocol class CommandListsTest(protocol.BaseTestCase): def test_command_list_begin(self): response = self.send_request("command_list_begin") assert response == [] def test_command_list_end(self): self.send_request("command_list_begin") self.send_request("command_list_end") self.assertInResponse("OK") def test_command_list_end_without_start_first_is_an_unknown_command(self): self.send_request("command_list_end") self.assertEqualResponse('ACK [5@0] {} unknown command "command_list_end"') def test_command_list_with_ping(self): self.send_request("command_list_begin") assert self.dispatcher.command_list_receiving assert not self.dispatcher.command_list_ok assert self.dispatcher.command_list == [] self.send_request("ping") assert "ping" in self.dispatcher.command_list self.send_request("command_list_end") self.assertInResponse("OK") assert not self.dispatcher.command_list_receiving assert not self.dispatcher.command_list_ok assert self.dispatcher.command_list == [] def test_command_list_with_error_returns_ack_with_correct_index(self): self.send_request("command_list_begin") self.send_request("play") # Known command self.send_request("paly") # Unknown command self.send_request("command_list_end") self.assertEqualResponse('ACK [5@1] {} unknown command "paly"') def test_command_list_ok_begin(self): response = self.send_request("command_list_ok_begin") assert response == [] def test_command_list_ok_with_ping(self): self.send_request("command_list_ok_begin") assert self.dispatcher.command_list_receiving assert self.dispatcher.command_list_ok assert self.dispatcher.command_list == [] self.send_request("ping") assert "ping" in self.dispatcher.command_list self.send_request("command_list_end") self.assertInResponse("list_OK") self.assertInResponse("OK") assert not self.dispatcher.command_list_receiving assert not self.dispatcher.command_list_ok assert self.dispatcher.command_list == [] # TODO: this should also include the special handling of idle within a # command list. That is that once a idle/noidle command is found inside a # commad list, the rest of the list seems to be ignored. ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1793654065.9655182 mopidy_mpd-4.1.0/tests/protocol/test_connection.py0000644000175100017510000001760615272176462022124 0ustar00runnerrunnerfrom unittest.mock import patch from mopidy_mpd.protocol import tagtype_list from mopidy_mpd.protocol.connection import MAX_BINARY_LIMIT, MIN_BINARY_LIMIT from tests import protocol class ConnectionHandlerTest(protocol.BaseTestCase): def test_close_closes_the_client_connection(self): with patch.object(self.session, "close") as close_mock: self.send_request("close") close_mock.assert_called_once_with() self.assertEqualResponse("OK") def test_empty_request(self): self.send_request("") self.assertNoResponse() self.send_request(" ") self.assertNoResponse() def test_kill(self): self.send_request("kill") self.assertEqualResponse( 'ACK [4@0] {kill} you don\'t have permission for "kill"' ) def test_ping(self): self.send_request("ping") self.assertEqualResponse("OK") def test_malformed_comamnd(self): self.send_request("GET / HTTP/1.1") self.assertNoResponse() self.connection.stop.assert_called_once_with("Malformed command") def test_tagtypes(self): self.send_request("tagtypes") self.assertInResponse("tagtype: Artist") self.assertInResponse("tagtype: ArtistSort") self.assertInResponse("tagtype: Album") self.assertInResponse("tagtype: AlbumArtist") self.assertInResponse("tagtype: Title") self.assertInResponse("tagtype: Track") self.assertInResponse("tagtype: Name") self.assertInResponse("tagtype: Genre") self.assertInResponse("tagtype: Date") self.assertInResponse("tagtype: Composer") self.assertInResponse("tagtype: Performer") self.assertInResponse("tagtype: Disc") self.assertInResponse("tagtype: MUSICBRAINZ_ARTISTID") self.assertInResponse("tagtype: MUSICBRAINZ_ALBUMID") self.assertInResponse("tagtype: MUSICBRAINZ_ALBUMARTISTID") self.assertInResponse("tagtype: MUSICBRAINZ_TRACKID") self.assertInResponse("OK") def test_tagtypes_clear(self): self.send_request("tagtypes clear") self.assertEqualResponse("OK") self.send_request("tagtypes") self.assertEqualResponse("OK") def test_tagtypes_all(self): self.send_request("tagtypes all") self.assertEqualResponse("OK") self.send_request("tagtypes") self.assertInResponse("tagtype: Artist") self.assertInResponse("tagtype: Album") self.assertInResponse("tagtype: AlbumArtist") self.assertInResponse("tagtype: Title") self.assertInResponse("tagtype: Track") self.assertInResponse("tagtype: Name") self.assertInResponse("tagtype: Genre") self.assertInResponse("tagtype: Date") self.assertInResponse("tagtype: Composer") self.assertInResponse("tagtype: Performer") self.assertInResponse("tagtype: Disc") self.assertInResponse("tagtype: MUSICBRAINZ_ARTISTID") self.assertInResponse("tagtype: MUSICBRAINZ_ALBUMID") self.assertInResponse("tagtype: MUSICBRAINZ_ALBUMARTISTID") self.assertInResponse("tagtype: MUSICBRAINZ_TRACKID") self.assertInResponse("OK") self.assertResponseLength(len(tagtype_list.TAGTYPE_LIST) + 1) def test_tagtypes_disable(self): self.send_request("tagtypes all") self.send_request( "tagtypes disable MUSICBRAINZ_ARTISTID MUSICBRAINZ_ALBUMID " "MUSICBRAINZ_ALBUMARTISTID MUSICBRAINZ_TRACKID" ) self.assertEqualResponse("OK") self.send_request("tagtypes") self.assertInResponse("tagtype: Artist") self.assertInResponse("tagtype: Album") self.assertInResponse("tagtype: AlbumArtist") self.assertInResponse("tagtype: Title") self.assertInResponse("tagtype: Track") self.assertInResponse("tagtype: Name") self.assertInResponse("tagtype: Genre") self.assertInResponse("tagtype: Date") self.assertInResponse("tagtype: Composer") self.assertInResponse("tagtype: Performer") self.assertInResponse("tagtype: Disc") self.assertNotInResponse("tagtype: MUSICBRAINZ_ARTISTID") self.assertNotInResponse("tagtype: MUSICBRAINZ_ALBUMID") self.assertNotInResponse("tagtype: MUSICBRAINZ_ALBUMARTISTID") self.assertNotInResponse("tagtype: MUSICBRAINZ_TRACKID") self.assertInResponse("OK") def test_tagtypes_enable(self): self.send_request("tagtypes clear") self.send_request("tagtypes enable Artist Album Title Track Name Genre") self.assertEqualResponse("OK") self.send_request("tagtypes") self.assertInResponse("tagtype: Artist") self.assertInResponse("tagtype: Album") self.assertInResponse("tagtype: Title") self.assertInResponse("tagtype: Track") self.assertInResponse("tagtype: Name") self.assertInResponse("tagtype: Genre") self.assertNotInResponse("tagtype: ArtistSort") self.assertNotInResponse("tagtype: AlbumArtist") self.assertNotInResponse("tagtype: AlbumArtistSort") self.assertNotInResponse("tagtype: Date") self.assertNotInResponse("tagtype: Composer") self.assertNotInResponse("tagtype: Performer") self.assertNotInResponse("tagtype: Disc") self.assertNotInResponse("tagtype: MUSICBRAINZ_ARTISTID") self.assertNotInResponse("tagtype: MUSICBRAINZ_ALBUMID") self.assertNotInResponse("tagtype: MUSICBRAINZ_ALBUMARTISTID") self.assertNotInResponse("tagtype: MUSICBRAINZ_TRACKID") self.assertInResponse("OK") def test_tagtypes_disable_x(self): self.send_request("tagtypes disable x") self.assertEqualResponse("ACK [2@0] {tagtypes} Unknown tag type") def test_tagtypes_enable_x(self): self.send_request("tagtypes enable x") self.assertEqualResponse("ACK [2@0] {tagtypes} Unknown tag type") def test_tagtypes_disable_empty(self): self.send_request("tagtypes disable") self.assertEqualResponse("ACK [2@0] {tagtypes} Not enough arguments") def test_tagtypes_enable_empty(self): self.send_request("tagtypes enable") self.assertEqualResponse("ACK [2@0] {tagtypes} Not enough arguments") def test_tagtypes_bogus(self): self.send_request("tagtypes bogus") self.assertEqualResponse("ACK [2@0] {tagtypes} Unknown sub command") def test_binarylimit_default(self): assert self.session.binary_limit == 8192 def test_binarylimit(self): self.send_request("binarylimit 16384") self.assertEqualResponse("OK") assert self.session.binary_limit == 16384 def test_binarylimit_min(self): self.send_request(f"binarylimit {MIN_BINARY_LIMIT}") self.assertEqualResponse("OK") assert self.session.binary_limit == MIN_BINARY_LIMIT def test_binarylimit_max(self): self.send_request(f"binarylimit {MAX_BINARY_LIMIT}") self.assertEqualResponse("OK") assert self.session.binary_limit == MAX_BINARY_LIMIT def test_binarylimit_too_small(self): self.send_request(f"binarylimit {MIN_BINARY_LIMIT - 1}") self.assertEqualResponse( f"ACK [2@0] {{binarylimit}} 'size' must be at least {MIN_BINARY_LIMIT}" ) assert self.session.binary_limit == 8192 def test_binarylimit_too_large(self): self.send_request(f"binarylimit {MAX_BINARY_LIMIT + 1}") self.assertEqualResponse( f"ACK [2@0] {{binarylimit}} 'size' must be at most {MAX_BINARY_LIMIT}" ) assert self.session.binary_limit == 8192 def test_binarylimit_not_a_number(self): self.send_request("binarylimit abc") self.assertEqualResponse("ACK [2@0] {binarylimit} incorrect arguments") self.send_request("binarylimit -1") self.assertEqualResponse("ACK [2@0] {binarylimit} incorrect arguments") assert self.session.binary_limit == 8192 ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1793654065.9655182 mopidy_mpd-4.1.0/tests/protocol/test_current_playlist.py0000644000175100017510000005123615272176462023365 0ustar00runnerrunnerimport pytest from mopidy.models import Ref, Track from tests import protocol class AddCommandsTest(protocol.BaseTestCase): def setUp(self): super().setUp() self.tracks = [ Track(uri="dummy:/a", name="a"), Track(uri="dummy:/foo/b", name="b"), Track(uri="dummy:/foo/ǂ", name="ǂ"), ] self.refs = { "/a": Ref.track(uri="dummy:/a", name="a"), "/foo": Ref.directory(uri="dummy:/foo", name="foo"), "/foo/b": Ref.track(uri="dummy:/foo/b", name="b"), "/foo/ǂ": Ref.track(uri="dummy:/foo/ǂ", name="ǂ"), } self.backend.library.dummy_library = self.tracks def test_add(self): for track in [self.tracks[0], self.tracks[0], self.tracks[1]]: self.send_request(f'add "{track.uri}"') assert len(self.core.tracklist.get_tracks().get()) == 3 assert self.core.tracklist.get_tracks().get()[2] == self.tracks[1] self.assertEqualResponse("OK") def test_add_unicode(self): for track in [self.tracks[0], self.tracks[1], self.tracks[2]]: self.send_request(f'add "{track.uri}"') assert len(self.core.tracklist.get_tracks().get()) == 3 assert self.core.tracklist.get_tracks().get()[2] == self.tracks[2] self.assertEqualResponse("OK") def test_add_with_uri_not_found_in_library_should_ack(self): self.send_request('add "dummy://foo"') self.assertEqualResponse("ACK [50@0] {add} directory or file not found") def test_add_with_empty_uri_should_not_add_anything_and_ok(self): self.backend.library.dummy_browse_result = {"dummy:/": [self.refs["/a"]]} self.send_request('add ""') assert len(self.core.tracklist.get_tracks().get()) == 0 self.assertInResponse("OK") def test_add_with_library_should_recurse(self): self.backend.library.dummy_browse_result = { "dummy:/": [self.refs["/a"], self.refs["/foo"]], "dummy:/foo": [self.refs["/foo/b"], self.refs["/foo/ǂ"]], } self.send_request('add "/dummy"') assert self.core.tracklist.get_tracks().get() == self.tracks self.assertInResponse("OK") def test_add_root_should_not_add_anything_and_ok(self): self.backend.library.dummy_browse_result = {"dummy:/": [self.refs["/a"]]} self.send_request('add "/"') assert len(self.core.tracklist.get_tracks().get()) == 0 self.assertInResponse("OK") def test_addid_without_songpos(self): for track in [self.tracks[0], self.tracks[0], self.tracks[1]]: self.send_request(f'addid "{track.uri}"') tl_tracks = self.core.tracklist.get_tl_tracks().get() assert len(tl_tracks) == 3 assert tl_tracks[2].track == self.tracks[1] self.assertInResponse(f"Id: {tl_tracks[2].tlid:d}") self.assertInResponse("OK") def test_addid_with_songpos(self): for track in [self.tracks[0], self.tracks[0]]: self.send_request(f'add "{track.uri}"') self.send_request(f'addid "{self.tracks[1].uri}" "1"') tl_tracks = self.core.tracklist.get_tl_tracks().get() assert len(tl_tracks) == 3 assert tl_tracks[1].track == self.tracks[1] self.assertInResponse(f"Id: {tl_tracks[1].tlid:d}") self.assertInResponse("OK") def test_addid_with_songpos_out_of_bounds_should_ack(self): self.send_request(f'addid "{self.tracks[0].uri}" "3"') self.assertEqualResponse("ACK [2@0] {addid} Bad song index") def test_addid_with_empty_uri_acks(self): self.send_request('addid ""') self.assertEqualResponse("ACK [50@0] {addid} No such song") def test_addid_with_uri_not_found_in_library_should_ack(self): self.send_request('addid "dummy://foo"') self.assertEqualResponse("ACK [50@0] {addid} No such song") class BasePopulatedTracklistTestCase(protocol.BaseTestCase): def setUp(self): super().setUp() tracks = [Track(uri=f"dummy:/{x}", name=x) for x in "abcdeǂ"] self.backend.library.dummy_library = tracks self.core.tracklist.add(uris=[t.uri for t in tracks]) class DeleteCommandsTest(BasePopulatedTracklistTestCase): def test_clear(self): self.send_request("clear") assert len(self.core.tracklist.get_tracks().get()) == 0 assert self.core.playback.get_current_track().get() is None self.assertInResponse("OK") def test_delete_songpos(self): tl_tracks = self.core.tracklist.get_tl_tracks().get() self.send_request(f'delete "{tl_tracks[1].tlid}"') assert len(self.core.tracklist.get_tracks().get()) == 5 self.assertInResponse("OK") def test_delete_songpos_out_of_bounds(self): self.send_request('delete "8"') assert len(self.core.tracklist.get_tracks().get()) == 6 self.assertEqualResponse("ACK [2@0] {delete} Bad song index") def test_delete_open_range(self): self.send_request('delete "1:"') assert len(self.core.tracklist.get_tracks().get()) == 1 self.assertInResponse("OK") @pytest.mark.skip("Check how this should work") def test_delete_open_upper_range(self): self.send_request('delete ":8"') assert len(self.core.tracklist.get_tracks().get()) == 0 self.assertInResponse("OK") def test_delete_closed_range(self): self.send_request('delete "1:3"') assert len(self.core.tracklist.get_tracks().get()) == 4 self.assertInResponse("OK") def test_delete_entire_range_out_of_bounds(self): self.send_request('delete "8:9"') assert len(self.core.tracklist.get_tracks().get()) == 6 self.assertEqualResponse("ACK [2@0] {delete} Bad song index") def test_delete_upper_range_out_of_bounds(self): self.send_request('delete "5:9"') assert len(self.core.tracklist.get_tracks().get()) == 5 self.assertEqualResponse("OK") def test_deleteid(self): self.send_request('deleteid "1"') assert len(self.core.tracklist.get_tracks().get()) == 5 self.assertInResponse("OK") def test_deleteid_does_not_exist(self): self.send_request('deleteid "12345"') assert len(self.core.tracklist.get_tracks().get()) == 6 self.assertEqualResponse("ACK [50@0] {deleteid} No such song") class MoveCommandsTest(BasePopulatedTracklistTestCase): def test_move_songpos(self): self.send_request('move "1" "0"') result = [t.name for t in self.core.tracklist.get_tracks().get()] assert result == ["b", "a", "c", "d", "e", "ǂ"] self.assertInResponse("OK") def test_move_open_range(self): self.send_request('move "2:" "0"') result = [t.name for t in self.core.tracklist.get_tracks().get()] assert result == ["c", "d", "e", "ǂ", "a", "b"] self.assertInResponse("OK") def test_move_closed_range(self): self.send_request('move "1:3" "0"') result = [t.name for t in self.core.tracklist.get_tracks().get()] assert result == ["b", "c", "a", "d", "e", "ǂ"] self.assertInResponse("OK") def test_moveid(self): self.send_request('moveid "5" "2"') result = [t.name for t in self.core.tracklist.get_tracks().get()] assert result == ["a", "b", "e", "c", "d", "ǂ"] self.assertInResponse("OK") def test_moveid_with_tlid_not_found_in_tracklist_should_ack(self): self.send_request('moveid "10" "0"') self.assertEqualResponse("ACK [50@0] {moveid} No such song") class PlaylistFindCommandTest(protocol.BaseTestCase): def test_playlistfind(self): self.send_request('playlistfind "tag" "needle"') self.assertEqualResponse("ACK [0@0] {playlistfind} Not implemented") def test_playlistfind_by_filename_not_in_tracklist(self): self.send_request('playlistfind "filename" "file:///dev/null"') self.assertEqualResponse("OK") def test_playlistfind_by_filename_without_quotes(self): self.send_request('playlistfind filename "file:///dev/null"') self.assertEqualResponse("OK") def test_playlistfind_by_filename_in_tracklist(self): track = Track(uri="dummy:///exists") self.backend.library.dummy_library = [track] self.core.tracklist.add(uris=[track.uri]) self.send_request('playlistfind filename "dummy:///exists"') self.assertInResponse("file: dummy:///exists") self.assertInResponse("Id: 1") self.assertInResponse("Pos: 0") self.assertInResponse("OK") class PlaylistIdCommandTest(BasePopulatedTracklistTestCase): def test_playlistid_without_songid(self): self.send_request("playlistid") self.assertInResponse("Title: a") self.assertInResponse("Title: b") self.assertInResponse("OK") def test_playlistid_with_songid(self): self.send_request('playlistid "2"') self.assertNotInResponse("Title: a") self.assertNotInResponse("Id: 1") self.assertInResponse("Title: b") self.assertInResponse("Id: 2") self.assertInResponse("OK") def test_playlistid_with_not_existing_songid_fails(self): self.send_request('playlistid "25"') self.assertEqualResponse("ACK [50@0] {playlistid} No such song") class PlaylistInfoCommandTest(BasePopulatedTracklistTestCase): def test_playlist_returns_same_as_playlistinfo(self): playlist_response = self.send_request("playlist") playlistinfo_response = self.send_request("playlistinfo") assert playlist_response == playlistinfo_response def test_playlistinfo_without_songpos_or_range(self): self.send_request("playlistinfo") self.assertInResponse("Title: a") self.assertInResponse("Pos: 0") self.assertInResponse("Title: b") self.assertInResponse("Pos: 1") self.assertInResponse("Title: c") self.assertInResponse("Pos: 2") self.assertInResponse("Title: d") self.assertInResponse("Pos: 3") self.assertInResponse("Title: e") self.assertInResponse("Pos: 4") self.assertInResponse("Title: ǂ") self.assertInResponse("Pos: 5") self.assertInResponse("OK") def test_playlistinfo_with_songpos(self): # Make the track's CPID not match the playlist position self.core.tracklist.tlid = 17 self.send_request('playlistinfo "4"') self.assertNotInResponse("Title: a") self.assertNotInResponse("Pos: 0") self.assertNotInResponse("Title: b") self.assertNotInResponse("Pos: 1") self.assertNotInResponse("Title: c") self.assertNotInResponse("Pos: 2") self.assertNotInResponse("Title: d") self.assertNotInResponse("Pos: 3") self.assertInResponse("Title: e") self.assertInResponse("Pos: 4") self.assertNotInResponse("Title: ǂ") self.assertNotInResponse("Pos: 5") self.assertInResponse("OK") def test_playlistinfo_with_negative_songpos_same_as_playlistinfo(self): response1 = self.send_request('playlistinfo "-1"') response2 = self.send_request("playlistinfo") assert response1 == response2 def test_playlistinfo_with_open_range(self): self.send_request('playlistinfo "2:"') self.assertNotInResponse("Title: a") self.assertNotInResponse("Pos: 0") self.assertNotInResponse("Title: b") self.assertNotInResponse("Pos: 1") self.assertInResponse("Title: c") self.assertInResponse("Pos: 2") self.assertInResponse("Title: d") self.assertInResponse("Pos: 3") self.assertInResponse("Title: e") self.assertInResponse("Pos: 4") self.assertInResponse("Title: ǂ") self.assertInResponse("Pos: 5") self.assertInResponse("OK") def test_playlistinfo_with_closed_range(self): self.send_request('playlistinfo "2:4"') self.assertNotInResponse("Title: a") self.assertNotInResponse("Title: b") self.assertInResponse("Title: c") self.assertInResponse("Title: d") self.assertNotInResponse("Title: e") self.assertNotInResponse("Title: ǂ") self.assertInResponse("OK") def test_playlistinfo_with_too_high_start_of_range_returns_arg_error(self): self.send_request('playlistinfo "10:20"') self.assertEqualResponse("ACK [2@0] {playlistinfo} Bad song index") def test_playlistinfo_with_too_high_end_of_range_returns_ok(self): self.send_request('playlistinfo "0:20"') self.assertInResponse("OK") def test_playlistinfo_with_zero_returns_ok(self): self.send_request('playlistinfo "0"') self.assertInResponse("OK") class PlaylistSearchCommandTest(protocol.BaseTestCase): def test_playlistsearch(self): self.send_request('playlistsearch "any" "needle"') self.assertEqualResponse("ACK [0@0] {playlistsearch} Not implemented") def test_playlistsearch_without_quotes(self): self.send_request('playlistsearch any "needle"') self.assertEqualResponse("ACK [0@0] {playlistsearch} Not implemented") class PlChangeCommandTest(BasePopulatedTracklistTestCase): def test_plchanges_with_lower_version_returns_changes(self): self.send_request('plchanges "0"') self.assertInResponse("Title: a") self.assertInResponse("Title: b") self.assertInResponse("Title: c") self.assertInResponse("OK") def test_plchanges_with_equal_version_returns_nothing(self): assert self.core.tracklist.get_version().get() == 1 self.send_request('plchanges "1"') self.assertNotInResponse("Title: a") self.assertNotInResponse("Title: b") self.assertNotInResponse("Title: c") self.assertInResponse("OK") def test_plchanges_with_greater_version_returns_nothing(self): assert self.core.tracklist.get_version().get() == 1 self.send_request('plchanges "2"') self.assertNotInResponse("Title: a") self.assertNotInResponse("Title: b") self.assertNotInResponse("Title: c") self.assertInResponse("OK") def test_plchanges_with_minus_one_returns_entire_playlist(self): self.send_request('plchanges "-1"') self.assertInResponse("Title: a") self.assertInResponse("Title: b") self.assertInResponse("Title: c") self.assertInResponse("OK") def test_plchanges_without_quotes_works(self): self.send_request("plchanges 0") self.assertInResponse("Title: a") self.assertInResponse("Title: b") self.assertInResponse("Title: c") self.assertInResponse("OK") def test_plchanges_with_range(self): self.send_request('plchanges "0" "1:3"') self.assertNotInResponse("Title: a") self.assertInResponse("Title: b") self.assertInResponse("Pos: 1") self.assertInResponse("Title: c") self.assertInResponse("Pos: 2") self.assertNotInResponse("Title: d") self.assertInResponse("OK") def test_plchanges_with_single_position_range(self): self.send_request('plchanges "0" "4"') self.assertNotInResponse("Title: d") self.assertInResponse("Title: e") self.assertInResponse("Pos: 4") self.assertNotInResponse("Title: ǂ") self.assertInResponse("OK") def test_plchanges_with_open_ended_range(self): self.send_request('plchanges "0" "4:"') self.assertNotInResponse("Title: d") self.assertInResponse("Title: e") self.assertInResponse("Title: ǂ") self.assertInResponse("OK") def test_plchanges_with_range_past_end_returns_nothing(self): self.send_request('plchanges "0" "10:12"') self.assertEqualResponse("OK") def test_plchanges_with_invalid_range(self): self.send_request('plchanges "0" "3:1"') self.assertEqualResponse("ACK [2@0] {plchanges} incorrect arguments") def _play_with_stream_title(self, title): self.core.playback.play().get() self.audio.trigger_fake_tags_changed({"title": [title]}).get() assert self.core.playback.get_stream_title().get() == title def test_plchanges_with_equal_version_and_range_including_stream(self): self._play_with_stream_title("foo") self.send_request('plchanges "1" "0:1"') self.assertInResponse("Title: foo") self.assertInResponse("Pos: 0") self.assertInResponse("OK") def test_plchanges_with_equal_version_and_range_excluding_stream(self): self._play_with_stream_title("foo") self.send_request('plchanges "1" "1:"') self.assertEqualResponse("OK") def test_plchangesposid(self): self.send_request('plchangesposid "0"') tl_tracks = self.core.tracklist.get_tl_tracks().get() self.assertInResponse("cpos: 0") self.assertInResponse(f"Id: {tl_tracks[0].tlid:d}") self.assertInResponse("cpos: 2") self.assertInResponse(f"Id: {tl_tracks[1].tlid:d}") self.assertInResponse("cpos: 2") self.assertInResponse(f"Id: {tl_tracks[2].tlid:d}") self.assertInResponse("OK") def test_plchangesposid_with_range(self): self.send_request('plchangesposid "0" "1:3"') tl_tracks = self.core.tracklist.get_tl_tracks().get() self.assertNotInResponse("cpos: 0") self.assertInResponse("cpos: 1") self.assertInResponse(f"Id: {tl_tracks[1].tlid:d}") self.assertInResponse("cpos: 2") self.assertInResponse(f"Id: {tl_tracks[2].tlid:d}") self.assertNotInResponse("cpos: 3") self.assertInResponse("OK") def test_plchangesposid_with_range_past_end_returns_nothing(self): self.send_request('plchangesposid "0" "10:12"') self.assertEqualResponse("OK") class PrioCommandTest(protocol.BaseTestCase): def test_prio(self): self.send_request("prio 255 0:10") self.assertEqualResponse("ACK [0@0] {prio} Not implemented") def test_prioid(self): self.send_request("prioid 255 17 23") self.assertEqualResponse("ACK [0@0] {prioid} Not implemented") class RangeIdCommandTest(protocol.BaseTestCase): def test_rangeid(self): self.send_request("rangeid 17 0:30") self.assertEqualResponse("ACK [0@0] {rangeid} Not implemented") # TODO: we only seem to be testing that don't touch the non shuffled region :/ class ShuffleCommandTest(BasePopulatedTracklistTestCase): def test_shuffle_without_range(self): version = self.core.tracklist.get_version().get() self.send_request("shuffle") assert version < self.core.tracklist.get_version().get() self.assertInResponse("OK") def test_shuffle_with_open_range(self): version = self.core.tracklist.get_version().get() self.send_request('shuffle "4:"') assert version < self.core.tracklist.get_version().get() result = [t.name for t in self.core.tracklist.get_tracks().get()] assert result[:4] == ["a", "b", "c", "d"] self.assertInResponse("OK") def test_shuffle_with_closed_range(self): version = self.core.tracklist.get_version().get() self.send_request('shuffle "1:3"') assert version < self.core.tracklist.get_version().get() result = [t.name for t in self.core.tracklist.get_tracks().get()] assert result[:1] == ["a"] assert result[3:] == ["d", "e", "ǂ"] self.assertInResponse("OK") class SwapCommandTest(BasePopulatedTracklistTestCase): def test_swap(self): self.send_request('swap "1" "4"') result = [t.name for t in self.core.tracklist.get_tracks().get()] assert result == ["a", "e", "c", "d", "b", "ǂ"] self.assertInResponse("OK") def test_swap_highest_position_first(self): self.send_request('swap "4" "1"') result = [t.name for t in self.core.tracklist.get_tracks().get()] assert result == ["a", "e", "c", "d", "b", "ǂ"] self.assertInResponse("OK") def test_swapid(self): self.send_request('swapid "2" "5"') result = [t.name for t in self.core.tracklist.get_tracks().get()] assert result == ["a", "e", "c", "d", "b", "ǂ"] self.assertInResponse("OK") def test_swapid_with_first_id_unknown_should_ack(self): self.send_request('swapid "1" "8"') self.assertEqualResponse("ACK [50@0] {swapid} No such song") def test_swapid_with_second_id_unknown_should_ack(self): self.send_request('swapid "8" "1"') self.assertEqualResponse("ACK [50@0] {swapid} No such song") class TagCommandTest(protocol.BaseTestCase): def test_addtagid(self): self.send_request("addtagid 17 artist Abba") self.assertEqualResponse("ACK [0@0] {addtagid} Not implemented") def test_cleartagid(self): self.send_request("cleartagid 17 artist") self.assertEqualResponse("ACK [0@0] {cleartagid} Not implemented") ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1793654065.9655182 mopidy_mpd-4.1.0/tests/protocol/test_idle.py0000644000175100017510000001764315272176462020703 0ustar00runnerrunnerfrom unittest.mock import patch from mopidy_mpd.protocol.status import SUBSYSTEMS from tests import protocol class IdleHandlerTest(protocol.BaseTestCase): def idle_event(self, subsystem): self.session.on_event(subsystem) def assertEqualEvents(self, events): # noqa: N802 assert self.dispatcher.subsystem_events == set(events) def assertEqualSubscriptions(self, events): # noqa: N802 assert self.dispatcher.subsystem_subscriptions == set(events) def assertNoEvents(self): # noqa: N802 self.assertEqualEvents([]) def assertNoSubscriptions(self): # noqa: N802 self.assertEqualSubscriptions([]) def test_base_state(self): self.assertNoSubscriptions() self.assertNoEvents() self.assertNoResponse() def test_idle(self): self.send_request("idle") self.assertEqualSubscriptions(SUBSYSTEMS) self.assertNoEvents() self.assertNoResponse() def test_idle_disables_timeout(self): self.send_request("idle") self.connection.disable_timeout.assert_called_once_with() def test_noidle(self): self.send_request("noidle") self.assertNoSubscriptions() self.assertNoEvents() self.assertNoResponse() def test_idle_player(self): self.send_request("idle player") self.assertEqualSubscriptions(["player"]) self.assertNoEvents() self.assertNoResponse() def test_idle_output(self): self.send_request("idle output") self.assertEqualSubscriptions(["output"]) self.assertNoEvents() self.assertNoResponse() def test_idle_player_playlist(self): self.send_request("idle player playlist") self.assertEqualSubscriptions(["player", "playlist"]) self.assertNoEvents() self.assertNoResponse() def test_idle_then_noidle(self): self.send_request("idle") self.send_request("noidle") self.assertNoSubscriptions() self.assertNoEvents() self.assertOnceInResponse("OK") def test_idle_then_noidle_enables_timeout(self): self.send_request("idle") self.send_request("noidle") self.connection.enable_timeout.assert_called_once_with() def test_idle_then_play(self): with patch.object(self.session, "stop") as stop_mock: self.send_request("idle") self.send_request("play") stop_mock.assert_called_once_with() def test_idle_then_idle(self): with patch.object(self.session, "stop") as stop_mock: self.send_request("idle") self.send_request("idle") stop_mock.assert_called_once_with() def test_idle_player_then_play(self): with patch.object(self.session, "stop") as stop_mock: self.send_request("idle player") self.send_request("play") stop_mock.assert_called_once_with() def test_idle_then_player(self): self.send_request("idle") self.idle_event("player") self.assertNoSubscriptions() self.assertNoEvents() self.assertOnceInResponse("changed: player") self.assertOnceInResponse("OK") def test_idle_player_then_event_player(self): self.send_request("idle player") self.idle_event("player") self.assertNoSubscriptions() self.assertNoEvents() self.assertOnceInResponse("changed: player") self.assertOnceInResponse("OK") def test_idle_then_output(self): self.send_request("idle") self.idle_event("output") self.assertNoSubscriptions() self.assertNoEvents() self.assertOnceInResponse("changed: output") self.assertOnceInResponse("OK") def test_idle_output_then_event_output(self): self.send_request("idle output") self.idle_event("output") self.assertNoSubscriptions() self.assertNoEvents() self.assertOnceInResponse("changed: output") self.assertOnceInResponse("OK") def test_idle_player_then_noidle(self): self.send_request("idle player") self.send_request("noidle") self.assertNoSubscriptions() self.assertNoEvents() self.assertOnceInResponse("OK") def test_idle_player_playlist_then_noidle(self): self.send_request("idle player playlist") self.send_request("noidle") self.assertNoEvents() self.assertNoSubscriptions() self.assertOnceInResponse("OK") def test_idle_player_playlist_then_player(self): self.send_request("idle player playlist") self.idle_event("player") self.assertNoEvents() self.assertNoSubscriptions() self.assertOnceInResponse("changed: player") self.assertNotInResponse("changed: playlist") self.assertOnceInResponse("OK") def test_idle_playlist_then_player(self): self.send_request("idle playlist") self.idle_event("player") self.assertEqualEvents(["player"]) self.assertEqualSubscriptions(["playlist"]) self.assertNoResponse() def test_idle_playlist_then_player_then_playlist(self): self.send_request("idle playlist") self.idle_event("player") self.idle_event("playlist") self.assertNoEvents() self.assertNoSubscriptions() self.assertNotInResponse("changed: player") self.assertOnceInResponse("changed: playlist") self.assertOnceInResponse("OK") def test_player(self): self.idle_event("player") self.assertEqualEvents(["player"]) self.assertNoSubscriptions() self.assertNoResponse() def test_player_then_idle_player(self): self.idle_event("player") self.send_request("idle player") self.assertNoEvents() self.assertNoSubscriptions() self.assertOnceInResponse("changed: player") self.assertNotInResponse("changed: playlist") self.assertOnceInResponse("OK") def test_player_then_playlist(self): self.idle_event("player") self.idle_event("playlist") self.assertEqualEvents(["player", "playlist"]) self.assertNoSubscriptions() self.assertNoResponse() def test_player_then_idle(self): self.idle_event("player") self.send_request("idle") self.assertNoEvents() self.assertNoSubscriptions() self.assertOnceInResponse("changed: player") self.assertOnceInResponse("OK") def test_player_then_playlist_then_idle(self): self.idle_event("player") self.idle_event("playlist") self.send_request("idle") self.assertNoEvents() self.assertNoSubscriptions() self.assertOnceInResponse("changed: player") self.assertOnceInResponse("changed: playlist") self.assertOnceInResponse("OK") def test_player_then_idle_playlist(self): self.idle_event("player") self.send_request("idle playlist") self.assertEqualEvents(["player"]) self.assertEqualSubscriptions(["playlist"]) self.assertNoResponse() def test_player_then_idle_playlist_then_noidle(self): self.idle_event("player") self.send_request("idle playlist") self.send_request("noidle") self.assertNoEvents() self.assertNoSubscriptions() self.assertOnceInResponse("OK") def test_player_then_playlist_then_idle_playlist(self): self.idle_event("player") self.idle_event("playlist") self.send_request("idle playlist") self.assertNoEvents() self.assertNoSubscriptions() self.assertNotInResponse("changed: player") self.assertOnceInResponse("changed: playlist") self.assertOnceInResponse("OK") def test_output_then_idle_toggleoutput(self): self.idle_event("output") self.send_request("idle output") self.assertNoEvents() self.assertNoSubscriptions() self.assertOnceInResponse("changed: output") self.assertOnceInResponse("OK") ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1793654065.9655182 mopidy_mpd-4.1.0/tests/protocol/test_mount.py0000644000175100017510000000123215272176462021113 0ustar00runnerrunnerfrom tests import protocol class MountTest(protocol.BaseTestCase): def test_mount(self): self.send_request("mount my_disk /dev/sda") self.assertEqualResponse("ACK [0@0] {mount} Not implemented") def test_unmount(self): self.send_request("unmount my_disk") self.assertEqualResponse("ACK [0@0] {unmount} Not implemented") def test_listmounts(self): self.send_request("listmounts") self.assertEqualResponse("ACK [0@0] {listmounts} Not implemented") def test_listneighbors(self): self.send_request("listneighbors") self.assertEqualResponse("ACK [0@0] {listneighbors} Not implemented") ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1793654065.9655182 mopidy_mpd-4.1.0/tests/protocol/test_music_db.py0000644000175100017510000014542715272176462021555 0ustar00runnerrunnerimport unittest from unittest import mock from mopidy.models import Album, Artist, Playlist, Ref, SearchResult, Track from mopidy.types import Uri from mopidy_mpd.protocol import music_db, stored_playlists from tests import protocol # TODO: split into more modules for faster parallel tests? class QueryForSearchTest(unittest.TestCase): def test_dates_are_extracted(self): result = music_db._query_for_search(["Date", "1974-01-02", "Date", "1975"]) assert result["date"] == ["1974-01-02", "1975"] def test_empty_value_is_ignored(self): result = music_db._query_for_search(["Date", ""]) assert result == {} def test_whitespace_value_is_ignored(self): result = music_db._query_for_search(["Date", " "]) assert result == {} # TODO Test more mappings class SplitWindowTest(unittest.TestCase): def test_without_window(self): args, window = music_db._split_window(("artist", "foo")) assert list(args) == ["artist", "foo"] assert window == slice(None) def test_with_window(self): args, window = music_db._split_window(("artist", "foo", "window", "1:3")) assert list(args) == ["artist", "foo"] assert window == slice(1, 3) def test_window_as_search_value_is_not_split(self): args, window = music_db._split_window(("artist", "window")) assert list(args) == ["artist", "window"] assert window == slice(None) class QueryFromMpdListFormatTest(unittest.TestCase): pass # TODO class TagMappingTest(unittest.TestCase): def test_is_list_name_mapping_for_every_field(self): for field in music_db._LIST_MAPPING.values(): assert field in music_db._LIST_NAME_MAPPING def test_list_mapping_does_not_includes_any(self): assert "any" not in music_db._LIST_NAME_MAPPING def test_search_mapping_includes_any(self): assert "any" in music_db._SEARCH_MAPPING # TODO: why isn't core.playlists.filter getting deprecation warnings? class MusicDatabaseHandlerTest(protocol.BaseTestCase): def test_count(self): self.send_request('count "artist" "needle"') self.assertInResponse("songs: 0") self.assertInResponse("playtime: 0") self.assertInResponse("OK") def test_count_without_quotes(self): self.send_request('count artist "needle"') self.assertInResponse("songs: 0") self.assertInResponse("playtime: 0") self.assertInResponse("OK") def test_count_with_multiple_pairs(self): self.send_request('count "artist" "foo" "album" "bar"') self.assertInResponse("songs: 0") self.assertInResponse("playtime: 0") self.assertInResponse("OK") def test_count_correct_length(self): # Count the lone track self.backend.library.dummy_find_exact_result = SearchResult( tracks=[Track(uri="dummy:a", name="foo", date="2001", length=4000)] ) self.send_request('count "title" "foo"') self.assertInResponse("songs: 1") self.assertInResponse("playtime: 4") self.assertInResponse("OK") # Count multiple tracks self.backend.library.dummy_find_exact_result = SearchResult( tracks=[ Track(uri="dummy:b", date="2001", length=50000), Track(uri="dummy:c", date="2001", length=600000), ] ) self.send_request('count "date" "2001"') self.assertInResponse("songs: 2") self.assertInResponse("playtime: 650") self.assertInResponse("OK") def test_count_with_track_length_none(self): self.backend.library.dummy_find_exact_result = SearchResult( tracks=[Track(uri="dummy:b", date="2001", length=None)] ) self.send_request('count "date" "2001"') self.assertInResponse("songs: 1") self.assertInResponse("playtime: 0") self.assertInResponse("OK") def test_findadd(self): track = Track(uri="dummy:a", name="A") self.backend.library.dummy_library = [track] self.backend.library.dummy_find_exact_result = SearchResult(tracks=[track]) assert self.core.tracklist.get_length().get() == 0 self.send_request('findadd "title" "A"') assert self.core.tracklist.get_length().get() == 1 assert self.core.tracklist.get_tracks().get()[0].uri == "dummy:a" self.assertInResponse("OK") def test_searchadd(self): track = Track(uri="dummy:a", name="A") self.backend.library.dummy_library = [track] self.backend.library.dummy_search_result = SearchResult(tracks=[track]) assert self.core.tracklist.get_length().get() == 0 self.send_request('searchadd "title" "a"') assert self.core.tracklist.get_length().get() == 1 assert self.core.tracklist.get_tracks().get()[0].uri == "dummy:a" self.assertInResponse("OK") def test_searchaddpl_appends_to_existing_playlist(self): playlist = self.core.playlists.create("my favs").get() playlist = playlist.replace( tracks=[ Track(uri="dummy:x", name="X"), Track(uri="dummy:y", name="y"), ] ) self.core.playlists.save(playlist) self.backend.library.dummy_search_result = SearchResult( tracks=[Track(uri="dummy:a", name="A")] ) items = self.core.playlists.get_items(playlist.uri).get() assert len(items) == 2 self.send_request('searchaddpl "my favs" "title" "a"') items = self.core.playlists.get_items(playlist.uri).get() assert len(items) == 3 assert items[0].uri == "dummy:x" assert items[1].uri == "dummy:y" assert items[2].uri == "dummy:a" self.assertInResponse("OK") def test_searchaddpl_creates_missing_playlist(self): self.backend.library.dummy_search_result = SearchResult( tracks=[Track(uri="dummy:a", name="A")] ) playlists = self.core.playlists.as_list().get() assert "my favs" not in {p.name for p in playlists} self.send_request('searchaddpl "my favs" "title" "a"') playlists = self.core.playlists.as_list().get() playlist = {p.name: p for p in playlists}["my favs"] items = self.core.playlists.get_items(playlist.uri).get() assert len(items) == 1 assert items[0].uri == "dummy:a" self.assertInResponse("OK") def test_listall_without_uri(self): tracks = [ Track(uri="dummy:/a", name="a"), Track(uri="dummy:/foo/b", name="b"), ] self.backend.library.dummy_library = tracks self.backend.library.dummy_browse_result = { "dummy:/": [ Ref.track(uri="dummy:/a", name="a"), Ref.directory(uri="dummy:/foo", name="foo"), Ref.album(uri="dummy:/album", name="album"), Ref.artist(uri="dummy:/artist", name="artist"), Ref.playlist(uri="dummy:/pl", name="pl"), ], "dummy:/foo": [Ref.track(uri="dummy:/foo/b", name="b")], } self.send_request("listall") self.assertInResponse("file: dummy:/a") self.assertInResponse("directory: dummy/foo") self.assertInResponse("directory: dummy/album") self.assertInResponse("directory: dummy/artist") self.assertInResponse("directory: dummy/pl") self.assertInResponse("file: dummy:/foo/b") self.assertInResponse("OK") def test_listall_with_uri(self): tracks = [ Track(uri="dummy:/a", name="a"), Track(uri="dummy:/foo/b", name="b"), ] self.backend.library.dummy_library = tracks self.backend.library.dummy_browse_result = { "dummy:/": [ Ref.track(uri="dummy:/a", name="a"), Ref.directory(uri="dummy:/foo", name="foo"), ], "dummy:/foo": [Ref.track(uri="dummy:/foo/b", name="b")], } self.send_request('listall "/dummy/foo"') self.assertNotInResponse("file: dummy:/a") self.assertInResponse("directory: dummy/foo") self.assertInResponse("file: dummy:/foo/b") self.assertInResponse("OK") def test_listall_with_unknown_uri(self): self.send_request('listall "/unknown"') self.assertEqualResponse("ACK [50@0] {listall} Not found") def test_listall_for_dir_with_and_without_leading_slash_is_the_same(self): self.backend.library.dummy_browse_result = { "dummy:/": [ Ref.track(uri="dummy:/a", name="a"), Ref.directory(uri="dummy:/foo", name="foo"), ] } response1 = self.send_request('listall "dummy"') response2 = self.send_request('listall "/dummy"') assert response1 == response2 def test_listall_for_dir_with_and_without_trailing_slash_is_the_same(self): self.backend.library.dummy_browse_result = { "dummy:/": [ Ref.track(uri="dummy:/a", name="a"), Ref.directory(uri="dummy:/foo", name="foo"), ] } response1 = self.send_request('listall "dummy"') response2 = self.send_request('listall "dummy/"') assert response1 == response2 def test_listall_duplicate(self): self.backend.library.dummy_browse_result = { "dummy:/": [ Ref.directory(uri="dummy:/a1", name="a"), Ref.directory(uri="dummy:/a2", name="a"), ] } self.send_request("listall") self.assertInResponse("directory: dummy/a") self.assertInResponse("directory: dummy/a [2]") def test_listallinfo_without_uri(self): tracks = [ Track(uri="dummy:/a", name="a"), Track(uri="dummy:/foo/b", name="b"), ] self.backend.library.dummy_library = tracks self.backend.library.dummy_browse_result = { "dummy:/": [ Ref.track(uri="dummy:/a", name="a"), Ref.directory(uri="dummy:/foo", name="foo"), Ref.album(uri="dummy:/album", name="album"), Ref.artist(uri="dummy:/artist", name="artist"), Ref.playlist(uri="dummy:/pl", name="pl"), ], "dummy:/foo": [Ref.track(uri="dummy:/foo/b", name="b")], } self.send_request("listallinfo") self.assertInResponse("file: dummy:/a") self.assertInResponse("Title: a") self.assertInResponse("directory: dummy/foo") self.assertInResponse("directory: dummy/album") self.assertInResponse("directory: dummy/artist") self.assertInResponse("directory: dummy/pl") self.assertInResponse("file: dummy:/foo/b") self.assertInResponse("Title: b") self.assertInResponse("OK") def test_listallinfo_with_uri(self): tracks = [ Track(uri="dummy:/a", name="a"), Track(uri="dummy:/foo/b", name="b"), ] self.backend.library.dummy_library = tracks self.backend.library.dummy_browse_result = { "dummy:/": [ Ref.track(uri="dummy:/a", name="a"), Ref.directory(uri="dummy:/foo", name="foo"), ], "dummy:/foo": [Ref.track(uri="dummy:/foo/b", name="b")], } self.send_request('listallinfo "/dummy/foo"') self.assertNotInResponse("file: dummy:/a") self.assertNotInResponse("Title: a") self.assertInResponse("directory: dummy/foo") self.assertInResponse("file: dummy:/foo/b") self.assertInResponse("Title: b") self.assertInResponse("OK") def test_listallinfo_with_unknown_uri(self): self.send_request('listallinfo "/unknown"') self.assertEqualResponse("ACK [50@0] {listallinfo} Not found") def test_listallinfo_for_dir_with_and_without_leading_slash_is_same(self): self.backend.library.dummy_browse_result = { "dummy:/": [ Ref.track(uri="dummy:/a", name="a"), Ref.directory(uri="dummy:/foo", name="foo"), ] } response1 = self.send_request('listallinfo "dummy"') response2 = self.send_request('listallinfo "/dummy"') assert response1 == response2 def test_listallinfo_for_dir_with_and_without_trailing_slash_is_same(self): self.backend.library.dummy_browse_result = { "dummy:/": [ Ref.track(uri="dummy:/a", name="a"), Ref.directory(uri="dummy:/foo", name="foo"), ] } response1 = self.send_request('listallinfo "dummy"') response2 = self.send_request('listallinfo "dummy/"') assert response1 == response2 def test_listallinfo_duplicate(self): self.backend.library.dummy_browse_result = { "dummy:/": [ Ref.directory(uri="dummy:/a1", name="a"), Ref.directory(uri="dummy:/a2", name="a"), ] } self.send_request("listallinfo") self.assertInResponse("directory: dummy/a") self.assertInResponse("directory: dummy/a [2]") def test_listfiles(self): self.send_request("listfiles") self.assertEqualResponse("ACK [0@0] {listfiles} Not implemented") @mock.patch.object(stored_playlists, "_get_last_modified") def test_lsinfo_without_path_returns_same_as_for_root(self, last_modified_mock): last_modified_mock.return_value = "2015-08-05T22:51:06Z" self.backend.playlists.set_dummy_playlists([Playlist(name="a", uri="dummy:/a")]) response1 = self.send_request("lsinfo") response2 = self.send_request('lsinfo "/"') assert response1 == response2 @mock.patch.object(stored_playlists, "_get_last_modified") def test_lsinfo_with_empty_path_returns_same_as_for_root(self, last_modified_mock): last_modified_mock.return_value = "2015-08-05T22:51:06Z" self.backend.playlists.set_dummy_playlists([Playlist(name="a", uri="dummy:/a")]) response1 = self.send_request('lsinfo ""') response2 = self.send_request('lsinfo "/"') assert response1 == response2 @mock.patch.object(stored_playlists, "_get_last_modified") def test_lsinfo_for_root_includes_playlists(self, last_modified_mock): last_modified_mock.return_value = "2015-08-05T22:51:06Z" self.backend.playlists.set_dummy_playlists([Playlist(name="a", uri="dummy:/a")]) self.send_request('lsinfo "/"') self.assertInResponse("playlist: a") self.assertInResponse("Last-Modified: 2015-08-05T22:51:06Z") self.assertInResponse("OK") def test_lsinfo_for_root_includes_dirs_for_each_lib_with_content(self): self.backend.library.dummy_browse_result = { "dummy:/": [ Ref.track(uri="dummy:/a", name="a"), Ref.directory(uri="dummy:/foo", name="foo"), ] } self.send_request('lsinfo "/"') self.assertInResponse("directory: dummy") self.assertInResponse("OK") @mock.patch.object(stored_playlists, "_get_last_modified") def test_lsinfo_for_dir_with_and_without_leading_slash_is_the_same( self, last_modified_mock ): last_modified_mock.return_value = "2015-08-05T22:51:06Z" self.backend.library.dummy_browse_result = { "dummy:/": [ Ref.track(uri="dummy:/a", name="a"), Ref.directory(uri="dummy:/foo", name="foo"), ] } response1 = self.send_request('lsinfo "dummy"') response2 = self.send_request('lsinfo "/dummy"') assert response1 == response2 @mock.patch.object(stored_playlists, "_get_last_modified") def test_lsinfo_for_dir_with_and_without_trailing_slash_is_the_same( self, last_modified_mock ): last_modified_mock.return_value = "2015-08-05T22:51:06Z" self.backend.library.dummy_browse_result = { "dummy:/": [ Ref.track(uri="dummy:/a", name="a"), Ref.directory(uri="dummy:/foo", name="foo"), ] } response1 = self.send_request('lsinfo "dummy"') response2 = self.send_request('lsinfo "dummy/"') assert response1 == response2 def test_lsinfo_for_dir_includes_tracks(self): self.backend.library.dummy_library = [ Track(uri="dummy:/a", name="a"), ] self.backend.library.dummy_browse_result = { "dummy:/": [Ref.track(uri="dummy:/a", name="a")] } self.send_request('lsinfo "/dummy"') self.assertInResponse("file: dummy:/a") self.assertInResponse("Title: a") self.assertInResponse("OK") def test_lsinfo_for_dir_includes_subdirs(self): self.backend.library.dummy_browse_result = { "dummy:/": [Ref.directory(uri="dummy:/foo", name="foo")] } self.send_request('lsinfo "/dummy"') self.assertInResponse("directory: dummy/foo") self.assertInResponse("OK") def test_lsinfo_for_empty_dir_returns_nothing(self): self.backend.library.dummy_browse_result = {"dummy:/": []} self.send_request('lsinfo "/dummy"') self.assertInResponse("OK") def test_lsinfo_for_dir_does_not_recurse(self): self.backend.library.dummy_library = [ Track(uri="dummy:/a", name="a"), ] self.backend.library.dummy_browse_result = { "dummy:/": [Ref.directory(uri="dummy:/foo", name="foo")], "dummy:/foo": [Ref.track(uri="dummy:/a", name="a")], } self.send_request('lsinfo "/dummy"') self.assertNotInResponse("file: dummy:/a") self.assertInResponse("OK") def test_lsinfo_for_dir_does_not_include_self(self): self.backend.library.dummy_browse_result = { "dummy:/": [Ref.directory(uri="dummy:/foo", name="foo")], "dummy:/foo": [Ref.track(uri="dummy:/a", name="a")], } self.send_request('lsinfo "/dummy"') self.assertNotInResponse("directory: dummy") self.assertInResponse("OK") def test_lsinfo_for_root_returns_browse_result_before_playlists(self): self.backend.library.dummy_browse_result = { "dummy:/": [ Ref.track(uri="dummy:/a", name="a"), Ref.directory(uri="dummy:/foo", name="foo"), ] } self.backend.playlists.set_dummy_playlists([Playlist(name="a", uri="dummy:/a")]) response = self.send_request('lsinfo "/"') assert response.index("directory: dummy") < response.index("playlist: a") def test_lsinfo_duplicate(self): self.backend.library.dummy_browse_result = { "dummy:/": [ Ref.directory(uri="dummy:/a1", name="a"), Ref.directory(uri="dummy:/a2", name="a"), ] } self.send_request('lsinfo "/dummy"') self.assertInResponse("directory: dummy/a") self.assertInResponse("directory: dummy/a [2]") def test_update_without_uri(self): self.send_request("update") self.assertInResponse("updating_db: 0") self.assertInResponse("OK") def test_update_with_uri(self): self.send_request('update "file:///dev/urandom"') self.assertInResponse("updating_db: 0") self.assertInResponse("OK") def test_rescan_without_uri(self): self.send_request("rescan") self.assertInResponse("updating_db: 0") self.assertInResponse("OK") def test_rescan_with_uri(self): self.send_request('rescan "file:///dev/urandom"') self.assertInResponse("updating_db: 0") self.assertInResponse("OK") class MusicDatabaseFindTest(protocol.BaseTestCase): def test_find_includes_fake_artist_and_album_tracks(self): self.backend.library.dummy_find_exact_result = SearchResult( albums=[Album(uri="dummy:album:a", name="A", date="2001")], artists=[Artist(uri="dummy:artist:b", name="B")], tracks=[Track(uri="dummy:track:c", name="C")], ) self.send_request('find "any" "foo"') self.assertInResponse("file: dummy:artist:b") self.assertInResponse("Title: Artist: B") self.assertInResponse("file: dummy:album:a") self.assertInResponse("Title: Album: A") self.assertInResponse("Date: 2001") self.assertInResponse("file: dummy:track:c") self.assertInResponse("Title: C") self.assertInResponse("OK") def test_find_with_window(self): self.backend.library.dummy_find_exact_result = SearchResult( albums=[Album(uri="dummy:album:a", name="A")], artists=[Artist(uri="dummy:artist:b", name="B")], tracks=[Track(uri="dummy:track:c", name="C")], ) self.send_request('find "any" "foo" window 1:3') self.assertNotInResponse("file: dummy:artist:b") self.assertInResponse("file: dummy:album:a") self.assertInResponse("file: dummy:track:c") self.assertInResponse("OK") def test_find_with_window_past_end_of_results(self): self.backend.library.dummy_find_exact_result = SearchResult( tracks=[Track(uri="dummy:track:c", name="C")], ) self.send_request('find "any" "foo" window 5:10') self.assertEqualResponse("OK") def test_find_with_invalid_window(self): self.send_request('find "any" "foo" window "3:1"') self.assertEqualResponse("ACK [2@0] {find} incorrect arguments") def test_find_artist_does_not_include_fake_artist_tracks(self): self.backend.library.dummy_find_exact_result = SearchResult( albums=[Album(uri="dummy:album:a", name="A", date="2001")], artists=[Artist(uri="dummy:artist:b", name="B")], tracks=[Track(uri="dummy:track:c", name="C")], ) self.send_request('find "artist" "foo"') self.assertNotInResponse("file: dummy:artist:b") self.assertNotInResponse("Title: Artist: B") self.assertInResponse("file: dummy:album:a") self.assertInResponse("Title: Album: A") self.assertInResponse("Date: 2001") self.assertInResponse("file: dummy:track:c") self.assertInResponse("Title: C") self.assertInResponse("OK") def test_find_albumartist_does_not_include_fake_artist_tracks(self): self.backend.library.dummy_find_exact_result = SearchResult( albums=[Album(uri="dummy:album:a", name="A", date="2001")], artists=[Artist(uri="dummy:artist:b", name="B")], tracks=[Track(uri="dummy:track:c", name="C")], ) self.send_request('find "albumartist" "foo"') self.assertNotInResponse("file: dummy:artist:b") self.assertNotInResponse("Title: Artist: B") self.assertInResponse("file: dummy:album:a") self.assertInResponse("Title: Album: A") self.assertInResponse("Date: 2001") self.assertInResponse("file: dummy:track:c") self.assertInResponse("Title: C") self.assertInResponse("OK") def test_find_artist_and_album_does_not_include_fake_tracks(self): self.backend.library.dummy_find_exact_result = SearchResult( albums=[Album(uri="dummy:album:a", name="A", date="2001")], artists=[Artist(uri="dummy:artist:b", name="B")], tracks=[Track(uri="dummy:track:c", name="C")], ) self.send_request('find "artist" "foo" "album" "bar"') self.assertNotInResponse("file: dummy:artist:b") self.assertNotInResponse("Title: Artist: B") self.assertNotInResponse("file: dummy:album:a") self.assertNotInResponse("Title: Album: A") self.assertNotInResponse("Date: 2001") self.assertInResponse("file: dummy:track:c") self.assertInResponse("Title: C") self.assertInResponse("OK") def test_find_album(self): self.send_request('find "album" "what"') self.assertInResponse("OK") def test_find_album_without_quotes(self): self.send_request('find album "what"') self.assertInResponse("OK") def test_find_artist(self): self.send_request('find "artist" "what"') self.assertInResponse("OK") def test_find_artist_without_quotes(self): self.send_request('find artist "what"') self.assertInResponse("OK") def test_find_albumartist(self): self.send_request('find "albumartist" "what"') self.assertInResponse("OK") def test_find_albumartist_without_quotes(self): self.send_request('find albumartist "what"') self.assertInResponse("OK") def test_find_composer(self): self.send_request('find "composer" "what"') self.assertInResponse("OK") def test_find_composer_without_quotes(self): self.send_request('find composer "what"') self.assertInResponse("OK") def test_find_performer(self): self.send_request('find "performer" "what"') self.assertInResponse("OK") def test_find_performer_without_quotes(self): self.send_request('find performer "what"') self.assertInResponse("OK") def test_find_filename(self): self.send_request('find "filename" "afilename"') self.assertInResponse("OK") def test_find_filename_without_quotes(self): self.send_request('find filename "afilename"') self.assertInResponse("OK") def test_find_file(self): self.send_request('find "file" "afilename"') self.assertInResponse("OK") def test_find_file_without_quotes(self): self.send_request('find file "afilename"') self.assertInResponse("OK") def test_find_title(self): self.send_request('find "title" "what"') self.assertInResponse("OK") def test_find_title_without_quotes(self): self.send_request('find title "what"') self.assertInResponse("OK") def test_find_track_no(self): self.send_request('find "track" "10"') self.assertInResponse("OK") def test_find_track_no_without_quotes(self): self.send_request('find track "10"') self.assertInResponse("OK") def test_find_track_no_without_filter_value(self): self.send_request('find "track" ""') self.assertInResponse("OK") def test_find_genre(self): self.send_request('find "genre" "what"') self.assertInResponse("OK") def test_find_genre_without_quotes(self): self.send_request('find genre "what"') self.assertInResponse("OK") def test_find_date(self): self.send_request('find "date" "2002-01-01"') self.assertInResponse("OK") def test_find_date_without_quotes(self): self.send_request('find date "2002-01-01"') self.assertInResponse("OK") def test_find_date_with_capital_d_and_incomplete_date(self): self.send_request('find Date "2005"') self.assertInResponse("OK") def test_find_else_should_fail(self): self.send_request('find "somethingelse" "what"') self.assertEqualResponse("ACK [2@0] {find} incorrect arguments") def test_find_album_and_artist(self): self.send_request('find album "album_what" artist "artist_what"') self.assertInResponse("OK") def test_find_without_filter_value(self): self.send_request('find "album" ""') self.assertInResponse("OK") class MusicDatabaseListTest(protocol.BaseTestCase): def test_list(self): self.backend.library.dummy_get_distinct_result = {"artist": {"A Artist"}} self.send_request('list "artist" "artist" "foo"') self.assertInResponse("Artist: A Artist") self.assertInResponse("OK") def test_list_foo_returns_ack(self): self.send_request('list "foO"') self.assertEqualResponse("ACK [2@0] {list} Unknown tag type: foO") def test_list_without_type_returns_ack(self): self.send_request("list") self.assertEqualResponse('ACK [2@0] {list} too few arguments for "list"') def test_list_all_supported_fields(self): # Be nice to use pytest's parametrize here. fields = [ "album", "albumartist", "artist", "comment", "composer", "date", "disc", "file", "filename", "genre", "musicbrainz_albumid", "musicbrainz_artistid", "musicbrainz_trackid", "performer", "title", "track", ] for field in fields: self.send_request(f'list "{field}"') self.assertInResponse("OK") # Track title def test_list_title(self): self.send_request('list "title"') self.assertInResponse("OK") def test_list_title_by_title(self): self.send_request('list "title" "title" "atitle"') self.assertInResponse("OK") # Artist def test_list_artist_with_quotes(self): self.send_request('list "artist"') self.assertInResponse("OK") def test_list_artist_without_quotes(self): self.send_request("list artist") self.assertInResponse("OK") def test_list_artist_without_quotes_and_capitalized(self): self.send_request("list Artist") self.assertInResponse("OK") def test_list_artist_with_query_of_one_token(self): self.send_request('list "artist" "anartist"') self.assertEqualResponse('ACK [2@0] {list} should be "Album" for 3 arguments') def test_list_artist_with_unknown_field_in_query_returns_ack(self): self.send_request('list "artist" "foo" "bar"') self.assertEqualResponse("ACK [2@0] {list} Unknown filter type") def test_list_artist_by_artist(self): self.send_request('list "artist" "artist" "anartist"') self.assertInResponse("OK") def test_list_artist_by_album(self): self.send_request('list "artist" "album" "analbum"') self.assertInResponse("OK") def test_list_artist_by_full_date(self): self.send_request('list "artist" "date" "2001-01-01"') self.assertInResponse("OK") def test_list_artist_by_year(self): self.send_request('list "artist" "date" "2001"') self.assertInResponse("OK") def test_list_artist_by_genre(self): self.send_request('list "artist" "genre" "agenre"') self.assertInResponse("OK") def test_list_artist_by_artist_and_album(self): self.send_request('list "artist" "artist" "anartist" "album" "analbum"') self.assertInResponse("OK") def test_list_artist_without_filter_value(self): self.send_request('list "artist" "artist" ""') self.assertInResponse("OK") def test_list_artist_should_not_return_artists_without_names(self): self.backend.library.dummy_find_exact_result = SearchResult( tracks=( Track( uri=Uri("test:foo"), performers=frozenset([Artist(name="")]), ), ) ) self.send_request('list "artist"') self.assertNotInResponse("Artist: ") self.assertInResponse("OK") # Albumartist def test_list_albumartist_with_quotes(self): self.send_request('list "albumartist"') self.assertInResponse("OK") def test_list_albumartist_without_quotes(self): self.send_request("list albumartist") self.assertInResponse("OK") def test_list_albumartist_without_quotes_and_capitalized(self): self.send_request("list Albumartist") self.assertInResponse("OK") def test_list_albumartist_with_query_of_one_token(self): self.send_request('list "albumartist" "anartist"') self.assertEqualResponse('ACK [2@0] {list} should be "Album" for 3 arguments') def test_list_albumartist_with_unknown_field_in_query_returns_ack(self): self.send_request('list "albumartist" "foo" "bar"') self.assertEqualResponse("ACK [2@0] {list} Unknown filter type") def test_list_albumartist_by_artist(self): self.send_request('list "albumartist" "artist" "anartist"') self.assertInResponse("OK") def test_list_albumartist_by_album(self): self.send_request('list "albumartist" "album" "analbum"') self.assertInResponse("OK") def test_list_albumartist_by_full_date(self): self.send_request('list "albumartist" "date" "2001-01-01"') self.assertInResponse("OK") def test_list_albumartist_by_year(self): self.send_request('list "albumartist" "date" "2001"') self.assertInResponse("OK") def test_list_albumartist_by_genre(self): self.send_request('list "albumartist" "genre" "agenre"') self.assertInResponse("OK") def test_list_albumartist_by_artist_and_album(self): self.send_request('list "albumartist" "artist" "anartist" "album" "analbum"') self.assertInResponse("OK") def test_list_albumartist_without_filter_value(self): self.send_request('list "albumartist" "artist" ""') self.assertInResponse("OK") def test_list_albumartist_should_not_return_artists_without_names(self): self.backend.library.dummy_find_exact_result = SearchResult( tracks=( Track( uri=Uri("test:foo"), performers=frozenset([Artist(name="")]), ), ) ) self.send_request('list "albumartist"') self.assertNotInResponse("Artist: ") self.assertNotInResponse("Albumartist: ") self.assertNotInResponse("Composer: ") self.assertNotInResponse("Performer: ") self.assertInResponse("OK") # Composer def test_list_composer_with_quotes(self): self.send_request('list "composer"') self.assertInResponse("OK") def test_list_composer_without_quotes(self): self.send_request("list composer") self.assertInResponse("OK") def test_list_composer_without_quotes_and_capitalized(self): self.send_request("list Composer") self.assertInResponse("OK") def test_list_composer_with_query_of_one_token(self): self.send_request('list "composer" "anartist"') self.assertEqualResponse('ACK [2@0] {list} should be "Album" for 3 arguments') def test_list_composer_with_unknown_field_in_query_returns_ack(self): self.send_request('list "composer" "foo" "bar"') self.assertEqualResponse("ACK [2@0] {list} Unknown filter type") def test_list_composer_by_artist(self): self.send_request('list "composer" "artist" "anartist"') self.assertInResponse("OK") def test_list_composer_by_album(self): self.send_request('list "composer" "album" "analbum"') self.assertInResponse("OK") def test_list_composer_by_full_date(self): self.send_request('list "composer" "date" "2001-01-01"') self.assertInResponse("OK") def test_list_composer_by_year(self): self.send_request('list "composer" "date" "2001"') self.assertInResponse("OK") def test_list_composer_by_genre(self): self.send_request('list "composer" "genre" "agenre"') self.assertInResponse("OK") def test_list_composer_by_artist_and_album(self): self.send_request('list "composer" "artist" "anartist" "album" "analbum"') self.assertInResponse("OK") def test_list_composer_without_filter_value(self): self.send_request('list "composer" "artist" ""') self.assertInResponse("OK") def test_list_composer_should_not_return_artists_without_names(self): self.backend.library.dummy_find_exact_result = SearchResult( tracks=( Track( uri=Uri("test:foo"), performers=frozenset([Artist(name="")]), ), ) ) self.send_request('list "composer"') self.assertNotInResponse("Artist: ") self.assertNotInResponse("Albumartist: ") self.assertNotInResponse("Composer: ") self.assertNotInResponse("Performer: ") self.assertInResponse("OK") # Performer def test_list_performer_with_quotes(self): self.send_request('list "performer"') self.assertInResponse("OK") def test_list_performer_without_quotes(self): self.send_request("list performer") self.assertInResponse("OK") def test_list_performer_without_quotes_and_capitalized(self): self.send_request("list Albumartist") self.assertInResponse("OK") def test_list_performer_with_query_of_one_token(self): self.send_request('list "performer" "anartist"') self.assertEqualResponse('ACK [2@0] {list} should be "Album" for 3 arguments') def test_list_performer_with_unknown_field_in_query_returns_ack(self): self.send_request('list "performer" "foo" "bar"') self.assertEqualResponse("ACK [2@0] {list} Unknown filter type") def test_list_performer_by_artist(self): self.send_request('list "performer" "artist" "anartist"') self.assertInResponse("OK") def test_list_performer_by_album(self): self.send_request('list "performer" "album" "analbum"') self.assertInResponse("OK") def test_list_performer_by_full_date(self): self.send_request('list "performer" "date" "2001-01-01"') self.assertInResponse("OK") def test_list_performer_by_year(self): self.send_request('list "performer" "date" "2001"') self.assertInResponse("OK") def test_list_performer_by_genre(self): self.send_request('list "performer" "genre" "agenre"') self.assertInResponse("OK") def test_list_performer_by_artist_and_album(self): self.send_request('list "performer" "artist" "anartist" "album" "analbum"') self.assertInResponse("OK") def test_list_performer_without_filter_value(self): self.send_request('list "performer" "artist" ""') self.assertInResponse("OK") def test_list_performer_should_not_return_artists_without_names(self): self.backend.library.dummy_find_exact_result = SearchResult( tracks=( Track( uri=Uri("test:foo"), performers=frozenset([Artist(name="")]), ), ) ) self.send_request('list "performer"') self.assertNotInResponse("Artist: ") self.assertNotInResponse("Albumartist: ") self.assertNotInResponse("Composer: ") self.assertNotInResponse("Performer: ") self.assertInResponse("OK") # Album def test_list_album_with_quotes(self): self.send_request('list "album"') self.assertInResponse("OK") def test_list_album_without_quotes(self): self.send_request("list album") self.assertInResponse("OK") def test_list_album_without_quotes_and_capitalized(self): self.send_request("list Album") self.assertInResponse("OK") def test_list_album_with_artist_name(self): self.backend.library.dummy_get_distinct_result = {"album": {"foo"}} self.send_request('list "album" "anartist"') self.assertInResponse("Album: foo") self.assertInResponse("OK") def test_list_album_with_artist_name_without_filter_value(self): self.send_request('list "album" ""') self.assertInResponse("OK") def test_list_album_by_artist(self): self.send_request('list "album" "artist" "anartist"') self.assertInResponse("OK") def test_list_album_by_album(self): self.send_request('list "album" "album" "analbum"') self.assertInResponse("OK") def test_list_album_by_albumartist(self): self.send_request('list "album" "albumartist" "anartist"') self.assertInResponse("OK") def test_list_album_by_composer(self): self.send_request('list "album" "composer" "anartist"') self.assertInResponse("OK") def test_list_album_by_performer(self): self.send_request('list "album" "performer" "anartist"') self.assertInResponse("OK") def test_list_album_by_full_date(self): self.send_request('list "album" "date" "2001-01-01"') self.assertInResponse("OK") def test_list_album_by_year(self): self.send_request('list "album" "date" "2001"') self.assertInResponse("OK") def test_list_album_by_genre(self): self.send_request('list "album" "genre" "agenre"') self.assertInResponse("OK") def test_list_album_by_artist_and_album(self): self.send_request('list "album" "artist" "anartist" "album" "analbum"') self.assertInResponse("OK") def test_list_album_without_filter_value(self): self.send_request('list "album" "artist" ""') self.assertInResponse("OK") def test_list_album_should_not_return_albums_without_names(self): self.backend.library.dummy_find_exact_result = SearchResult( tracks=( Track( uri=Uri("test:foo"), performers=frozenset([Artist(name="")]), ), ) ) self.send_request('list "album"') self.assertNotInResponse("Album: ") self.assertInResponse("OK") # Date def test_list_date_with_quotes(self): self.send_request('list "date"') self.assertInResponse("OK") def test_list_date_without_quotes(self): self.send_request("list date") self.assertInResponse("OK") def test_list_date_without_quotes_and_capitalized(self): self.send_request("list Date") self.assertInResponse("OK") def test_list_date_with_query_of_one_token(self): self.send_request('list "date" "anartist"') self.assertEqualResponse('ACK [2@0] {list} should be "Album" for 3 arguments') def test_list_date_by_artist(self): self.send_request('list "date" "artist" "anartist"') self.assertInResponse("OK") def test_list_date_by_album(self): self.send_request('list "date" "album" "analbum"') self.assertInResponse("OK") def test_list_date_by_full_date(self): self.send_request('list "date" "date" "2001-01-01"') self.assertInResponse("OK") def test_list_date_by_year(self): self.send_request('list "date" "date" "2001"') self.assertInResponse("OK") def test_list_date_by_genre(self): self.send_request('list "date" "genre" "agenre"') self.assertInResponse("OK") def test_list_date_by_artist_and_album(self): self.send_request('list "date" "artist" "anartist" "album" "analbum"') self.assertInResponse("OK") def test_list_date_without_filter_value(self): self.send_request('list "date" "artist" ""') self.assertInResponse("OK") # Genre def test_list_genre_with_quotes(self): self.send_request('list "genre"') self.assertInResponse("OK") def test_list_genre_without_quotes(self): self.send_request("list genre") self.assertInResponse("OK") def test_list_genre_without_quotes_and_capitalized(self): self.send_request("list Genre") self.assertInResponse("OK") def test_list_genre_with_query_of_one_token(self): self.send_request('list "genre" "anartist"') self.assertEqualResponse('ACK [2@0] {list} should be "Album" for 3 arguments') def test_list_genre_by_artist(self): self.send_request('list "genre" "artist" "anartist"') self.assertInResponse("OK") def test_list_genre_by_album(self): self.send_request('list "genre" "album" "analbum"') self.assertInResponse("OK") def test_list_genre_by_full_date(self): self.send_request('list "genre" "date" "2001-01-01"') self.assertInResponse("OK") def test_list_genre_by_year(self): self.send_request('list "genre" "date" "2001"') self.assertInResponse("OK") def test_list_genre_by_genre(self): self.send_request('list "genre" "genre" "agenre"') self.assertInResponse("OK") def test_list_genre_by_artist_and_album(self): self.send_request('list "genre" "artist" "anartist" "album" "analbum"') self.assertInResponse("OK") def test_list_genre_without_filter_value(self): self.send_request('list "genre" "artist" ""') self.assertInResponse("OK") class MusicDatabaseSearchTest(protocol.BaseTestCase): def test_search(self): self.backend.library.dummy_search_result = SearchResult( albums=[Album(uri="dummy:album:a", name="A")], artists=[Artist(uri="dummy:artist:b", name="B")], tracks=[Track(uri="dummy:track:c", name="C")], ) self.send_request('search "any" "foo"') self.assertInResponse("file: dummy:album:a") self.assertInResponse("Title: Album: A") self.assertInResponse("file: dummy:artist:b") self.assertInResponse("Title: Artist: B") self.assertInResponse("file: dummy:track:c") self.assertInResponse("Title: C") self.assertInResponse("OK") def test_search_with_window(self): self.backend.library.dummy_search_result = SearchResult( albums=[Album(uri="dummy:album:a", name="A")], artists=[Artist(uri="dummy:artist:b", name="B")], tracks=[Track(uri="dummy:track:c", name="C")], ) self.send_request('search "any" "foo" window 2') self.assertNotInResponse("file: dummy:artist:b") self.assertNotInResponse("file: dummy:album:a") self.assertInResponse("file: dummy:track:c") self.assertInResponse("OK") def test_search_with_invalid_window(self): self.send_request('search "any" "foo" window "x"') self.assertEqualResponse("ACK [2@0] {search} incorrect arguments") def test_search_album(self): self.send_request('search "album" "analbum"') self.assertInResponse("OK") def test_search_album_without_quotes(self): self.send_request('search album "analbum"') self.assertInResponse("OK") def test_search_album_without_filter_value(self): self.send_request('search "album" ""') self.assertInResponse("OK") def test_search_artist(self): self.send_request('search "artist" "anartist"') self.assertInResponse("OK") def test_search_artist_without_quotes(self): self.send_request('search artist "anartist"') self.assertInResponse("OK") def test_search_artist_without_filter_value(self): self.send_request('search "artist" ""') self.assertInResponse("OK") def test_search_albumartist(self): self.send_request('search "albumartist" "analbumartist"') self.assertInResponse("OK") def test_search_albumartist_without_quotes(self): self.send_request('search albumartist "analbumartist"') self.assertInResponse("OK") def test_search_albumartist_without_filter_value(self): self.send_request('search "albumartist" ""') self.assertInResponse("OK") def test_search_composer(self): self.send_request('search "composer" "acomposer"') self.assertInResponse("OK") def test_search_composer_without_quotes(self): self.send_request('search composer "acomposer"') self.assertInResponse("OK") def test_search_composer_without_filter_value(self): self.send_request('search "composer" ""') self.assertInResponse("OK") def test_search_performer(self): self.send_request('search "performer" "aperformer"') self.assertInResponse("OK") def test_search_performer_without_quotes(self): self.send_request('search performer "aperformer"') self.assertInResponse("OK") def test_search_performer_without_filter_value(self): self.send_request('search "performer" ""') self.assertInResponse("OK") def test_search_filename(self): self.send_request('search "filename" "afilename"') self.assertInResponse("OK") def test_search_filename_without_quotes(self): self.send_request('search filename "afilename"') self.assertInResponse("OK") def test_search_filename_without_filter_value(self): self.send_request('search "filename" ""') self.assertInResponse("OK") def test_search_file(self): self.send_request('search "file" "afilename"') self.assertInResponse("OK") def test_search_file_without_quotes(self): self.send_request('search file "afilename"') self.assertInResponse("OK") def test_search_file_without_filter_value(self): self.send_request('search "file" ""') self.assertInResponse("OK") def test_search_title(self): self.send_request('search "title" "atitle"') self.assertInResponse("OK") def test_search_title_without_quotes(self): self.send_request('search title "atitle"') self.assertInResponse("OK") def test_search_title_without_filter_value(self): self.send_request('search "title" ""') self.assertInResponse("OK") def test_search_any(self): self.send_request('search "any" "anything"') self.assertInResponse("OK") def test_search_any_without_quotes(self): self.send_request('search any "anything"') self.assertInResponse("OK") def test_search_any_without_filter_value(self): self.send_request('search "any" ""') self.assertInResponse("OK") def test_search_track_no(self): self.send_request('search "track" "10"') self.assertInResponse("OK") def test_search_track_no_without_quotes(self): self.send_request('search track "10"') self.assertInResponse("OK") def test_search_track_no_without_filter_value(self): self.send_request('search "track" ""') self.assertInResponse("OK") def test_search_genre(self): self.send_request('search "genre" "agenre"') self.assertInResponse("OK") def test_search_genre_without_quotes(self): self.send_request('search genre "agenre"') self.assertInResponse("OK") def test_search_genre_without_filter_value(self): self.send_request('search "genre" ""') self.assertInResponse("OK") def test_search_date(self): self.send_request('search "date" "2002-01-01"') self.assertInResponse("OK") def test_search_date_without_quotes(self): self.send_request('search date "2002-01-01"') self.assertInResponse("OK") def test_search_date_with_capital_d_and_incomplete_date(self): self.send_request('search Date "2005"') self.assertInResponse("OK") def test_search_date_without_filter_value(self): self.send_request('search "date" ""') self.assertInResponse("OK") def test_search_comment(self): self.send_request('search "comment" "acomment"') self.assertInResponse("OK") def test_search_comment_without_quotes(self): self.send_request('search comment "acomment"') self.assertInResponse("OK") def test_search_comment_without_filter_value(self): self.send_request('search "comment" ""') self.assertInResponse("OK") def test_search_else_should_fail(self): self.send_request('search "sometype" "something"') self.assertEqualResponse("ACK [2@0] {search} incorrect arguments") ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1793654065.9655182 mopidy_mpd-4.1.0/tests/protocol/test_playback.py0000644000175100017510000004354715272176462021556 0ustar00runnerrunnerimport unittest from mopidy.models import Track from mopidy.types import PlaybackState from tests import protocol PAUSED = PlaybackState.PAUSED PLAYING = PlaybackState.PLAYING STOPPED = PlaybackState.STOPPED class PlaybackOptionsHandlerTest(protocol.BaseTestCase): def test_consume_off(self): self.send_request('consume "0"') assert not self.core.tracklist.get_consume().get() self.assertInResponse("OK") def test_consume_off_without_quotes(self): self.send_request("consume 0") assert not self.core.tracklist.get_consume().get() self.assertInResponse("OK") def test_consume_on(self): self.send_request('consume "1"') assert self.core.tracklist.get_consume().get() self.assertInResponse("OK") def test_consume_on_without_quotes(self): self.send_request("consume 1") assert self.core.tracklist.get_consume().get() self.assertInResponse("OK") def test_crossfade(self): self.send_request('crossfade "10"') self.assertInResponse("ACK [0@0] {crossfade} Not implemented") def test_random_off(self): self.send_request('random "0"') assert not self.core.tracklist.get_random().get() self.assertInResponse("OK") def test_random_off_without_quotes(self): self.send_request("random 0") assert not self.core.tracklist.get_random().get() self.assertInResponse("OK") def test_random_on(self): self.send_request('random "1"') assert self.core.tracklist.get_random().get() self.assertInResponse("OK") def test_random_on_without_quotes(self): self.send_request("random 1") assert self.core.tracklist.get_random().get() self.assertInResponse("OK") def test_repeat_off(self): self.send_request('repeat "0"') assert not self.core.tracklist.get_repeat().get() self.assertInResponse("OK") def test_repeat_off_without_quotes(self): self.send_request("repeat 0") assert not self.core.tracklist.get_repeat().get() self.assertInResponse("OK") def test_repeat_on(self): self.send_request('repeat "1"') assert self.core.tracklist.get_repeat().get() self.assertInResponse("OK") def test_repeat_on_without_quotes(self): self.send_request("repeat 1") assert self.core.tracklist.get_repeat().get() self.assertInResponse("OK") def test_single_off(self): self.send_request('single "0"') assert not self.core.tracklist.get_single().get() self.assertInResponse("OK") def test_single_off_without_quotes(self): self.send_request("single 0") assert not self.core.tracklist.get_single().get() self.assertInResponse("OK") def test_single_on(self): self.send_request('single "1"') assert self.core.tracklist.get_single().get() self.assertInResponse("OK") def test_single_on_without_quotes(self): self.send_request("single 1") assert self.core.tracklist.get_single().get() self.assertInResponse("OK") def test_replay_gain_mode_off(self): self.send_request('replay_gain_mode "off"') self.assertInResponse("ACK [0@0] {replay_gain_mode} Not implemented") def test_replay_gain_mode_track(self): self.send_request('replay_gain_mode "track"') self.assertInResponse("ACK [0@0] {replay_gain_mode} Not implemented") def test_replay_gain_mode_album(self): self.send_request('replay_gain_mode "album"') self.assertInResponse("ACK [0@0] {replay_gain_mode} Not implemented") def test_replay_gain_status_default(self): self.send_request("replay_gain_status") self.assertInResponse("OK") self.assertInResponse("replay_gain_mode: off") def test_mixrampdb(self): self.send_request('mixrampdb "10"') self.assertInResponse("ACK [0@0] {mixrampdb} Not implemented") def test_mixrampdelay(self): self.send_request('mixrampdelay "10"') self.assertInResponse("ACK [0@0] {mixrampdelay} Not implemented") @unittest.SkipTest def test_replay_gain_status_off(self): pass @unittest.SkipTest def test_replay_gain_status_track(self): pass @unittest.SkipTest def test_replay_gain_status_album(self): pass class PlaybackControlHandlerTest(protocol.BaseTestCase): def setUp(self): super().setUp() self.tracks = [ Track(uri="dummy:a", length=40000), Track(uri="dummy:b", length=40000), ] self.backend.library.dummy_library = self.tracks self.core.tracklist.add(uris=[t.uri for t in self.tracks]).get() def test_next(self): self.core.tracklist.clear().get() self.send_request("next") self.assertInResponse("OK") def test_pause_off(self): self.send_request('play "0"') self.send_request('pause "1"') self.send_request('pause "0"') assert self.core.playback.get_state().get() == PLAYING self.assertInResponse("OK") def test_pause_on(self): self.send_request('play "0"') self.send_request('pause "1"') assert self.core.playback.get_state().get() == PAUSED self.assertInResponse("OK") def test_pause_toggle(self): self.send_request('play "0"') assert self.core.playback.get_state().get() == PLAYING self.assertInResponse("OK") # Deprecated version self.send_request("pause") assert self.core.playback.get_state().get() == PAUSED self.assertInResponse("OK") self.send_request("pause") assert self.core.playback.get_state().get() == PLAYING self.assertInResponse("OK") def test_play_without_pos(self): self.send_request("play") assert self.core.playback.get_state().get() == PLAYING self.assertInResponse("OK") def test_play_with_pos(self): self.send_request('play "0"') assert self.core.playback.get_state().get() == PLAYING self.assertInResponse("OK") def test_play_with_pos_without_quotes(self): self.send_request("play 0") assert self.core.playback.get_state().get() == PLAYING self.assertInResponse("OK") def test_play_with_pos_out_of_bounds(self): self.core.tracklist.clear().get() self.send_request('play "0"') assert self.core.playback.get_state().get() == STOPPED self.assertInResponse("ACK [2@0] {play} Bad song index") def test_play_minus_one_plays_first_in_playlist_if_no_current_track(self): assert self.core.playback.get_current_track().get() is None self.send_request('play "-1"') assert self.core.playback.get_state().get() == PLAYING assert self.core.playback.get_current_track().get().uri == "dummy:a" self.assertInResponse("OK") def test_play_minus_one_plays_current_track_if_current_track_is_set(self): assert self.core.playback.get_current_track().get() is None self.core.playback.play() self.core.playback.next() self.core.playback.stop().get() assert self.core.playback.get_current_track().get() is not None self.send_request('play "-1"') assert self.core.playback.get_state().get() == PLAYING assert self.core.playback.get_current_track().get().uri == "dummy:b" self.assertInResponse("OK") def test_play_minus_one_on_empty_playlist_does_not_ack(self): self.core.tracklist.clear() self.send_request('play "-1"') assert self.core.playback.get_state().get() == STOPPED assert self.core.playback.get_current_track().get() is None self.assertInResponse("OK") def test_play_minus_is_ignored_if_playing(self): self.core.playback.play().get() self.core.playback.seek(30000) assert self.core.playback.get_time_position().get() >= 30000 assert self.core.playback.get_state().get() == PLAYING self.send_request('play "-1"') assert self.core.playback.get_state().get() == PLAYING assert self.core.playback.get_time_position().get() >= 30000 self.assertInResponse("OK") def test_play_minus_one_resumes_if_paused(self): self.core.playback.play().get() self.core.playback.seek(30000) assert self.core.playback.get_time_position().get() >= 30000 assert self.core.playback.get_state().get() == PLAYING self.core.playback.pause() assert self.core.playback.get_state().get() == PAUSED self.send_request('play "-1"') assert self.core.playback.get_state().get() == PLAYING assert self.core.playback.get_time_position().get() >= 30000 self.assertInResponse("OK") def test_playid(self): self.send_request('playid "1"') assert self.core.playback.get_state().get() == PLAYING self.assertInResponse("OK") def test_playid_without_quotes(self): self.send_request("playid 1") assert self.core.playback.get_state().get() == PLAYING self.assertInResponse("OK") def test_playid_minus_1_plays_first_in_playlist_if_no_current_track(self): assert self.core.playback.get_current_track().get() is None self.send_request('playid "-1"') assert self.core.playback.get_state().get() == PLAYING assert self.core.playback.get_current_track().get().uri == "dummy:a" self.assertInResponse("OK") def test_playid_minus_1_plays_current_track_if_current_track_is_set(self): assert self.core.playback.get_current_track().get() is None self.core.playback.play().get() self.core.playback.next().get() self.core.playback.stop() assert self.core.playback.get_current_track().get() is not None self.send_request('playid "-1"') assert self.core.playback.get_state().get() == PLAYING assert self.core.playback.get_current_track().get().uri == "dummy:b" self.assertInResponse("OK") def test_playid_minus_one_on_empty_playlist_does_not_ack(self): self.core.tracklist.clear() self.send_request('playid "-1"') assert self.core.playback.get_state().get() == STOPPED assert self.core.playback.get_current_track().get() is None self.assertInResponse("OK") def test_playid_minus_is_ignored_if_playing(self): self.core.playback.play().get() self.core.playback.seek(30000) assert self.core.playback.get_time_position().get() >= 30000 assert self.core.playback.get_state().get() == PLAYING self.send_request('playid "-1"') assert self.core.playback.get_state().get() == PLAYING assert self.core.playback.get_time_position().get() >= 30000 self.assertInResponse("OK") def test_playid_minus_one_resumes_if_paused(self): self.core.playback.play().get() self.core.playback.seek(30000) assert self.core.playback.get_time_position().get() >= 30000 assert self.core.playback.get_state().get() == PLAYING self.core.playback.pause() assert self.core.playback.get_state().get() == PAUSED self.send_request('playid "-1"') assert self.core.playback.get_state().get() == PLAYING assert self.core.playback.get_time_position().get() >= 30000 self.assertInResponse("OK") def test_playid_which_does_not_exist(self): self.send_request('playid "12345"') self.assertInResponse("ACK [50@0] {playid} No such song") def test_previous(self): self.core.tracklist.clear().get() self.send_request("previous") self.assertInResponse("OK") def test_seek_in_current_track(self): self.core.playback.play() self.send_request('seek "0" "30"') current_track = self.core.playback.get_current_track().get() assert current_track == self.tracks[0] assert self.core.playback.get_time_position().get() >= 30000 self.assertInResponse("OK") def test_seek_in_another_track(self): self.core.playback.play() current_track = self.core.playback.get_current_track().get() assert current_track != self.tracks[1] self.send_request('seek "1" "30"') current_track = self.core.playback.get_current_track().get() assert current_track == self.tracks[1] self.assertInResponse("OK") def test_seek_without_quotes(self): self.core.playback.play() self.send_request("seek 0 30") assert self.core.playback.get_time_position().get() >= 30000 self.assertInResponse("OK") def test_seek_with_float(self): self.core.playback.play() self.send_request('seek "0" "30.1"') assert self.core.playback.get_time_position().get() >= 30100 self.assertInResponse("OK") def test_seekid_in_current_track(self): self.core.playback.play() self.send_request('seekid "1" "30"') current_track = self.core.playback.get_current_track().get() assert current_track == self.tracks[0] assert self.core.playback.get_time_position().get() >= 30000 self.assertInResponse("OK") def test_seekid_in_another_track(self): self.core.playback.play() self.send_request('seekid "2" "30"') current_tl_track = self.core.playback.get_current_tl_track().get() assert current_tl_track.tlid == 2 assert current_tl_track.track == self.tracks[1] self.assertInResponse("OK") def test_seekid_with_float(self): self.core.playback.play() self.send_request('seekid "1" "30.1"') current_track = self.core.playback.get_current_track().get() assert current_track == self.tracks[0] assert self.core.playback.get_time_position().get() >= 30100 self.assertInResponse("OK") def test_seekcur_absolute_value(self): self.core.playback.play().get() self.send_request('seekcur "30"') assert self.core.playback.get_time_position().get() >= 30000 self.assertInResponse("OK") def test_seekcur_positive_diff(self): self.core.playback.play().get() self.core.playback.seek(10000) assert self.core.playback.get_time_position().get() >= 10000 self.send_request('seekcur "+20"') assert self.core.playback.get_time_position().get() >= 30000 self.assertInResponse("OK") def test_seekcur_negative_diff(self): self.core.playback.play().get() self.core.playback.seek(30000) assert self.core.playback.get_time_position().get() >= 30000 self.send_request('seekcur "-20"') assert self.core.playback.get_time_position().get() <= 15000 self.assertInResponse("OK") def test_seekcur_absolute_float(self): self.core.playback.play().get() self.send_request('seekcur "30.1"') assert self.core.playback.get_time_position().get() >= 30100 self.assertInResponse("OK") def test_seekcur_negative_float(self): self.core.playback.play().get() self.core.playback.seek(30000) assert self.core.playback.get_time_position().get() >= 30000 self.send_request('seekcur "-20.1"') assert self.core.playback.get_time_position().get() <= 10000 self.assertInResponse("OK") def test_stop(self): self.core.tracklist.clear().get() self.send_request("stop") assert self.core.playback.get_state().get() == STOPPED self.assertInResponse("OK") class VolumeTest(protocol.BaseTestCase): def test_setvol_below_min(self): self.send_request('setvol "-10"') assert self.core.mixer.get_volume().get() == 0 self.assertInResponse("OK") def test_setvol_min(self): self.send_request('setvol "0"') assert self.core.mixer.get_volume().get() == 0 self.assertInResponse("OK") def test_setvol_middle(self): self.send_request('setvol "50"') assert self.core.mixer.get_volume().get() == 50 self.assertInResponse("OK") def test_setvol_max(self): self.send_request('setvol "100"') assert self.core.mixer.get_volume().get() == 100 self.assertInResponse("OK") def test_setvol_above_max(self): self.send_request('setvol "110"') assert self.core.mixer.get_volume().get() == 100 self.assertInResponse("OK") def test_setvol_plus_is_ignored(self): self.send_request('setvol "+10"') assert self.core.mixer.get_volume().get() == 10 self.assertInResponse("OK") def test_setvol_without_quotes(self): self.send_request("setvol 50") assert self.core.mixer.get_volume().get() == 50 self.assertInResponse("OK") def test_volume_plus(self): self.core.mixer.set_volume(50) self.send_request("volume +20") assert self.core.mixer.get_volume().get() == 70 self.assertInResponse("OK") def test_volume_minus(self): self.core.mixer.set_volume(50) self.send_request("volume -20") assert self.core.mixer.get_volume().get() == 30 self.assertInResponse("OK") def test_volume_less_than_minus_100(self): self.core.mixer.set_volume(50) self.send_request("volume -110") assert self.core.mixer.get_volume().get() == 50 self.assertInResponse("ACK [2@0] {volume} Invalid volume value") def test_volume_more_than_plus_100(self): self.core.mixer.set_volume(50) self.send_request("volume +110") assert self.core.mixer.get_volume().get() == 50 self.assertInResponse("ACK [2@0] {volume} Invalid volume value") class VolumeWithNoMixerTest(protocol.BaseTestCase): enable_mixer = False def test_setvol_without_mixer_fails(self): self.send_request('setvol "100"') self.assertInResponse("ACK [52@0] {setvol} problems setting volume") def test_volume_without_mixer_failes(self): self.send_request("volume +100") self.assertInResponse("ACK [52@0] {volume} problems setting volume") ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1793654065.9655182 mopidy_mpd-4.1.0/tests/protocol/test_reflection.py0000644000175100017510000001050615272176462022107 0ustar00runnerrunnerfrom unittest import mock from tests import protocol class ReflectionHandlerTest(protocol.BaseTestCase): def test_unimplemented_channels_are_not_advertised(self): for request in ("commands", "notcommands"): self.send_request(request) for name in ( "subscribe", "unsubscribe", "channels", "readmessages", "sendmessage", ): with self.subTest(request=request, command=name): self.assertNotInResponse(f"command: {name}") self.assertInResponse("OK") def test_config_is_not_allowed_across_the_network(self): self.send_request("config") self.assertEqualResponse( 'ACK [4@0] {config} you don\'t have permission for "config"' ) def test_commands_returns_list_of_all_commands(self): self.send_request("commands") # Check if some random commands are included self.assertInResponse("command: commands") self.assertInResponse("command: play") self.assertInResponse("command: status") # The dummy backend provides playlists self.assertInResponse("command: save") self.assertInResponse("command: listplaylists") # Check if commands you do not have access to are not present self.assertNotInResponse("command: config") self.assertNotInResponse("command: kill") # Check if the blacklisted commands are not present self.assertNotInResponse("command: command_list_begin") self.assertNotInResponse("command: command_list_ok_begin") self.assertNotInResponse("command: command_list_end") self.assertInResponse("command: idle") self.assertNotInResponse("command: noidle") self.assertNotInResponse("command: sticker") self.assertInResponse("OK") def test_commands_hides_playlist_commands_without_playlist_backend(self): with mock.patch( "mopidy.core.PlaylistsController.get_uri_schemes", return_value=[] ): self.send_request("commands") for name in ( "listplaylists", "playlistadd", "playlistclear", "playlistdelete", "playlistmove", "rename", "rm", "save", ): self.assertNotInResponse(f"command: {name}") self.assertInResponse("command: load") self.assertInResponse("command: listplaylist") self.assertInResponse("command: play") self.assertInResponse("OK") def test_decoders(self): self.send_request("decoders") self.assertInResponse("OK") def test_notcommands_returns_only_config_and_kill_and_ok(self): response = self.send_request("notcommands") assert len(response) == 3 self.assertInResponse("command: config") self.assertInResponse("command: kill") self.assertInResponse("OK") def test_urlhandlers(self): self.send_request("urlhandlers") self.assertInResponse("OK") self.assertInResponse("handler: dummy") class ReflectionWhenNotAuthedTest(protocol.BaseTestCase): def get_config(self): config = super().get_config() config["mpd"]["password"] = "topsecret" # noqa: S105 return config def test_commands_show_less_if_auth_required_and_not_authed(self): self.send_request("commands") # Not requiring auth self.assertInResponse("command: close") self.assertInResponse("command: commands") self.assertInResponse("command: notcommands") self.assertInResponse("command: password") self.assertInResponse("command: ping") # Requiring auth self.assertNotInResponse("command: play") self.assertNotInResponse("command: status") def test_notcommands_returns_more_if_auth_required_and_not_authed(self): self.send_request("notcommands") # Not requiring auth self.assertNotInResponse("command: close") self.assertNotInResponse("command: commands") self.assertNotInResponse("command: notcommands") self.assertNotInResponse("command: password") self.assertNotInResponse("command: ping") # Requiring auth self.assertInResponse("command: play") self.assertInResponse("command: status") ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1793654065.9655182 mopidy_mpd-4.1.0/tests/protocol/test_regression.py0000644000175100017510000002005415272176462022134 0ustar00runnerrunnerimport random from unittest import mock from mopidy.models import Playlist, Ref, Track from mopidy_mpd.protocol import stored_playlists from tests import protocol def mock_shuffle(foo): foo[:] = [foo[1], foo[2], foo[5], foo[3], foo[4], foo[0]] class IssueGH17RegressionTest(protocol.BaseTestCase): """ The issue: http://github.com/mopidy/mopidy/issues/17 How to reproduce: - Play a playlist where one track cannot be played - Turn on random mode - Press next until you get to the unplayable track """ def test(self): random.seed(1) tracks = [ Track(uri="dummy:a"), Track(uri="dummy:b"), Track(uri="dummy:error"), Track(uri="dummy:d"), Track(uri="dummy:e"), Track(uri="dummy:f"), ] self.audio.trigger_fake_playback_failure("dummy:error") self.backend.library.dummy_library = tracks self.core.tracklist.add(uris=[t.uri for t in tracks]).get() # Playlist order: abcfde self.send_request("play") assert self.core.playback.get_current_track().get().uri == "dummy:a" self.send_request('random "1"') self.send_request("next") assert self.core.playback.get_current_track().get().uri == "dummy:d" self.send_request("next") assert self.core.playback.get_current_track().get().uri == "dummy:f" self.send_request("next") assert self.core.playback.get_current_track().get().uri == "dummy:a" self.send_request("next") assert self.core.playback.get_current_track().get().uri == "dummy:e" self.send_request("next") assert self.core.playback.get_current_track().get().uri == "dummy:b" self.send_request("next") # All tracks have been played, including the error one, which we skipped. assert self.core.playback.get_current_track().get() is None class IssueGH18RegressionTest(protocol.BaseTestCase): """ The issue: http://github.com/mopidy/mopidy/issues/18 How to reproduce: Play, random on, next, random off, next, next. At this point it gives the same song over and over. """ def test(self): tracks = [ Track(uri="dummy:a"), Track(uri="dummy:b"), Track(uri="dummy:c"), Track(uri="dummy:d"), Track(uri="dummy:e"), Track(uri="dummy:f"), ] self.backend.library.dummy_library = tracks self.core.tracklist.add(uris=[t.uri for t in tracks]).get() random.seed(1) self.send_request("play") self.send_request('random "1"') self.send_request("next") self.send_request('random "0"') self.send_request("next") self.send_request("next") tl_track_1 = self.core.playback.get_current_tl_track().get() self.send_request("next") tl_track_2 = self.core.playback.get_current_tl_track().get() self.send_request("next") tl_track_3 = self.core.playback.get_current_tl_track().get() assert tl_track_1 != tl_track_2 assert tl_track_2 != tl_track_3 class IssueGH22RegressionTest(protocol.BaseTestCase): """ The issue: http://github.com/mopidy/mopidy/issues/22 How to reproduce: Play, random on, remove all tracks from the current playlist (as in "delete" each one, not "clear"). Alternatively: Play, random on, remove a random track from the current playlist, press next until it crashes. """ def test(self): tracks = [ Track(uri="dummy:a"), Track(uri="dummy:b"), Track(uri="dummy:c"), Track(uri="dummy:d"), Track(uri="dummy:e"), Track(uri="dummy:f"), ] self.backend.library.dummy_library = tracks self.core.tracklist.add(uris=[t.uri for t in tracks]).get() random.seed(1) self.send_request("play") self.send_request('random "1"') self.send_request('deleteid "1"') self.send_request('deleteid "2"') self.send_request('deleteid "3"') self.send_request('deleteid "4"') self.send_request('deleteid "5"') self.send_request('deleteid "6"') self.send_request("status") class IssueGH69RegressionTest(protocol.BaseTestCase): """ The issue: https://github.com/mopidy/mopidy/issues/69 How to reproduce: Play track, stop, clear current playlist, load a new playlist, status. The status response now contains "song: None". """ def test(self): self.core.playlists.create("foo") tracks = [ Track(uri="dummy:a"), Track(uri="dummy:b"), Track(uri="dummy:c"), Track(uri="dummy:d"), Track(uri="dummy:e"), Track(uri="dummy:f"), ] self.backend.library.dummy_library = tracks self.core.tracklist.add(uris=[t.uri for t in tracks]).get() self.send_request("play") self.send_request("stop") self.send_request("clear") self.send_request('load "foo"') self.assertNotInResponse("song: None") class IssueGH113RegressionTest(protocol.BaseTestCase): r""" The issue: https://github.com/mopidy/mopidy/issues/113 How to reproduce: - Have a playlist with a name contining backslashes, like "all lart spotify:track:\w\{22\} pastes". - Try to load the playlist with the backslashes in the playlist name escaped. """ def test(self): self.core.playlists.create(r"all lart spotify:track:\w\{22\} pastes") self.send_request('lsinfo "/"') self.assertInResponse(r"playlist: all lart spotify:track:\w\{22\} pastes") self.send_request( r'listplaylistinfo "all lart spotify:track:\\w\\{22\\} pastes"' ) self.assertInResponse("OK") class IssueGH137RegressionTest(protocol.BaseTestCase): """ The issue: https://github.com/mopidy/mopidy/issues/137 How to reproduce: - Send "list" query with mismatching quotes """ def test(self): self.send_request( 'list Date Artist "Anita Ward" ' 'Album "This Is Remixed Hits - Mashups & Rare 12" Mixes"' ) self.assertInResponse("ACK [2@0] {list} Invalid unquoted character") class IssueGH1120RegressionTest(protocol.BaseTestCase): """ The issue: https://github.com/mopidy/mopidy/issues/1120 How to reproduce: - A playlist must be in both browse results and playlists - Call for instance ``lsinfo "/"`` to populate the cache with the playlist name from the playlist backend. - Call ``lsinfo "/dummy"`` to override the playlist name with the browse name. - Call ``lsinfo "/"`` and we now have an invalid name with ``/`` in it. """ @mock.patch.object(stored_playlists, "_get_last_modified") def test(self, last_modified_mock): last_modified_mock.return_value = "2015-08-05T22:51:06Z" self.backend.library.dummy_browse_result = { "dummy:/": [Ref.playlist(name="Top 100 tracks", uri="dummy:/1")], } self.backend.playlists.set_dummy_playlists( [Playlist(name="Top 100 tracks", uri="dummy:/1")] ) response1 = self.send_request('lsinfo "/"') self.send_request('lsinfo "/dummy"') response2 = self.send_request('lsinfo "/"') assert response1 == response2 class IssueGH1348RegressionTest(protocol.BaseTestCase): """ The issue: http://github.com/mopidy/mopidy/issues/1348 """ def test(self): self.backend.library.dummy_library = [Track(uri="dummy:a")] # Create a dummy playlist and trigger population of mapping self.send_request('playlistadd "testing1" "dummy:a"') self.send_request("listplaylists") # Create an other playlist which isn't in the map self.send_request('playlistadd "testing2" "dummy:a"') assert self.send_request('rm "testing2"') == ["OK"] playlists = self.backend.playlists.as_list().get() assert [ref.name for ref in playlists] == ["testing1"] ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1793654065.9655182 mopidy_mpd-4.1.0/tests/protocol/test_status.py0000644000175100017510000000344515272176462021304 0ustar00runnerrunnerfrom mopidy.models import Album, Track from tests import protocol class StatusHandlerTest(protocol.BaseTestCase): def test_clearerror(self): self.send_request("clearerror") self.assertEqualResponse("ACK [0@0] {clearerror} Not implemented") def test_currentsong(self): track = Track(uri="dummy:/a") self.backend.library.dummy_library = [track] self.core.tracklist.add(uris=[track.uri]).get() self.core.playback.play().get() self.send_request("currentsong") self.assertInResponse("file: dummy:/a") self.assertInResponse("Time: 0") self.assertNotInResponse("Artist: ") self.assertNotInResponse("Title: ") self.assertNotInResponse("Album: ") self.assertNotInResponse("Track: 0") self.assertNotInResponse("Date: ") self.assertInResponse("Pos: 0") self.assertInResponse("Id: 1") self.assertInResponse("OK") def test_currentsong_unicode(self): track = Track( uri="dummy:/à", name="a nàme", album=Album(uri="something:àlbum:12345"), ) self.backend.library.dummy_library = [track] self.core.tracklist.add(uris=[track.uri]).get() self.core.playback.play().get() self.send_request("currentsong") self.assertInResponse("file: dummy:/à") self.assertInResponse("Title: a nàme") self.assertInResponse("X-AlbumUri: something:àlbum:12345") def test_currentsong_without_song(self): self.send_request("currentsong") self.assertInResponse("OK") def test_stats_command(self): self.send_request("stats") self.assertInResponse("OK") def test_status_command(self): self.send_request("status") self.assertInResponse("OK") ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1793654065.9655182 mopidy_mpd-4.1.0/tests/protocol/test_stickers.py0000644000175100017510000000227515272176462021610 0ustar00runnerrunnerfrom tests import protocol class StickersHandlerTest(protocol.BaseTestCase): def test_sticker_get(self): self.send_request('sticker get "song" "file:///dev/urandom" "a_name"') self.assertEqualResponse("ACK [0@0] {sticker} Not implemented") def test_sticker_set(self): self.send_request('sticker set "song" "file:///dev/urandom" "a_name" "a_value"') self.assertEqualResponse("ACK [0@0] {sticker} Not implemented") def test_sticker_delete_with_name(self): self.send_request('sticker delete "song" "file:///dev/urandom" "a_name"') self.assertEqualResponse("ACK [0@0] {sticker} Not implemented") def test_sticker_delete_without_name(self): self.send_request('sticker delete "song" "file:///dev/urandom"') self.assertEqualResponse("ACK [0@0] {sticker} Not implemented") def test_sticker_list(self): self.send_request('sticker list "song" "file:///dev/urandom"') self.assertEqualResponse("ACK [0@0] {sticker} Not implemented") def test_sticker_find(self): self.send_request('sticker find "song" "file:///dev/urandom" "a_name"') self.assertEqualResponse("ACK [0@0] {sticker} Not implemented") ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1793654065.9655182 mopidy_mpd-4.1.0/tests/protocol/test_stored_playlists.py0000644000175100017510000004716215272176462023371 0ustar00runnerrunnerfrom unittest import mock from mopidy.models import Playlist, Track from mopidy_mpd.protocol import stored_playlists from tests import protocol class PlaylistsHandlerTest(protocol.BaseTestCase): def test_listplaylist(self): self.backend.playlists.set_dummy_playlists( [Playlist(name="name", uri="dummy:name", tracks=[Track(uri="dummy:a")])] ) self.send_request('listplaylist "name"') self.assertInResponse("file: dummy:a") self.assertInResponse("OK") def test_listplaylist_unicode(self): self.backend.playlists.set_dummy_playlists( [Playlist(name="nàmé", uri="dummy:nàmé", tracks=[Track(uri="dummy:à")])] ) self.send_request('listplaylist "nàmé"') self.assertInResponse("file: dummy:à") self.assertInResponse("OK") def test_listplaylist_without_quotes(self): self.backend.playlists.set_dummy_playlists( [Playlist(name="name", uri="dummy:name", tracks=[Track(uri="dummy:a")])] ) self.send_request("listplaylist name") self.assertInResponse("file: dummy:a") self.assertInResponse("OK") def test_listplaylist_fails_if_no_playlist_is_found(self): self.send_request('listplaylist "name"') self.assertEqualResponse("ACK [50@0] {listplaylist} No such playlist") def test_listplaylist_duplicate(self): playlist1 = Playlist(name="a", uri="dummy:a1", tracks=[Track(uri="b")]) playlist2 = Playlist(name="a", uri="dummy:a2", tracks=[Track(uri="c")]) self.backend.playlists.set_dummy_playlists([playlist1, playlist2]) self.send_request('listplaylist "a [2]"') self.assertInResponse("file: c") self.assertInResponse("OK") def test_listplaylistinfo(self): tracks = [ Track(uri="dummy:a", name="Track A", length=5000), Track(uri="dummy:é", name="Track é", length=5000), ] self.backend.library.dummy_library = tracks self.backend.playlists.set_dummy_playlists( [Playlist(name="name", uri="dummy:name", tracks=tracks)] ) self.send_request('listplaylistinfo "name"') self.assertInResponse("file: dummy:a") self.assertInResponse("Title: Track A") self.assertInResponse("Time: 5") self.assertInResponse("file: dummy:é") self.assertInResponse("Title: Track é") self.assertNotInResponse("Track: 0") self.assertNotInResponse("Pos: 0") self.assertInResponse("OK") def test_listplaylistinfo_without_quotes(self): tracks = [ Track(uri="dummy:a"), ] self.backend.library.dummy_library = tracks self.backend.playlists.set_dummy_playlists( [Playlist(name="name", uri="dummy:name", tracks=tracks)] ) self.send_request("listplaylistinfo name") self.assertInResponse("file: dummy:a") self.assertNotInResponse("Track: 0") self.assertNotInResponse("Pos: 0") self.assertInResponse("OK") def test_listplaylistinfo_fails_if_no_playlist_is_found(self): self.send_request('listplaylistinfo "name"') self.assertEqualResponse("ACK [50@0] {listplaylistinfo} No such playlist") def test_listplaylistinfo_duplicate(self): tracks = [ Track(uri="dummy:b"), Track(uri="dummy:c"), ] self.backend.library.dummy_library = tracks playlist1 = Playlist(name="a", uri="dummy:a1", tracks=tracks[:1]) playlist2 = Playlist(name="a", uri="dummy:a2", tracks=tracks[1:]) self.backend.playlists.set_dummy_playlists([playlist1, playlist2]) self.send_request('listplaylistinfo "a [2]"') self.assertInResponse("file: dummy:c") self.assertNotInResponse("Track: 0") self.assertNotInResponse("Pos: 0") self.assertInResponse("OK") @mock.patch.object(stored_playlists, "_get_last_modified") def test_listplaylists(self, last_modified_mock): last_modified_mock.return_value = "2015-08-05T22:51:06Z" self.backend.playlists.set_dummy_playlists( [ Playlist(name="a", uri="dummy:a"), Playlist(name="é", uri="dummy:é"), ] ) self.send_request("listplaylists") self.assertInResponse("playlist: a") self.assertInResponse("playlist: é") # Date without milliseconds and with time zone information self.assertInResponse("Last-Modified: 2015-08-05T22:51:06Z") self.assertInResponse("OK") def test_listplaylists_duplicate(self): playlist1 = Playlist(name="a", uri="dummy:a1") playlist2 = Playlist(name="a", uri="dummy:a2") self.backend.playlists.set_dummy_playlists([playlist1, playlist2]) self.send_request("listplaylists") self.assertInResponse("playlist: a") self.assertInResponse("playlist: a [2]") self.assertInResponse("OK") def test_listplaylists_ignores_playlists_without_name(self): last_modified = 1390942873222 self.backend.playlists.set_dummy_playlists( [Playlist(name="", uri="dummy:", last_modified=last_modified)] ) self.send_request("listplaylists") self.assertNotInResponse("playlist: ") self.assertInResponse("OK") def test_listplaylists_replaces_newline_with_space(self): self.backend.playlists.set_dummy_playlists([Playlist(name="a\n", uri="dummy:")]) self.send_request("listplaylists") self.assertInResponse("playlist: a ") self.assertNotInResponse("playlist: a\n") self.assertInResponse("OK") def test_listplaylists_replaces_carriage_return_with_space(self): self.backend.playlists.set_dummy_playlists([Playlist(name="a\r", uri="dummy:")]) self.send_request("listplaylists") self.assertInResponse("playlist: a ") self.assertNotInResponse("playlist: a\r") self.assertInResponse("OK") def test_listplaylists_replaces_forward_slash_with_pipe(self): self.backend.playlists.set_dummy_playlists([Playlist(name="a/b", uri="dummy:")]) self.send_request("listplaylists") self.assertInResponse("playlist: a|b") self.assertNotInResponse("playlist: a/b") self.assertInResponse("OK") def test_load_appends_to_tracklist(self): tracks = [ Track(uri="dummy:a"), Track(uri="dummy:b"), Track(uri="dummy:c"), Track(uri="dummy:d"), Track(uri="dummy:ǫ"), ] self.backend.library.dummy_library = tracks self.core.tracklist.add(uris=["dummy:a", "dummy:b"]).get() assert len(self.core.tracklist.get_tracks().get()) == 2 self.backend.playlists.set_dummy_playlists( [Playlist(name="A-list", uri="dummy:A-list", tracks=tracks[2:])] ) self.send_request('load "A-list"') tracks = self.core.tracklist.get_tracks().get() assert len(tracks) == 5 assert tracks[0].uri == "dummy:a" assert tracks[1].uri == "dummy:b" assert tracks[2].uri == "dummy:c" assert tracks[3].uri == "dummy:d" assert tracks[4].uri == "dummy:ǫ" self.assertInResponse("OK") def test_load_with_range_loads_part_of_playlist(self): tracks = [ Track(uri="dummy:a"), Track(uri="dummy:b"), Track(uri="dummy:c"), Track(uri="dummy:d"), Track(uri="dummy:e"), ] self.backend.library.dummy_library = tracks self.core.tracklist.add(uris=["dummy:a", "dummy:b"]).get() assert len(self.core.tracklist.get_tracks().get()) == 2 self.backend.playlists.set_dummy_playlists( [Playlist(name="A-list", uri="dummy:A-list", tracks=tracks[2:])] ) self.send_request('load "A-list" "1:2"') tracks = self.core.tracklist.get_tracks().get() assert len(tracks) == 3 assert tracks[0].uri == "dummy:a" assert tracks[1].uri == "dummy:b" assert tracks[2].uri == "dummy:d" self.assertInResponse("OK") def test_load_with_range_without_end_loads_rest_of_playlist(self): tracks = [ Track(uri="dummy:a"), Track(uri="dummy:b"), Track(uri="dummy:c"), Track(uri="dummy:d"), Track(uri="dummy:e"), ] self.backend.library.dummy_library = tracks self.core.tracklist.add(uris=["dummy:a", "dummy:b"]).get() assert len(self.core.tracklist.get_tracks().get()) == 2 self.backend.playlists.set_dummy_playlists( [Playlist(name="A-list", uri="dummy:A-list", tracks=tracks[2:])] ) self.send_request('load "A-list" "1:"') tracks = self.core.tracklist.get_tracks().get() assert len(tracks) == 4 assert tracks[0].uri == "dummy:a" assert tracks[1].uri == "dummy:b" assert tracks[2].uri == "dummy:d" assert tracks[3].uri == "dummy:e" self.assertInResponse("OK") def test_load_unknown_playlist_acks(self): self.send_request('load "unknown playlist"') assert len(self.core.tracklist.get_tracks().get()) == 0 self.assertEqualResponse("ACK [50@0] {load} No such playlist") # No invalid name check for load. self.send_request('load "unknown/playlist"') self.assertEqualResponse("ACK [50@0] {load} No such playlist") def test_load_full_track_metadata(self): tracks = [ Track(uri="dummy:a", name="Track A", length=5000), ] self.backend.library.dummy_library = tracks self.backend.playlists.set_dummy_playlists( [Playlist(name="A-list", uri="dummy:a1", tracks=[Track(uri="dummy:a")])] ) self.send_request('load "A-list"') tracks = self.core.tracklist.get_tracks().get() assert len(tracks) == 1 assert tracks[0].uri == "dummy:a" assert tracks[0].name == "Track A" assert tracks[0].length == 5000 self.assertInResponse("OK") def test_playlistadd(self): tracks = [ Track(uri="dummy:a"), Track(uri="dummy:b"), ] self.backend.library.dummy_library = tracks self.backend.playlists.set_dummy_playlists( [Playlist(name="name", uri="dummy:a1", tracks=[tracks[0]])] ) self.send_request('playlistadd "name" "dummy:b"') self.assertInResponse("OK") assert len(self.backend.playlists.get_items("dummy:a1").get()) == 2 def test_playlistadd_creates_playlist(self): tracks = [ Track(uri="dummy:é"), ] self.backend.library.dummy_library = tracks self.send_request('playlistadd "namé" "dummy:é"') self.assertInResponse("OK") assert self.backend.playlists.lookup("dummy:namé").get() is not None def test_playlistadd_invalid_name_acks(self): self.send_request('playlistadd "foo/bar" "dummy:a"') self.assertInResponse( "ACK [2@0] {playlistadd} playlist name is " "invalid: playlist names may not contain " "slashes, newlines or carriage returns" ) def test_playlistclear(self): self.backend.playlists.set_dummy_playlists( [Playlist(name="namé", uri="dummy:a1", tracks=[Track(uri="b")])] ) self.send_request('playlistclear "namé"') self.assertInResponse("OK") assert len(self.backend.playlists.get_items("dummy:a1").get()) == 0 def test_playlistclear_creates_playlist(self): self.send_request('playlistclear "name"') self.assertInResponse("OK") assert self.backend.playlists.lookup("dummy:name").get() is not None def test_playlistclear_creates_playlist_save_fails(self): self.backend.playlists.set_allow_save(False) self.send_request('playlistclear "name"') self.assertInResponse( "ACK [0@0] {playlistclear} Backend with " 'scheme "dummy" failed to save playlist' ) def test_playlistclear_invalid_name_acks(self): self.send_request('playlistclear "foo/bar"') self.assertInResponse( "ACK [2@0] {playlistclear} playlist name is " "invalid: playlist names may not contain " "slashes, newlines or carriage returns" ) def test_playlistdelete(self): tracks = [ Track(uri="dummy:a"), Track(uri="dummy:b"), Track(uri="dummy:c"), ] # len() == 3 self.backend.playlists.set_dummy_playlists( [Playlist(name="namé", uri="dummy:a1", tracks=tracks)] ) self.send_request('playlistdelete "namé" "2"') self.assertInResponse("OK") assert len(self.backend.playlists.get_items("dummy:a1").get()) == 2 def test_playlistdelete_save_fails(self): tracks = [ Track(uri="dummy:a"), Track(uri="dummy:b"), Track(uri="dummy:c"), ] # len() == 3 self.backend.playlists.set_dummy_playlists( [Playlist(name="name", uri="dummy:a1", tracks=tracks)] ) self.backend.playlists.set_allow_save(False) self.send_request('playlistdelete "name" "2"') self.assertInResponse( "ACK [0@0] {playlistdelete} Backend with " 'scheme "dummy" failed to save playlist' ) def test_playlistdelete_invalid_name_acks(self): self.send_request('playlistdelete "foo/bar" "0"') self.assertInResponse( "ACK [2@0] {playlistdelete} playlist name is " "invalid: playlist names may not contain " "slashes, newlines or carriage returns" ) def test_playlistdelete_unknown_playlist_acks(self): self.send_request('playlistdelete "foobar" "0"') self.assertInResponse("ACK [50@0] {playlistdelete} No such playlist") def test_playlistdelete_unknown_index_acks(self): self.send_request('save "foobar"') self.send_request('playlistdelete "foobar" "0"') self.assertInResponse("ACK [2@0] {playlistdelete} Bad song index") def test_playlistmove(self): tracks = [ Track(uri="dummy:a"), Track(uri="dummy:b"), Track(uri="dummy:c"), # this one is getting moved to top ] self.backend.playlists.set_dummy_playlists( [Playlist(name="namé", uri="dummy:a1", tracks=tracks)] ) self.send_request('playlistmove "namé" "2" "0"') self.assertInResponse("OK") assert self.backend.playlists.get_items("dummy:a1").get()[0].uri == "dummy:c" def test_playlistmove_save_fails(self): tracks = [ Track(uri="dummy:a"), Track(uri="dummy:b"), Track(uri="dummy:c"), # this one is getting moved to top ] self.backend.playlists.set_dummy_playlists( [Playlist(name="name", uri="dummy:a1", tracks=tracks)] ) self.backend.playlists.set_allow_save(False) self.send_request('playlistmove "name" "2" "0"') self.assertInResponse( "ACK [0@0] {playlistmove} Backend with " 'scheme "dummy" failed to save playlist' ) def test_playlistmove_invalid_name_acks(self): self.send_request('playlistmove "foo/bar" "0" "1"') self.assertInResponse( "ACK [2@0] {playlistmove} playlist name is " "invalid: playlist names may not contain " "slashes, newlines or carriage returns" ) def test_playlistmove_unknown_playlist_acks(self): self.send_request('playlistmove "foobar" "0" "1"') self.assertInResponse("ACK [50@0] {playlistmove} No such playlist") def test_playlistmove_unknown_position_acks(self): self.send_request('save "foobar"') self.send_request('playlistmove "foobar" "0" "1"') self.assertInResponse("ACK [2@0] {playlistmove} Bad song index") def test_playlistmove_same_index_shortcircuits_everything(self): # Bad indexes on unknown playlist: self.send_request('playlistmove "foobar" "0" "0"') self.assertInResponse("OK") self.send_request('playlistmove "foobar" "100000" "100000"') self.assertInResponse("OK") # Bad indexes on known playlist: self.send_request('save "foobar"') self.send_request('playlistmove "foobar" "0" "0"') self.assertInResponse("OK") self.send_request('playlistmove "foobar" "10" "10"') self.assertInResponse("OK") # Invalid playlist name: self.send_request('playlistmove "foo/bar" "0" "0"') self.assertInResponse("OK") def test_rename(self): self.backend.playlists.set_dummy_playlists( [Playlist(name="old_name", uri="dummy:a1", tracks=[Track(uri="b")])] ) self.send_request('rename "old_name" "new_namé"') self.assertInResponse("OK") assert self.backend.playlists.lookup("dummy:new_namé").get() is not None def test_rename_save_fails(self): self.backend.playlists.set_dummy_playlists( [Playlist(name="old_name", uri="dummy:a1", tracks=[Track(uri="b")])] ) self.backend.playlists.set_allow_save(False) self.send_request('rename "old_name" "new_name"') self.assertInResponse( 'ACK [0@0] {rename} Backend with scheme "dummy" failed to save playlist' ) def test_rename_unknown_playlist_acks(self): self.send_request('rename "foo" "bar"') self.assertInResponse("ACK [50@0] {rename} No such playlist") def test_rename_to_existing_acks(self): self.send_request('save "foo"') self.send_request('save "bar"') self.send_request('rename "foo" "bar"') self.assertInResponse("ACK [56@0] {rename} Playlist already exists") def test_rename_invalid_name_acks(self): expected = ( "ACK [2@0] {rename} playlist name is invalid: playlist " "names may not contain slashes, newlines or carriage " "returns" ) self.send_request('rename "foo/bar" "bar"') self.assertInResponse(expected) self.send_request('rename "foo" "foo/bar"') self.assertInResponse(expected) self.send_request('rename "bar/foo" "foo/bar"') self.assertInResponse(expected) def test_rm(self): self.backend.playlists.set_dummy_playlists( [Playlist(name="namé", uri="dummy:à1", tracks=[Track(uri="b")])] ) self.send_request('rm "namé"') self.assertInResponse("OK") assert self.backend.playlists.lookup("dummy:à1").get() is None def test_rm_unknown_playlist_acks(self): self.send_request('rm "name"') self.assertInResponse("ACK [50@0] {rm} No such playlist") def test_rm_invalid_name_acks(self): self.send_request('rm "foo/bar"') self.assertInResponse( "ACK [2@0] {rm} playlist name is invalid: " "playlist names may not contain slashes, " "newlines or carriage returns" ) def test_save(self): self.send_request('save "namé"') self.assertInResponse("OK") assert self.backend.playlists.lookup("dummy:namé").get() is not None def test_save_fails(self): self.backend.playlists.set_allow_save(False) self.send_request('save "name"') self.assertInResponse( 'ACK [0@0] {save} Backend with scheme "dummy" failed to save playlist' ) def test_save_invalid_name_acks(self): self.send_request('save "foo/bar"') self.assertInResponse( "ACK [2@0] {save} playlist name is invalid: " "playlist names may not contain slashes, " "newlines or carriage returns" ) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1793654065.9655182 mopidy_mpd-4.1.0/tests/test_actor.py0000644000175100017510000000322315272176462017222 0ustar00runnerrunnerfrom unittest import mock import pytest from mopidy_mpd import actor # NOTE: Should be kept in sync with all events from mopidy.core.listener @pytest.mark.parametrize( ("event", "expected"), [ (["track_playback_paused", "tl_track", "time_position"], None), (["track_playback_resumed", "tl_track", "time_position"], None), (["track_playback_started", "tl_track"], None), (["track_playback_ended", "tl_track", "time_position"], None), (["playback_state_changed", "old_state", "new_state"], "player"), (["tracklist_changed"], "playlist"), (["playlists_loaded"], "stored_playlist"), (["playlist_changed", "playlist"], "stored_playlist"), (["playlist_deleted", "uri"], "stored_playlist"), (["options_changed"], "options"), (["volume_changed", "volume"], "mixer"), (["mute_changed", "mute"], "output"), (["seeked", "time_position"], "player"), (["stream_title_changed", "title"], "playlist"), ], ) def test_idle_hooked_up_correctly(event, expected): config = { "mpd": { "hostname": "foobar", "port": 1234, "zeroconf": None, "max_connections": None, "connection_timeout": None, } } with mock.patch.object(actor.MpdFrontend, "_setup_server"): frontend = actor.MpdFrontend(core=mock.Mock(), config=config) with mock.patch("mopidy.listener.send") as send_mock: frontend.on_event(event[0], **dict.fromkeys(event[1:])) if expected is None: assert not send_mock.call_args else: send_mock.assert_called_once_with(mock.ANY, expected) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1793654065.9655182 mopidy_mpd-4.1.0/tests/test_commands.py0000644000175100017510000002677715272176462017736 0ustar00runnerrunnerfrom __future__ import annotations import unittest from typing import TYPE_CHECKING from unittest import mock from mopidy_mpd import exceptions, protocol if TYPE_CHECKING: from mopidy_mpd.context import MpdContext class TestConverts(unittest.TestCase): def test_integer(self): assert protocol.INT("123") == 123 assert protocol.INT("-123") == (-123) assert protocol.INT("+123") == 123 self.assertRaises(ValueError, protocol.INT, "3.14") self.assertRaises(ValueError, protocol.INT, "") self.assertRaises(ValueError, protocol.INT, "abc") self.assertRaises(ValueError, protocol.INT, "12 34") def test_unsigned_integer(self): assert protocol.UINT("123") == 123 self.assertRaises(ValueError, protocol.UINT, "-123") self.assertRaises(ValueError, protocol.UINT, "+123") self.assertRaises(ValueError, protocol.UINT, "3.14") self.assertRaises(ValueError, protocol.UINT, "") self.assertRaises(ValueError, protocol.UINT, "abc") self.assertRaises(ValueError, protocol.UINT, "12 34") def test_boolean(self): assert protocol.BOOL("1") is True assert protocol.BOOL("0") is False self.assertRaises(ValueError, protocol.BOOL, "3.14") self.assertRaises(ValueError, protocol.BOOL, "") self.assertRaises(ValueError, protocol.BOOL, "true") self.assertRaises(ValueError, protocol.BOOL, "false") self.assertRaises(ValueError, protocol.BOOL, "abc") self.assertRaises(ValueError, protocol.BOOL, "12 34") def test_range(self): assert slice(1, 2) == protocol.RANGE("1") assert slice(0, 1) == protocol.RANGE("0") assert slice(0, None) == protocol.RANGE("0:") assert slice(1, 3) == protocol.RANGE("1:3") self.assertRaises(ValueError, protocol.RANGE, "3.14") self.assertRaises(ValueError, protocol.RANGE, "1:abc") self.assertRaises(ValueError, protocol.RANGE, "abc:1") self.assertRaises(ValueError, protocol.RANGE, "2:1") self.assertRaises(ValueError, protocol.RANGE, "-1:2") self.assertRaises(ValueError, protocol.RANGE, "1 : 2") self.assertRaises(ValueError, protocol.RANGE, "") self.assertRaises(ValueError, protocol.RANGE, "true") self.assertRaises(ValueError, protocol.RANGE, "false") self.assertRaises(ValueError, protocol.RANGE, "abc") self.assertRaises(ValueError, protocol.RANGE, "12 34") class TestCommands(unittest.TestCase): def setUp(self): self.commands = protocol.Commands() def test_add_as_a_decorator(self): @self.commands.add("test") def test(context: MpdContext): pass def test_register_second_command_to_same_name_fails(self): def func(context: MpdContext): pass self.commands.add("foo")(func) with self.assertRaises(ValueError): self.commands.add("foo")(func) def test_function_only_takes_context_succeeds(self): sentinel = object() self.commands.add("bar")(lambda context: sentinel) assert sentinel == self.commands.call(context=None, tokens=["bar"]) def test_function_has_required_arg_succeeds(self): sentinel = object() self.commands.add("bar")(lambda context, required: sentinel) assert sentinel == self.commands.call(context=None, tokens=["bar", "arg"]) def test_function_has_optional_args_succeeds(self): sentinel = object() self.commands.add("bar")(lambda context, optional=None: sentinel) assert sentinel == self.commands.call(context=None, tokens=["bar"]) assert sentinel == self.commands.call(context=None, tokens=["bar", "arg"]) def test_function_has_required_and_optional_args_succeeds(self): sentinel = object() def func(context: MpdContext, required, optional=None): return sentinel self.commands.add("bar")(func) assert sentinel == self.commands.call(context=None, tokens=["bar", "arg"]) assert sentinel == self.commands.call( context=None, tokens=["bar", "arg", "arg"] ) def test_function_has_varargs_succeeds(self): sentinel, args = object(), [] self.commands.add("bar")(lambda context, *args: sentinel) for _ in range(10): assert sentinel == self.commands.call(context=None, tokens=["bar", *args]) args.append("test") def test_function_has_only_varags_succeeds(self): sentinel = object() self.commands.add("baz")(lambda *args: sentinel) assert sentinel == self.commands.call(context=None, tokens=["baz"]) def test_function_has_no_arguments_fails(self): with self.assertRaises(TypeError): self.commands.add("test")(lambda: True) def test_function_has_required_and_varargs_fails(self): with self.assertRaises(TypeError): def func(context: MpdContext, required, *args): pass self.commands.add("test")(func) def test_function_has_optional_and_varargs_fails(self): with self.assertRaises(TypeError): def func(context: MpdContext, optional=None, *args): pass self.commands.add("test")(func) def test_function_hash_keywordargs_fails(self): with self.assertRaises(TypeError): self.commands.add("test")(lambda context, **kwargs: True) def test_call_chooses_correct_handler(self): sentinel1, sentinel2, sentinel3 = object(), object(), object() self.commands.add("foo")(lambda context: sentinel1) self.commands.add("bar")(lambda context: sentinel2) self.commands.add("baz")(lambda context: sentinel3) assert sentinel1 == self.commands.call(context=None, tokens=["foo"]) assert sentinel2 == self.commands.call(context=None, tokens=["bar"]) assert sentinel3 == self.commands.call(context=None, tokens=["baz"]) def test_call_with_nonexistent_handler(self): with self.assertRaises(exceptions.MpdUnknownCommandError): self.commands.call(context=None, tokens=["bar"]) def test_call_passes_context(self): sentinel = object() self.commands.add("foo")(lambda context: context) assert sentinel == self.commands.call(context=sentinel, tokens=["foo"]) def test_call_without_args_fails(self): with self.assertRaises(exceptions.MpdNoCommandError): self.commands.call(context=None, tokens=[]) def test_call_passes_required_argument(self): self.commands.add("foo")(lambda context, required: required) assert self.commands.call(context=None, tokens=["foo", "test123"]) == "test123" def test_call_passes_optional_argument(self): sentinel = object() self.commands.add("foo")(lambda context, optional=sentinel: optional) assert sentinel == self.commands.call(context=None, tokens=["foo"]) assert self.commands.call(context=None, tokens=["foo", "test"]) == "test" def test_call_passes_required_and_optional_argument(self): def func(context: MpdContext, required, optional=None): return (required, optional) self.commands.add("foo")(func) assert self.commands.call(context=None, tokens=["foo", "arg"]) == ("arg", None) assert self.commands.call(context=None, tokens=["foo", "arg", "kwarg"]) == ( "arg", "kwarg", ) def test_call_passes_varargs(self): self.commands.add("foo")(lambda context, *args: args) def test_call_incorrect_args(self): self.commands.add("foo")(lambda context: context) with self.assertRaises(exceptions.MpdArgError): self.commands.call(context=None, tokens=["foo", "bar"]) self.commands.add("bar")(lambda context, required: context) with self.assertRaises(exceptions.MpdArgError): self.commands.call(context=None, tokens=["bar", "bar", "baz"]) self.commands.add("baz")(lambda context, optional=None: context) with self.assertRaises(exceptions.MpdArgError): self.commands.call(context=None, tokens=["baz", "bar", "baz"]) def test_validator_gets_applied_to_required_arg(self): sentinel = object() def func(context: MpdContext, required): return required self.commands.add("test", required=lambda v: sentinel)(func) assert sentinel == self.commands.call(context=None, tokens=["test", "foo"]) def test_validator_gets_applied_to_optional_arg(self): sentinel = object() def func(context: MpdContext, optional=None): return optional self.commands.add("foo", optional=lambda v: sentinel)(func) assert sentinel == self.commands.call(context=None, tokens=["foo", "123"]) def test_validator_skips_optional_default(self): sentinel = object() def func(context: MpdContext, optional=sentinel): return optional self.commands.add("foo", optional=lambda v: None)(func) assert sentinel == self.commands.call(context=None, tokens=["foo"]) def test_validator_applied_to_non_existent_arg_fails(self): self.commands.add("foo")(lambda context, arg: arg) with self.assertRaises(TypeError): def func(context: MpdContext, wrong_arg): return wrong_arg self.commands.add("bar", arg=lambda v: v)(func) def test_validator_called_context_fails(self): return # TODO: how to handle this with self.assertRaises(TypeError): def func(context: MpdContext): pass self.commands.add("bar", context=lambda v: v)(func) def test_validator_value_error_is_converted(self): def validdate(value): raise ValueError def func(context: MpdContext, arg): pass self.commands.add("bar", arg=validdate)(func) with self.assertRaises(exceptions.MpdArgError): self.commands.call(context=None, tokens=["bar", "test"]) def test_auth_required_gets_stored(self): def func1(context: MpdContext): pass def func2(context: MpdContext): pass self.commands.add("foo")(func1) self.commands.add("bar", auth_required=False)(func2) assert self.commands.handlers["foo"].auth_required assert not self.commands.handlers["bar"].auth_required def test_list_command_gets_stored(self): def func1(context: MpdContext): pass def func2(context: MpdContext): pass self.commands.add("foo")(func1) self.commands.add("bar", list_command=False)(func2) assert self.commands.handlers["foo"].list_command assert not self.commands.handlers["bar"].list_command def test_is_listed_returns_bool_list_command(self): def func1(context: MpdContext): pass def func2(context: MpdContext): pass self.commands.add("foo")(func1) self.commands.add("bar", list_command=False)(func2) context = mock.sentinel.context assert self.commands.handlers["foo"].is_listed(context) is True assert self.commands.handlers["bar"].is_listed(context) is False def test_is_listed_calls_list_command_with_context(self): def func(context: MpdContext): pass list_command = mock.Mock(return_value=False) self.commands.add("foo", list_command=list_command)(func) context = mock.sentinel.context assert self.commands.handlers["foo"].is_listed(context) is False list_command.assert_called_once_with(context) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1793654065.9655182 mopidy_mpd-4.1.0/tests/test_context.py0000644000175100017510000000446615272176462017610 0ustar00runnerrunnerfrom typing import cast import pykka import pytest from mopidy.backend import BackendProxy from mopidy.core import Core, CoreProxy from mopidy.models import Ref from mopidy_mpd import uri_mapper from mopidy_mpd.context import MpdContext from tests import dummy_backend @pytest.fixture def a_track() -> Ref: return Ref.track(uri="dummy:/a", name="a") @pytest.fixture def b_track() -> Ref: return Ref.track(uri="dummy:/foo/b", name="b") @pytest.fixture def backend_to_browse(a_track: Ref, b_track: Ref) -> BackendProxy: backend = cast("BackendProxy", dummy_backend.create_proxy()) backend.library.dummy_browse_result = { "dummy:/": [ a_track, Ref.directory(uri="dummy:/foo", name="foo"), ], "dummy:/foo": [ b_track, ], } return backend @pytest.fixture def mpd_context(backend_to_browse: BackendProxy) -> MpdContext: core = cast( "CoreProxy", Core.start(config=None, backends=[backend_to_browse]).proxy(), ) return MpdContext( config=None, core=core, uri_map=uri_mapper.MpdUriMapper(core), dispatcher=None, session=None, ) class TestMpdContext: @classmethod def teardown_class(cls): pykka.ActorRegistry.stop_all() def test_browse_root(self, mpd_context, a_track): results = mpd_context.browse("dummy", recursive=False, lookup=False) assert [("/dummy/a", a_track), ("/dummy/foo", None)] == list(results) def test_browse_root_recursive(self, mpd_context, a_track, b_track): results = mpd_context.browse("dummy", recursive=True, lookup=False) assert [ ("/dummy", None), ("/dummy/a", a_track), ("/dummy/foo", None), ("/dummy/foo/b", b_track), ] == list(results) @pytest.mark.parametrize( "bad_ref", [ Ref.track(uri="dummy:/x"), Ref.directory(uri="dummy:/y"), ], ) def test_browse_skips_bad_refs( self, backend_to_browse, a_track, bad_ref, mpd_context ): backend_to_browse.library.dummy_browse_result = { "dummy:/": [bad_ref, a_track], } results = mpd_context.browse("dummy", recursive=False, lookup=False) assert [("/dummy/a", a_track)] == list(results) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1793654065.9655182 mopidy_mpd-4.1.0/tests/test_dispatcher.py0000644000175100017510000000306515272176462020244 0ustar00runnerrunnerimport unittest from typing import cast import pykka from mopidy.core import Core, CoreProxy from mopidy_mpd import uri_mapper from mopidy_mpd.dispatcher import MpdDispatcher from mopidy_mpd.exceptions import MpdAckError from tests import dummy_backend class MpdDispatcherTest(unittest.TestCase): def setUp(self): config = {"mpd": {"password": None, "command_blacklist": ["disabled"]}} self.backend = dummy_backend.create_proxy() self.core = cast( "CoreProxy", Core.start(config=None, backends=[self.backend]).proxy() ) self.dispatcher = MpdDispatcher( config=config, core=self.core, uri_map=uri_mapper.MpdUriMapper(self.core), session=None, ) def tearDown(self): pykka.ActorRegistry.stop_all() def test_call_handler_for_unknown_command_raises_exception(self): with self.assertRaises(MpdAckError) as cm: self.dispatcher._call_handler("an_unknown_command with args") assert ( cm.exception.get_mpd_ack() == 'ACK [5@0] {} unknown command "an_unknown_command"' ) def test_handling_unknown_request_yields_error(self): result = self.dispatcher.handle_request("an unhandled request") assert result[0] == 'ACK [5@0] {} unknown command "an"' def test_handling_blacklisted_command(self): result = self.dispatcher.handle_request("disabled") assert ( result[0] == 'ACK [0@0] {disabled} "disabled" has been disabled in the server' ) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1793654065.9655182 mopidy_mpd-4.1.0/tests/test_exceptions.py0000644000175100017510000000400715272176462020274 0ustar00runnerrunnerimport unittest import pytest from mopidy_mpd.exceptions import ( MpdAckError, MpdNoCommandError, MpdNoExistError, MpdNotImplementedError, MpdPermissionError, MpdSystemError, MpdUnknownCommandError, ) class MpdExceptionsTest(unittest.TestCase): def test_mpd_not_implemented_is_a_mpd_ack_error(self): with pytest.raises(MpdAckError) as exc_info: raise MpdNotImplementedError assert exc_info.value.message == "Not implemented" def test_get_mpd_ack_with_default_values(self): e = MpdAckError("A description") assert e.get_mpd_ack() == "ACK [0@0] {None} A description" def test_get_mpd_ack_with_values(self): with pytest.raises(MpdAckError) as exc_info: raise MpdAckError("A description", index=7, command="foo") assert exc_info.value.get_mpd_ack() == "ACK [0@7] {foo} A description" def test_mpd_unknown_command(self): with pytest.raises(MpdAckError) as exc_info: raise MpdUnknownCommandError(command="play") assert exc_info.value.get_mpd_ack() == 'ACK [5@0] {} unknown command "play"' def test_mpd_no_command(self): with pytest.raises(MpdAckError) as exc_info: raise MpdNoCommandError assert exc_info.value.get_mpd_ack() == "ACK [5@0] {} No command given" def test_mpd_system_error(self): with pytest.raises(MpdSystemError) as exc_info: raise MpdSystemError("foo") assert exc_info.value.get_mpd_ack() == "ACK [52@0] {None} foo" def test_mpd_permission_error(self): with pytest.raises(MpdPermissionError) as exc_info: raise MpdPermissionError(command="foo") assert ( exc_info.value.get_mpd_ack() == 'ACK [4@0] {foo} you don\'t have permission for "foo"' ) def test_mpd_noexist_error(self): with pytest.raises(MpdNoExistError) as exc_info: raise MpdNoExistError(command="foo") assert exc_info.value.get_mpd_ack() == "ACK [50@0] {foo} " ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1793654065.9655182 mopidy_mpd-4.1.0/tests/test_extension.py0000644000175100017510000000112515272176462020125 0ustar00runnerrunnerfrom mopidy_mpd import Extension def test_get_default_config() -> None: ext = Extension() config = ext.get_default_config() assert "[mpd]" in config assert "enabled = true" in config def test_get_config_schema() -> None: ext = Extension() schema = ext.get_config_schema() assert "hostname" in schema assert "port" in schema assert "password" in schema assert "max_connections" in schema assert "connection_timeout" in schema assert "zeroconf" in schema assert "command_blacklist" in schema assert "default_playlist_scheme" in schema ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1793654065.9655182 mopidy_mpd-4.1.0/tests/test_path_utils.py0000644000175100017510000000077415272176462020276 0ustar00runnerrunnerimport os import unittest from tests import path_utils # TODO: kill this in favour of just os.path.getmtime + mocks class MtimeTest(unittest.TestCase): def tearDown(self): path_utils.mtime.undo_fake() def test_mtime_of_current_dir(self): mtime_dir = int(os.stat(".").st_mtime) # noqa: PTH116 assert mtime_dir == path_utils.mtime(".") def test_fake_time_is_returned(self): path_utils.mtime.set_fake_time(123456) assert path_utils.mtime(".") == 123456 ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1793654065.9655182 mopidy_mpd-4.1.0/tests/test_session.py0000644000175100017510000000306615272176462017602 0ustar00runnerrunnerimport logging from unittest.mock import Mock, sentinel from mopidy_mpd import dispatcher, network, session def test_on_start_logged(caplog): caplog.set_level(logging.INFO) connection = Mock(spec=network.Connection) session.MpdSession( config=None, core=None, uri_map=None, connection=connection, ).on_start() assert f"New MPD connection from {connection}" in caplog.text def test_on_line_received_logged(caplog): caplog.set_level(logging.DEBUG) connection = Mock(spec=network.Connection) mpd_session = session.MpdSession( config=None, core=None, uri_map=None, connection=connection, ) mpd_session.dispatcher = Mock(spec=dispatcher.MpdDispatcher) mpd_session.dispatcher.handle_request.return_value = [str(sentinel.resp)] mpd_session.on_line_received("foobar") assert f"Request from {connection}: foobar" in caplog.text assert f"Response to {connection}:" in caplog.text def test_on_line_received_escapes_markup_in_log(caplog): caplog.set_level(logging.DEBUG) connection = Mock(spec=network.Connection) mpd_session = session.MpdSession( config=None, core=None, uri_map=None, connection=connection, ) mpd_session.dispatcher = Mock(spec=dispatcher.MpdDispatcher) mpd_session.dispatcher.handle_request.return_value = ["Title: [/x]"] mpd_session.on_line_received('find title "[/x]"') assert f'Request from {connection}: find title "\\[/x]"' in caplog.text assert "Title: \\[/x]" in caplog.text ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1793654065.9655182 mopidy_mpd-4.1.0/tests/test_status.py0000644000175100017510000002135215272176462017440 0ustar00runnerrunnerimport unittest from typing import cast import pykka from mopidy import core from mopidy.models import Track from mopidy_mpd import dispatcher, uri_mapper from mopidy_mpd.protocol import status from tests import dummy_audio, dummy_backend, dummy_mixer # TODO: migrate to using protocol.BaseTestCase instead of status.stats directly? class StatusHandlerTest(unittest.TestCase): def setUp(self): config = { "core": {"max_tracklist_length": 10000}, "mpd": {"password": None}, } self.audio = dummy_audio.create_proxy() self.mixer = dummy_mixer.create_proxy() self.backend = dummy_backend.create_proxy(audio=self.audio) self.core = cast( "core.CoreProxy", core.Core.start( config, audio=self.audio, mixer=self.mixer, backends=[self.backend], ).proxy(), ) self.dispatcher = dispatcher.MpdDispatcher( config=config, core=self.core, uri_map=uri_mapper.MpdUriMapper(self.core), session=None, ) self.context = self.dispatcher.context def tearDown(self): pykka.ActorRegistry.stop_all() def set_tracklist(self, tracks): self.backend.library.dummy_library = tracks self.core.tracklist.add(uris=[track.uri for track in tracks]).get() def test_stats_method(self): result = status.stats(self.context) assert "artists" in result assert int(result["artists"]) >= 0 assert "albums" in result assert int(result["albums"]) >= 0 assert "songs" in result assert int(result["songs"]) >= 0 assert "uptime" in result assert int(result["uptime"]) >= 0 assert "db_playtime" in result assert int(result["db_playtime"]) >= 0 assert "db_update" in result assert int(result["db_update"]) >= 0 assert "playtime" in result assert int(result["playtime"]) >= 0 def test_status_method_contains_volume_with_na_value(self): result = dict(status.status(self.context)) assert "volume" in result assert int(result["volume"]) == (-1) def test_status_method_contains_volume(self): self.core.mixer.set_volume(17) result = dict(status.status(self.context)) assert "volume" in result assert int(result["volume"]) == 17 def test_status_method_contains_repeat_is_0(self): result = dict(status.status(self.context)) assert "repeat" in result assert int(result["repeat"]) == 0 def test_status_method_contains_repeat_is_1(self): self.core.tracklist.set_repeat(True) result = dict(status.status(self.context)) assert "repeat" in result assert int(result["repeat"]) == 1 def test_status_method_contains_random_is_0(self): result = dict(status.status(self.context)) assert "random" in result assert int(result["random"]) == 0 def test_status_method_contains_random_is_1(self): self.core.tracklist.set_random(True) result = dict(status.status(self.context)) assert "random" in result assert int(result["random"]) == 1 def test_status_method_contains_single(self): result = dict(status.status(self.context)) assert "single" in result assert int(result["single"]) in (0, 1) def test_status_method_contains_consume_is_0(self): result = dict(status.status(self.context)) assert "consume" in result assert int(result["consume"]) == 0 def test_status_method_contains_consume_is_1(self): self.core.tracklist.set_consume(True) result = dict(status.status(self.context)) assert "consume" in result assert int(result["consume"]) == 1 def test_status_method_contains_playlist(self): result = dict(status.status(self.context)) assert "playlist" in result assert int(result["playlist"]) >= 0 assert int(result["playlist"]) <= ((2**31) - 1) def test_status_method_contains_playlistlength(self): result = dict(status.status(self.context)) assert "playlistlength" in result assert int(result["playlistlength"]) >= 0 def test_status_method_contains_xfade(self): result = dict(status.status(self.context)) assert "xfade" in result assert int(result["xfade"]) >= 0 def test_status_method_contains_state_is_play(self): self.set_tracklist([Track(uri="dummy:/a")]) self.core.playback.play().get() result = dict(status.status(self.context)) assert "state" in result assert result["state"] == "play" def test_status_method_contains_state_is_stop(self): result = dict(status.status(self.context)) assert "state" in result assert result["state"] == "stop" def test_status_method_contains_state_is_pause(self): self.set_tracklist([Track(uri="dummy:/a")]) self.core.playback.play().get() self.core.playback.pause().get() result = dict(status.status(self.context)) assert "state" in result assert result["state"] == "pause" def test_status_method_when_playlist_loaded_contains_song(self): self.set_tracklist([Track(uri="dummy:/a")]) self.core.playback.play().get() result = dict(status.status(self.context)) assert "song" in result assert int(result["song"]) >= 0 def test_status_method_when_playlist_loaded_contains_tlid_as_songid(self): self.set_tracklist([Track(uri="dummy:/a")]) self.core.playback.play().get() result = dict(status.status(self.context)) assert "songid" in result assert int(result["songid"]) == 1 def test_status_method_when_playlist_loaded_contains_nextsong(self): self.set_tracklist([Track(uri="dummy:/a"), Track(uri="dummy:/b")]) self.core.playback.play().get() result = dict(status.status(self.context)) assert "nextsong" in result assert int(result["nextsong"]) >= 0 def test_status_method_when_playlist_loaded_contains_nextsongid(self): self.set_tracklist([Track(uri="dummy:/a"), Track(uri="dummy:/b")]) self.core.playback.play().get() result = dict(status.status(self.context)) assert "nextsongid" in result assert int(result["nextsongid"]) == 2 def test_status_method_when_playing_contains_time_with_no_length(self): self.set_tracklist([Track(uri="dummy:/a", length=None)]) self.core.playback.play().get() result = dict(status.status(self.context)) assert "time" in result (position, total) = result["time"].split(":") position = int(position) total = int(total) assert position <= total def test_status_method_when_playing_contains_time_with_length(self): self.set_tracklist([Track(uri="dummy:/a", length=10000)]) self.core.playback.play().get() result = dict(status.status(self.context)) assert "time" in result (position, total) = result["time"].split(":") position = int(position) total = int(total) assert position <= total def test_status_method_when_playing_contains_duration(self): self.set_tracklist([Track(uri="dummy:/a", length=60123)]) self.core.playback.play().get() result = dict(status.status(self.context)) assert "duration" in result assert result["duration"] == "60.123" def test_status_method_when_playing_with_no_length_has_no_duration(self): self.set_tracklist([Track(uri="dummy:/a", length=None)]) self.core.playback.play().get() result = dict(status.status(self.context)) assert "duration" not in result def test_status_method_when_playing_contains_elapsed(self): self.set_tracklist([Track(uri="dummy:/a", length=60000)]) self.core.playback.play().get() self.core.playback.pause() self.core.playback.seek(59123) result = dict(status.status(self.context)) assert "elapsed" in result assert result["elapsed"] == "59.123" def test_status_method_when_starting_playing_contains_elapsed_zero(self): self.set_tracklist([Track(uri="dummy:/a", length=10000)]) self.core.playback.play().get() self.core.playback.pause() result = dict(status.status(self.context)) assert "elapsed" in result assert result["elapsed"] == "0.000" def test_status_method_when_playing_contains_bitrate(self): self.set_tracklist([Track(uri="dummy:/a", bitrate=3200)]) self.core.playback.play().get() result = dict(status.status(self.context)) assert "bitrate" in result assert int(result["bitrate"]) == 3200 ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1793654065.965916 mopidy_mpd-4.1.0/tests/test_tokenizer.py0000644000175100017510000001444015272176462020127 0ustar00runnerrunnerimport unittest from mopidy_mpd import exceptions, tokenize class TestTokenizer(unittest.TestCase): def assertTokenizeEquals(self, expected, line): # noqa: N802 assert expected == tokenize.split(line) def assertTokenizeRaises(self, exception, message, line): # noqa: N802 with self.assertRaises(exception) as cm: tokenize.split(line) assert cm.exception.message == message def test_empty_string(self): ex = exceptions.MpdNoCommandError msg = "No command given" self.assertTokenizeRaises(ex, msg, "") self.assertTokenizeRaises(ex, msg, " ") self.assertTokenizeRaises(ex, msg, "\t\t\t") def test_command(self): self.assertTokenizeEquals(["test"], "test") self.assertTokenizeEquals(["test123"], "test123") self.assertTokenizeEquals(["foo_bar"], "foo_bar") def test_command_trailing_whitespace(self): self.assertTokenizeEquals(["test"], "test ") self.assertTokenizeEquals(["test"], "test\t\t\t") def test_command_leading_whitespace(self): ex = exceptions.MpdUnknownError msg = "Letter expected" self.assertTokenizeRaises(ex, msg, " test") self.assertTokenizeRaises(ex, msg, "\ttest") def test_invalid_command(self): ex = exceptions.MpdUnknownError msg = "Invalid word character" self.assertTokenizeRaises(ex, msg, "foo/bar") self.assertTokenizeRaises(ex, msg, "æøå") self.assertTokenizeRaises(ex, msg, "test?") self.assertTokenizeRaises(ex, msg, 'te"st') def test_unquoted_param(self): self.assertTokenizeEquals(["test", "param"], "test param") self.assertTokenizeEquals(["test", "param"], "test\tparam") def test_unquoted_param_leading_whitespace(self): self.assertTokenizeEquals(["test", "param"], "test param") self.assertTokenizeEquals(["test", "param"], "test\t\tparam") def test_unquoted_param_trailing_whitespace(self): self.assertTokenizeEquals(["test", "param"], "test param ") self.assertTokenizeEquals(["test", "param"], "test param\t\t") def test_unquoted_param_invalid_chars(self): ex = exceptions.MpdArgError msg = "Invalid unquoted character" self.assertTokenizeRaises(ex, msg, 'test par"m') self.assertTokenizeRaises(ex, msg, "test foo\bbar") self.assertTokenizeRaises(ex, msg, 'test foo"bar"baz') self.assertTokenizeRaises(ex, msg, "test foo'bar") def test_unquoted_param_numbers(self): self.assertTokenizeEquals(["test", "123"], "test 123") self.assertTokenizeEquals(["test", "+123"], "test +123") self.assertTokenizeEquals(["test", "-123"], "test -123") self.assertTokenizeEquals(["test", "3.14"], "test 3.14") def test_unquoted_param_extended_chars(self): self.assertTokenizeEquals(["test", "æøå"], "test æøå") self.assertTokenizeEquals(["test", "?#$"], "test ?#$") self.assertTokenizeEquals(["test", "/foo/bar/"], "test /foo/bar/") self.assertTokenizeEquals(["test", "foo\\bar"], "test foo\\bar") def test_unquoted_params(self): self.assertTokenizeEquals(["test", "foo", "bar"], "test foo bar") def test_quoted_param(self): self.assertTokenizeEquals(["test", "param"], 'test "param"') self.assertTokenizeEquals(["test", "param"], 'test\t"param"') def test_quoted_param_leading_whitespace(self): self.assertTokenizeEquals(["test", "param"], 'test "param"') self.assertTokenizeEquals(["test", "param"], 'test\t\t"param"') def test_quoted_param_trailing_whitespace(self): self.assertTokenizeEquals(["test", "param"], 'test "param" ') self.assertTokenizeEquals(["test", "param"], 'test "param"\t\t') def test_quoted_param_invalid_chars(self): ex = exceptions.MpdArgError msg = "Space expected after closing '\"'" self.assertTokenizeRaises(ex, msg, 'test "foo"bar"') self.assertTokenizeRaises(ex, msg, 'test "foo"bar" ') self.assertTokenizeRaises(ex, msg, 'test "foo"bar') self.assertTokenizeRaises(ex, msg, 'test "foo"bar ') def test_quoted_param_numbers(self): self.assertTokenizeEquals(["test", "123"], 'test "123"') self.assertTokenizeEquals(["test", "+123"], 'test "+123"') self.assertTokenizeEquals(["test", "-123"], 'test "-123"') self.assertTokenizeEquals(["test", "3.14"], 'test "3.14"') def test_quoted_param_spaces(self): self.assertTokenizeEquals(["test", "foo bar"], 'test "foo bar"') self.assertTokenizeEquals(["test", "foo bar"], 'test "foo bar"') self.assertTokenizeEquals(["test", " param\t"], 'test " param\t"') def test_quoted_param_extended_chars(self): self.assertTokenizeEquals(["test", "æøå"], 'test "æøå"') self.assertTokenizeEquals(["test", "?#$"], 'test "?#$"') self.assertTokenizeEquals(["test", "/foo/bar/"], 'test "/foo/bar/"') def test_quoted_param_escaping(self): self.assertTokenizeEquals(["test", "\\"], r'test "\\"') self.assertTokenizeEquals(["test", '"'], r'test "\""') self.assertTokenizeEquals(["test", " "], r'test "\ "') self.assertTokenizeEquals(["test", "\\n"], r'test "\\\n"') def test_quoted_params(self): self.assertTokenizeEquals(["test", "foo", "bar"], 'test "foo" "bar"') def test_mixed_params(self): self.assertTokenizeEquals(["test", "foo", "bar"], 'test foo "bar"') self.assertTokenizeEquals(["test", "foo", "bar"], 'test "foo" bar') self.assertTokenizeEquals(["test", "1", "2"], 'test 1 "2"') self.assertTokenizeEquals(["test", "1", "2"], 'test "1" 2') self.assertTokenizeEquals( ["test", "foo bar", "baz", "123"], 'test "foo bar" baz 123' ) self.assertTokenizeEquals( ["test", 'foo"bar', "baz", "123"], r'test "foo\"bar" baz 123' ) def test_unbalanced_quotes(self): ex = exceptions.MpdArgError msg = "Invalid unquoted character" self.assertTokenizeRaises(ex, msg, 'test "foo bar" baz"') def test_missing_closing_quote(self): ex = exceptions.MpdArgError msg = "Missing closing '\"'" self.assertTokenizeRaises(ex, msg, 'test "foo') self.assertTokenizeRaises(ex, msg, 'test "foo a ') ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1793654065.965916 mopidy_mpd-4.1.0/tests/test_translator.py0000644000175100017510000002537715272176462020321 0ustar00runnerrunnerimport unittest from mopidy.models import Album, Artist, Playlist, TlTrack, Track from mopidy.types import DurationMs, ReleaseDate, TracklistId, Uri from mopidy_mpd import translator from mopidy_mpd.protocol import tagtype_list from tests import path_utils class TrackMpdFormatTest(unittest.TestCase): track = Track( uri=Uri("à uri"), artists=frozenset( [ Artist(name="an artist"), Artist(name="yet another artist"), ] ), name="a nàme", album=Album( name="an album", num_tracks=13, artists=frozenset( [ Artist(name="an other artist"), Artist(name="still another artist"), ] ), uri=Uri("urischeme:àlbum:12345"), ), track_no=7, composers=frozenset( [ Artist(name="a composer"), Artist(name="another composer"), ] ), performers=frozenset( [ Artist(name="a performer"), Artist(name="another performer"), ] ), genre="a genre", date=ReleaseDate("1977-01-01"), disc_no=1, comment="a comment", length=DurationMs(137000), ) def setUp(self): self.media_dir = "/dir/subdir" path_utils.mtime.set_fake_time(1234567) def tearDown(self): path_utils.mtime.undo_fake() def test_track_to_mpd_format_for_empty_track(self): result = translator.track_to_mpd_format( Track( uri=Uri("a uri"), length=DurationMs(137000), ), tagtype_list.TAGTYPE_LIST, ) assert ("file", "a uri") in result assert ("Time", 137) in result assert ("duration", "137.000") in result assert ("Artist", "") not in result assert ("Title", "") not in result assert ("Album", "") not in result assert ("Track", 0) not in result assert ("Date", "") not in result assert len(result) == 3 def test_track_to_mpd_format_duration_has_millisecond_precision(self): result = translator.track_to_mpd_format( Track(uri=Uri("a uri"), length=DurationMs(137123)), tagtype_list.TAGTYPE_LIST, ) assert ("Time", 137) in result assert ("duration", "137.123") in result def test_track_to_mpd_format_without_length_has_no_duration(self): result = translator.track_to_mpd_format( Track(uri=Uri("a uri")), tagtype_list.TAGTYPE_LIST, ) assert ("Time", 0) in result assert "duration" not in dict(result) def test_track_to_mpd_format_with_position(self): result = translator.track_to_mpd_format( Track(uri=Uri("test:foo")), tagtype_list.TAGTYPE_LIST, position=1, ) assert ("Pos", 1) not in result def test_track_to_mpd_format_with_tlid(self): result = translator.track_to_mpd_format( TlTrack( tlid=TracklistId(1), track=Track(uri=Uri("test:foo")), ), tagtype_list.TAGTYPE_LIST, ) assert ("Id", 1) not in result def test_track_to_mpd_format_with_position_and_tlid(self): result = translator.track_to_mpd_format( TlTrack( tlid=TracklistId(2), track=Track(uri=Uri("a uri")), ), tagtype_list.TAGTYPE_LIST, position=1, ) assert ("Pos", 1) in result assert ("Id", 2) in result def test_track_to_mpd_format_for_nonempty_track(self): result = translator.track_to_mpd_format( TlTrack(122, self.track), tagtype_list.TAGTYPE_LIST, position=9 ) assert ("file", "à uri") in result assert ("Time", 137) in result assert ("duration", "137.000") in result assert ("Artist", "an artist") in result assert ("Artist", "yet another artist") in result assert ("Title", "a nàme") in result assert ("Album", "an album") in result assert ("AlbumArtist", "an other artist") in result assert ("AlbumArtist", "still another artist") in result assert ("Composer", "a composer") in result assert ("Composer", "another composer") in result assert ("Performer", "a performer") in result assert ("Performer", "another performer") in result assert ("Genre", "a genre") in result assert ("Track", 7) in result assert ("Date", "1977-01-01") in result assert ("Disc", 1) in result assert ("Pos", 9) in result assert ("Id", 122) in result assert ("X-AlbumUri", "urischeme:àlbum:12345") in result assert ("Comment", "a comment") not in result assert len(result) == 20 def test_track_to_mpd_format_with_last_modified(self): track = self.track.replace(last_modified=995303899000) result = translator.track_to_mpd_format(track, tagtype_list.TAGTYPE_LIST) assert ("Last-Modified", "2001-07-16T17:18:19Z") in result def test_track_to_mpd_format_with_last_modified_of_zero(self): track = self.track.replace(last_modified=0) result = translator.track_to_mpd_format(track, tagtype_list.TAGTYPE_LIST) keys = [k for k, v in result] assert "Last-Modified" not in keys def test_track_to_mpd_format_musicbrainz_trackid(self): track = self.track.replace( musicbrainz_id="715d581b-ef70-46d5-984b-e2c1d8feb8a0" ) result = translator.track_to_mpd_format(track, tagtype_list.TAGTYPE_LIST) assert ("MUSICBRAINZ_TRACKID", "715d581b-ef70-46d5-984b-e2c1d8feb8a0") in result def test_track_to_mpd_format_musicbrainz_albumid(self): assert self.track.album album = self.track.album.replace( musicbrainz_id="715d581b-ef70-46d5-984b-e2c1d8feb8a0" ) track = self.track.replace(album=album) result = translator.track_to_mpd_format(track, tagtype_list.TAGTYPE_LIST) assert ("MUSICBRAINZ_ALBUMID", "715d581b-ef70-46d5-984b-e2c1d8feb8a0") in result def test_track_to_mpd_format_musicbrainz_albumartistid(self): artist = next(iter(self.track.artists)).replace( musicbrainz_id="715d581b-ef70-46d5-984b-e2c1d8feb8a0" ) assert self.track.album album = self.track.album.replace(artists=[artist]) track = self.track.replace(album=album) result = translator.track_to_mpd_format(track, tagtype_list.TAGTYPE_LIST) assert ( "MUSICBRAINZ_ALBUMARTISTID", "715d581b-ef70-46d5-984b-e2c1d8feb8a0", ) in result def test_track_to_mpd_format_musicbrainz_artistid(self): artist = next(iter(self.track.artists)).replace( musicbrainz_id="715d581b-ef70-46d5-984b-e2c1d8feb8a0" ) track = self.track.replace(artists=[artist]) result = translator.track_to_mpd_format(track, tagtype_list.TAGTYPE_LIST) assert ( "MUSICBRAINZ_ARTISTID", "715d581b-ef70-46d5-984b-e2c1d8feb8a0", ) in result def test_concat_multi_values(self): artists = [Artist(name="ABBA"), Artist(name="Beatles")] translated = translator.concat_multi_values(artists, "name") assert translated == "ABBA;Beatles" def test_concat_multi_values_artist_with_no_name(self): artists = [Artist(name=None)] translated = translator.concat_multi_values(artists, "name") assert translated == "" def test_concat_multi_values_artist_with_no_musicbrainz_id(self): artists = [Artist(name="Jah Wobble")] translated = translator.concat_multi_values(artists, "musicbrainz_id") assert translated == "" def test_track_to_mpd_format_with_stream_title(self): result = translator.track_to_mpd_format( self.track, tagtype_list.TAGTYPE_LIST, stream_title="foo" ) assert ("Name", "a nàme") in result assert ("Title", "foo") in result def test_track_to_mpd_format_with_empty_stream_title(self): result = translator.track_to_mpd_format( self.track, tagtype_list.TAGTYPE_LIST, stream_title="" ) assert ("Name", "a nàme") in result assert ("Title", "") not in result def test_track_to_mpd_format_with_stream_and_no_track_name(self): track = self.track.replace(name=None) result = translator.track_to_mpd_format( track, tagtype_list.TAGTYPE_LIST, stream_title="foo" ) assert ("Name", "") not in result assert ("Title", "foo") in result def test_track_to_mpd_client_filtered(self): configured_tagtypes = { "Artist", "Album", "Title", "Track", "Name", "Genre", } result = translator.track_to_mpd_format( TlTrack(tlid=TracklistId(122), track=self.track), configured_tagtypes, position=9, ) assert ("file", "à uri") in result assert ("Time", 137) in result assert ("duration", "137.000") in result assert ("Artist", "an artist") in result assert ("Artist", "yet another artist") in result assert ("Title", "a nàme") in result assert ("Album", "an album") in result assert ("Genre", "a genre") in result assert ("Track", 7) in result assert ("Pos", 9) in result assert ("Id", 122) in result assert len(result) == 11 class PlaylistMpdFormatTest(unittest.TestCase): def test_mpd_format(self): playlist = Playlist( uri=Uri("test:playlist"), tracks=( Track(uri=Uri("foo"), track_no=1), Track(uri=Uri("bàr"), track_no=2), Track(uri=Uri("baz"), track_no=3), ), ) result = translator.playlist_to_mpd_format(playlist, tagtype_list.TAGTYPE_LIST) assert result == [ ("file", "foo"), ("Time", 0), ("Track", 1), ("file", "bàr"), ("Time", 0), ("Track", 2), ("file", "baz"), ("Time", 0), ("Track", 3), ] def test_mpd_format_with_range(self): playlist = Playlist( uri=Uri("test:playlist"), tracks=( Track(uri=Uri("foo"), track_no=1), Track(uri=Uri("bàr"), track_no=2), Track(uri=Uri("baz"), track_no=3), ), ) result = translator.playlist_to_mpd_format( playlist, tagtype_list.TAGTYPE_LIST, start=1, end=2 ) assert result == [("file", "bàr"), ("Time", 0), ("Track", 2)]