├── .copier-answers.yml ├── .github └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── LICENSE ├── README.md ├── pyproject.toml ├── src └── mopidy_mpd │ ├── __init__.py │ ├── actor.py │ ├── context.py │ ├── dispatcher.py │ ├── exceptions.py │ ├── ext.conf │ ├── formatting.py │ ├── network.py │ ├── protocol │ ├── __init__.py │ ├── audio_output.py │ ├── channels.py │ ├── command_list.py │ ├── connection.py │ ├── current_playlist.py │ ├── mount.py │ ├── music_db.py │ ├── playback.py │ ├── reflection.py │ ├── status.py │ ├── stickers.py │ ├── stored_playlists.py │ └── tagtype_list.py │ ├── session.py │ ├── tokenize.py │ ├── translator.py │ ├── types.py │ └── uri_mapper.py └── tests ├── __init__.py ├── dummy_audio.py ├── dummy_backend.py ├── dummy_mixer.py ├── network ├── __init__.py ├── test_connection.py ├── test_lineprotocol.py ├── test_server.py └── test_utils.py ├── path_utils.py ├── protocol ├── __init__.py ├── test_audio_output.py ├── test_authentication.py ├── test_channels.py ├── test_command_list.py ├── test_connection.py ├── test_current_playlist.py ├── test_idle.py ├── test_mount.py ├── test_music_db.py ├── test_playback.py ├── test_reflection.py ├── test_regression.py ├── test_status.py ├── test_stickers.py └── test_stored_playlists.py ├── test_actor.py ├── test_commands.py ├── test_context.py ├── test_dispatcher.py ├── test_exceptions.py ├── test_extension.py ├── test_path_utils.py ├── test_session.py ├── test_status.py ├── test_tokenizer.py └── test_translator.py /.copier-answers.yml: -------------------------------------------------------------------------------- 1 | _commit: v2.1.2 2 | _src_path: gh:mopidy/mopidy-ext-template 3 | author_email: stein.magnus@jodal.no 4 | author_full_name: Stein Magnus Jodal 5 | dist_name: mopidy-mpd 6 | ext_name: mpd 7 | github_username: mopidy 8 | short_description: Mopidy extension for controlling Mopidy from MPD clients 9 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | build: 11 | name: Build 12 | runs-on: ubuntu-24.04 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: hynek/build-and-inspect-python-package@v2 16 | 17 | main: 18 | strategy: 19 | fail-fast: false 20 | matrix: 21 | include: 22 | - name: "pytest (3.11)" 23 | python: "3.11" 24 | tox: "3.11" 25 | - name: "pytest (3.12)" 26 | python: "3.12" 27 | tox: "3.12" 28 | - name: "pytest (3.13)" 29 | python: "3.13" 30 | tox: "3.13" 31 | coverage: true 32 | - name: "pyright" 33 | python: "3.13" 34 | tox: "pyright" 35 | - name: "ruff check" 36 | python: "3.13" 37 | tox: "ruff-check" 38 | - name: "ruff format" 39 | python: "3.13" 40 | tox: "ruff-format" 41 | 42 | name: ${{ matrix.name }} 43 | runs-on: ubuntu-24.04 44 | container: ghcr.io/mopidy/ci:latest 45 | 46 | steps: 47 | - uses: actions/checkout@v4 48 | - name: Fix home dir permissions to enable pip caching 49 | run: chown -R root /github/home 50 | - uses: actions/setup-python@v5 51 | with: 52 | python-version: ${{ matrix.python }} 53 | cache: pip 54 | allow-prereleases: true 55 | - run: python -m pip install tox 56 | - run: python -m tox -e ${{ matrix.tox }} 57 | if: ${{ ! matrix.coverage }} 58 | - run: python -m tox -e ${{ matrix.tox }} -- --cov-report=xml 59 | if: ${{ matrix.coverage }} 60 | - uses: codecov/codecov-action@v5 61 | if: ${{ matrix.coverage }} 62 | with: 63 | token: ${{ secrets.CODECOV_TOKEN }} 64 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | release: 9 | runs-on: ubuntu-24.04 10 | environment: 11 | name: pypi 12 | url: https://pypi.org/project/mopidy-mpd/ 13 | permissions: 14 | id-token: write 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: hynek/build-and-inspect-python-package@v2 18 | id: build 19 | - uses: actions/download-artifact@v4 20 | with: 21 | name: ${{ steps.build.outputs.artifact-name }} 22 | path: dist 23 | - uses: pypa/gh-action-pypi-publish@release/v1 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info/ 2 | /*.lock 3 | /.*_cache/ 4 | /.coverage 5 | /.tox/ 6 | /.venv/ 7 | /build/ 8 | /dist/ 9 | __pycache__/ 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mopidy-mpd 2 | 3 | [![Latest PyPI version](https://img.shields.io/pypi/v/mopidy-mpd)](https://pypi.org/p/mopidy-mpd) 4 | [![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) 5 | [![Test coverage](https://img.shields.io/codecov/c/gh/mopidy/mopidy-mpd)](https://codecov.io/gh/mopidy/mopidy-mpd) 6 | 7 | [Mopidy](https://mopidy.com/) extension for controlling Mopidy from MPD 8 | clients. 9 | 10 | MPD stands for Music Player Daemon, which is also the name of the 11 | [original MPD server project](https://www.musicpd.org/). Mopidy does not 12 | depend on the original MPD server, but implements the MPD protocol 13 | itself, and is thus compatible with most clients built for the original 14 | MPD server. 15 | 16 | ## Maintainer wanted 17 | 18 | Mopidy-MPD is currently kept on life support by the Mopidy core 19 | developers. It is in need of a more dedicated maintainer. 20 | 21 | If you want to be the maintainer of Mopidy-MPD, please: 22 | 23 | 1. Make 2-3 good pull requests improving any part of the project. 24 | 25 | 2. Read and get familiar with all of the project's open issues. 26 | 27 | 3. Send a pull request removing this section and adding yourself as the 28 | "Current maintainer" in the "Credits" section below. In the pull 29 | request description, please refer to the previous pull requests and 30 | state that you've familiarized yourself with the open issues. 31 | 32 | As a maintainer, you'll be given push access to the repo and the 33 | authority to make releases to PyPI when you see fit. 34 | 35 | ## Installation 36 | 37 | Install by running: 38 | 39 | ```sh 40 | python3 -m pip install mopidy-mpd 41 | ``` 42 | 43 | See https://mopidy.com/ext/mpd/ for alternative installation methods. 44 | 45 | ## Configuration 46 | 47 | Before starting Mopidy, you must add configuration for 48 | mopidy-mpd to your Mopidy configuration file: 49 | 50 | ```ini 51 | [mpd] 52 | hostname = :: 53 | ``` 54 | 55 | > [!WARNING] 56 | > As a simple security measure, the MPD server is by default only available from 57 | > localhost. To make it available from other computers, change the 58 | > `mpd/hostname` config value. 59 | > 60 | > Before you do so, note that the MPD server does not support any form of 61 | > encryption and only a single clear text password (see `mpd/password`) for weak 62 | > authentication. Anyone able to access the MPD server can control music 63 | > playback on your computer. Thus, you probably only want to make the MPD server 64 | > available from your local network. You have been warned. 65 | 66 | The following configuration values are available: 67 | 68 | - `mpd/enabled`: If the MPD extension should be enabled or not. 69 | - `mpd/hostname`: Which address the MPD server should bind to. This 70 | can be a network address or the path toa Unix socket: 71 | - `127.0.0.1`: Listens only on the IPv4 loopback interface 72 | (default). 73 | - `::1`: Listens only on the IPv6 loopback interface. 74 | - `0.0.0.0`: Listens on all IPv4 interfaces. 75 | - `::`: Listens on all interfaces, both IPv4 and IPv6. 76 | - `unix:/path/to/unix/socket.sock`: Listen on the Unix socket at 77 | the specified path. Must be prefixed with `unix:`. 78 | - `mpd/port`: Which TCP port the MPD server should listen to. Default: 6600. 79 | - `mpd/password`: The password required for connecting to the MPD 80 | server. If blank, no password is required. Default: blank. 81 | - `mpd/max_connections`: The maximum number of concurrent connections 82 | the MPD server will accept. Default: 20. 83 | - `mpd/connection_timeout`: Number of seconds an MPD client can stay 84 | inactive before the connection is closed by the server. Default: 60. 85 | - `mpd/zeroconf`: Name of the MPD service when published through 86 | Zeroconf. The variables `$hostname` and `$port` can be used in the 87 | name. Set to an empty string to disable Zeroconf for MPD. Default: 88 | `Mopidy MPD server on $hostname` 89 | - `mpd/command_blacklist`: List of MPD commands which are disabled by 90 | the server. By default this blacklists `listall` and `listallinfo`. 91 | These commands don't fit well with many of Mopidy's backends and 92 | are better left disabled unless you know what you are doing. 93 | - `mpd/default_playlist_scheme`: The URI scheme used if the server 94 | cannot find a backend appropriate for creating a playlist from the 95 | given tracks. Default: `m3u` 96 | 97 | ## Limitations 98 | 99 | This is a non-exhaustive list of MPD features that Mopidy doesn't 100 | support. 101 | 102 | - Only a single password is supported. It gives all-or-nothing access. 103 | - Toggling of audio outputs is not supported. 104 | - Channels for client-to-client communication are not supported. 105 | - Stickers are not supported. 106 | - Crossfade is not supported. 107 | - Replay gain is not supported. 108 | - `stats` does not provide any statistics. 109 | - `decoders` does not provide information about available decoders. 110 | - Live update of the music database is not supported. 111 | 112 | ## Clients 113 | 114 | Over the years, a huge number of MPD clients have been built for every 115 | thinkable platform. As always, the quality and state of maintenance 116 | varies between clients, so you might have to try a couple before you 117 | find one you like for your purpose. In general, they should all work 118 | with Mopidy-MPD. 119 | 120 | The [Wikipedia article on 121 | MPD](https://en.wikipedia.org/wiki/Music_Player_Daemon#Clients) has a 122 | short list of well-known clients. In the MPD wiki there is a [more 123 | complete list](https://mpd.fandom.com/wiki/Clients) of the available MPD 124 | clients. Both lists are grouped by user interface, e.g. terminal, 125 | graphical, or web-based 126 | 127 | ## Project resources 128 | 129 | - [Source code](https://github.com/mopidy/mopidy-mpd) 130 | - [Issues](https://github.com/mopidy/mopidy-mpd/issues) 131 | - [Releases](https://github.com/mopidy/mopidy-mpd/releases) 132 | 133 | ## Development 134 | 135 | ### Set up development environment 136 | 137 | Clone the repo using, e.g. using [gh](https://cli.github.com/): 138 | 139 | ```sh 140 | gh repo clone mopidy/mopidy-mpd 141 | ``` 142 | 143 | Enter the directory, and install dependencies using [uv](https://docs.astral.sh/uv/): 144 | 145 | ```sh 146 | cd mopidy-mpd/ 147 | uv sync 148 | ``` 149 | 150 | ### Running tests 151 | 152 | To run all tests and linters in isolated environments, use 153 | [tox](https://tox.wiki/): 154 | 155 | ```sh 156 | tox 157 | ``` 158 | 159 | To only run tests, use [pytest](https://pytest.org/): 160 | 161 | ```sh 162 | pytest 163 | ``` 164 | 165 | To format the code, use [ruff](https://docs.astral.sh/ruff/): 166 | 167 | ```sh 168 | ruff format . 169 | ``` 170 | 171 | To check for lints with ruff, run: 172 | 173 | ```sh 174 | ruff check . 175 | ``` 176 | 177 | To check for type errors, use [pyright](https://microsoft.github.io/pyright/): 178 | 179 | ```sh 180 | pyright . 181 | ``` 182 | 183 | ### Making a release 184 | 185 | To make a release to PyPI, go to the project's [GitHub releases 186 | page](https://github.com/mopidy/mopidy-mpd/releases) 187 | and click the "Draft a new release" button. 188 | 189 | In the "choose a tag" dropdown, select the tag you want to release or create a 190 | new tag, e.g. `v0.1.0`. Add a title, e.g. `v0.1.0`, and a description of the changes. 191 | 192 | Decide if the release is a pre-release (alpha, beta, or release candidate) or 193 | should be marked as the latest release, and click "Publish release". 194 | 195 | Once the release is created, the `release.yml` GitHub Action will automatically 196 | build and publish the release to 197 | [PyPI](https://pypi.org/project/mopidy-mpd/). 198 | 199 | ## Credits 200 | 201 | - Original author: [Stein Magnus Jodal](https://github.com/jodal) and 202 | [Thomas Adamcik](https://github.com/adamcik) for the Mopidy-MPD 203 | extension in Mopidy core. 204 | - Current maintainer: None. Maintainer wanted, see section above. 205 | - [Contributors](https://github.com/mopidy/mopidy-mpd/graphs/contributors) 206 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "mopidy-mpd" 3 | description = "Mopidy extension for controlling Mopidy from MPD clients" 4 | readme = "README.md" 5 | requires-python = ">= 3.11" 6 | license = { text = "Apache-2.0" } 7 | authors = [{ name = "Stein Magnus Jodal", email = "stein.magnus@jodal.no" }] 8 | classifiers = [ 9 | "Environment :: No Input/Output (Daemon)", 10 | "Intended Audience :: End Users/Desktop", 11 | "License :: OSI Approved :: Apache Software License", 12 | "Operating System :: OS Independent", 13 | "Topic :: Multimedia :: Sound/Audio :: Players", 14 | ] 15 | dynamic = ["version"] 16 | dependencies = ["mopidy >= 4.0.0a4", "pygobject >= 3.42", "pykka >= 4"] 17 | 18 | [project.urls] 19 | Homepage = "https://github.com/mopidy/mopidy-mpd" 20 | 21 | [project.entry-points."mopidy.ext"] 22 | mpd = "mopidy_mpd:Extension" 23 | 24 | 25 | [build-system] 26 | requires = ["setuptools >= 66", "setuptools-scm >= 7.1"] 27 | build-backend = "setuptools.build_meta" 28 | 29 | 30 | [dependency-groups] 31 | dev = [ 32 | "tox", 33 | { include-group = "ruff" }, 34 | { include-group = "tests" }, 35 | { include-group = "typing" }, 36 | ] 37 | ruff = ["ruff"] 38 | tests = ["pytest", "pytest-cov"] 39 | typing = ["pyright"] 40 | 41 | 42 | [tool.coverage.paths] 43 | source = ["src/", "*/site-packages/"] 44 | 45 | [tool.coverage.run] 46 | source_pkgs = ["mopidy_mpd"] 47 | 48 | [tool.coverage.report] 49 | show_missing = true 50 | 51 | 52 | [tool.pyright] 53 | pythonVersion = "3.11" 54 | typeCheckingMode = "standard" 55 | # Not all dependencies have type hints: 56 | reportMissingTypeStubs = false 57 | # Already covered by ruff's flake8-self checks: 58 | reportPrivateImportUsage = false 59 | 60 | 61 | [tool.pytest.ini_options] 62 | filterwarnings = [ 63 | # By default, fail tests on warnings from our own code 64 | "error:::mopidy_mpd", 65 | # 66 | # Add any warnings you want to ignore here 67 | ] 68 | 69 | 70 | [tool.ruff] 71 | target-version = "py311" 72 | 73 | [tool.ruff.lint] 74 | select = ["ALL"] 75 | ignore = [ 76 | "A005", # stdlib-module-shadowing 77 | "ANN401", # any-type 78 | "D", # pydocstyle 79 | "D203", # one-blank-line-before-class 80 | "D213", # multi-line-summary-second-line 81 | "EM101", # raw-string-in-exception # TODO 82 | "EM102", # f-string-in-exception # TODO 83 | "FIX001", # line-contains-fixme 84 | "FIX002", # line-contains-todo 85 | "G004", # logging-f-string 86 | "S101", # assert # TODO 87 | "TD002", # missing-todo-author 88 | "TD003", # missing-todo-link 89 | "TD004", # missing-todo-colon # TODO 90 | "TD005", # missing-todo-description # TODO 91 | "TRY003", # raise-vanilla-args 92 | # 93 | # Conflicting with `ruff format` 94 | "COM812", # missing-trailing-comma 95 | "ISC001", # single-line-implicit-string-concatenation 96 | ] 97 | 98 | [tool.ruff.lint.per-file-ignores] 99 | "src/mopidy_mpd/protocol/*" = [ 100 | "ARG001", # unused-function-argument 101 | ] 102 | "tests/*" = [ 103 | "ANN", # flake8-annotations 104 | "ARG", # flake8-unused-arguments 105 | "D", # pydocstyle 106 | "FBT", # flake8-boolean-trap 107 | "PLR0913", # too-many-arguments 108 | "PLR2004", # magic-value-comparison 109 | "PT027", # pytest-unittest-raises-assertion 110 | "S101", # assert 111 | "SLF001", # private-member-access 112 | "TRY002", # raise-vanilla-class 113 | ] 114 | 115 | 116 | [tool.setuptools.package-data] 117 | "*" = ["*.conf"] 118 | 119 | 120 | [tool.setuptools_scm] 121 | # This section, even if empty, must be present for setuptools_scm to work 122 | 123 | 124 | [tool.tox] 125 | env_list = ["3.11", "3.12", "3.13", "pyright", "ruff-check", "ruff-format"] 126 | 127 | [tool.tox.env_run_base] 128 | package = "wheel" 129 | wheel_build_env = ".pkg" 130 | dependency_groups = ["tests"] 131 | commands = [ 132 | [ 133 | "pytest", 134 | "--cov", 135 | "--basetemp={envtmpdir}", 136 | { replace = "posargs", extend = true }, 137 | ], 138 | ] 139 | 140 | [tool.tox.env.pyright] 141 | dependency_groups = ["typing"] 142 | commands = [["pyright", "{posargs:src}"]] 143 | 144 | [tool.tox.env.ruff-check] 145 | skip_install = true 146 | dependency_groups = ["ruff"] 147 | commands = [["ruff", "check", "{posargs:.}"]] 148 | 149 | [tool.tox.env.ruff-format] 150 | skip_install = true 151 | dependency_groups = ["ruff"] 152 | commands = [["ruff", "format", "--check", "{posargs:.}"]] 153 | -------------------------------------------------------------------------------- /src/mopidy_mpd/__init__.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | from importlib.metadata import version 3 | 4 | from mopidy import config, ext 5 | 6 | __version__ = version("mopidy-mpd") 7 | 8 | 9 | class Extension(ext.Extension): 10 | dist_name = "mopidy-mpd" 11 | ext_name = "mpd" 12 | version = __version__ 13 | 14 | def get_default_config(self) -> str: 15 | return config.read(pathlib.Path(__file__).parent / "ext.conf") 16 | 17 | def get_config_schema(self) -> config.ConfigSchema: 18 | schema = super().get_config_schema() 19 | schema["hostname"] = config.Hostname() 20 | schema["port"] = config.Port(optional=True) 21 | schema["password"] = config.Secret(optional=True) 22 | schema["max_connections"] = config.Integer(minimum=1) 23 | schema["connection_timeout"] = config.Integer(minimum=1) 24 | schema["zeroconf"] = config.String(optional=True) 25 | schema["command_blacklist"] = config.List(optional=True) 26 | schema["default_playlist_scheme"] = config.String() 27 | return schema 28 | 29 | def setup(self, registry: ext.Registry) -> None: 30 | from .actor import MpdFrontend 31 | 32 | registry.add("frontend", MpdFrontend) 33 | -------------------------------------------------------------------------------- /src/mopidy_mpd/actor.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Any 3 | 4 | import pykka 5 | from mopidy import exceptions, listener, zeroconf 6 | from mopidy.core import CoreListener, CoreProxy 7 | 8 | from mopidy_mpd import network, session, types, uri_mapper 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | _CORE_EVENTS_TO_IDLE_SUBSYSTEMS = { 13 | "track_playback_paused": None, 14 | "track_playback_resumed": None, 15 | "track_playback_started": None, 16 | "track_playback_ended": None, 17 | "playback_state_changed": "player", 18 | "tracklist_changed": "playlist", 19 | "playlists_loaded": "stored_playlist", 20 | "playlist_changed": "stored_playlist", 21 | "playlist_deleted": "stored_playlist", 22 | "options_changed": "options", 23 | "volume_changed": "mixer", 24 | "mute_changed": "output", 25 | "seeked": "player", 26 | "stream_title_changed": "playlist", 27 | } 28 | 29 | 30 | class MpdFrontend(pykka.ThreadingActor, CoreListener): 31 | def __init__(self, config: types.Config, core: CoreProxy) -> None: 32 | super().__init__() 33 | 34 | self.hostname = network.format_hostname(config["mpd"]["hostname"]) 35 | self.port = config["mpd"]["port"] 36 | self.zeroconf_name = config["mpd"]["zeroconf"] 37 | self.zeroconf_service = None 38 | 39 | self.uri_map = uri_mapper.MpdUriMapper(core) 40 | self.server = self._setup_server(config, core) 41 | 42 | def _setup_server(self, config: types.Config, core: CoreProxy) -> network.Server: 43 | try: 44 | server = network.Server( 45 | config=config, 46 | core=core, 47 | uri_map=self.uri_map, 48 | protocol=session.MpdSession, 49 | host=self.hostname, 50 | port=self.port, 51 | max_connections=config["mpd"]["max_connections"], 52 | timeout=config["mpd"]["connection_timeout"], 53 | ) 54 | except OSError as exc: 55 | raise exceptions.FrontendError(f"MPD server startup failed: {exc}") from exc 56 | 57 | logger.info(f"MPD server running at {network.format_address(server.address)}") 58 | 59 | return server 60 | 61 | def on_start(self) -> None: 62 | if self.zeroconf_name and not network.is_unix_socket(self.server.server_socket): 63 | self.zeroconf_service = zeroconf.Zeroconf( 64 | name=self.zeroconf_name, stype="_mpd._tcp", port=self.port 65 | ) 66 | self.zeroconf_service.publish() 67 | 68 | def on_stop(self) -> None: 69 | if self.zeroconf_service: 70 | self.zeroconf_service.unpublish() 71 | 72 | session_actors = pykka.ActorRegistry.get_by_class(session.MpdSession) 73 | for session_actor in session_actors: 74 | session_actor.stop() 75 | 76 | self.server.stop() 77 | 78 | def on_event(self, event: str, **kwargs: Any) -> None: 79 | if event not in _CORE_EVENTS_TO_IDLE_SUBSYSTEMS: 80 | logger.warning("Got unexpected event: %s(%s)", event, ", ".join(kwargs)) 81 | else: 82 | self.send_idle(_CORE_EVENTS_TO_IDLE_SUBSYSTEMS[event]) 83 | 84 | def send_idle(self, subsystem: str | None) -> None: 85 | if subsystem: 86 | listener.send(session.MpdSession, subsystem) 87 | -------------------------------------------------------------------------------- /src/mopidy_mpd/context.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | import re 5 | from typing import ( 6 | TYPE_CHECKING, 7 | Any, 8 | Literal, 9 | overload, 10 | ) 11 | 12 | from mopidy_mpd import exceptions, types 13 | 14 | if TYPE_CHECKING: 15 | from collections.abc import Generator 16 | 17 | import pykka 18 | from mopidy.core import CoreProxy 19 | from mopidy.models import Ref, Track 20 | from mopidy.types import Uri 21 | 22 | from mopidy_mpd.dispatcher import MpdDispatcher 23 | from mopidy_mpd.session import MpdSession 24 | from mopidy_mpd.uri_mapper import MpdUriMapper 25 | 26 | 27 | logger = logging.getLogger(__name__) 28 | 29 | 30 | class MpdContext: 31 | """ 32 | This object is passed as the first argument to all MPD command handlers to 33 | give the command handlers access to important parts of Mopidy. 34 | """ 35 | 36 | #: The Mopidy config. 37 | config: types.Config 38 | 39 | #: The Mopidy core API. 40 | core: CoreProxy 41 | 42 | #: The current session instance. 43 | session: MpdSession 44 | 45 | #: The current dispatcher instance. 46 | dispatcher: MpdDispatcher 47 | 48 | #: Mapping of URIs to MPD names. 49 | uri_map: MpdUriMapper 50 | 51 | def __init__( 52 | self, 53 | config: types.Config, 54 | core: CoreProxy, 55 | uri_map: MpdUriMapper, 56 | session: MpdSession, 57 | dispatcher: MpdDispatcher, 58 | ) -> None: 59 | self.config = config 60 | self.core = core 61 | self.uri_map = uri_map 62 | self.session = session 63 | self.dispatcher = dispatcher 64 | 65 | @overload 66 | def browse( 67 | self, path: str | None, *, recursive: bool, lookup: Literal[True] 68 | ) -> Generator[ 69 | tuple[str, pykka.Future[dict[Uri, list[Track]]] | None], Any, None 70 | ]: ... 71 | 72 | @overload 73 | def browse( 74 | self, path: str | None, *, recursive: bool, lookup: Literal[False] 75 | ) -> Generator[tuple[str, Ref | None], Any, None]: ... 76 | 77 | def browse( # noqa: C901, PLR0912 78 | self, 79 | path: str | None, 80 | *, 81 | recursive: bool = True, 82 | lookup: bool = True, 83 | ) -> Generator[Any, Any, None]: 84 | """ 85 | Browse the contents of a given directory path. 86 | 87 | Returns a sequence of two-tuples ``(path, data)``. 88 | 89 | If ``recursive`` is true, it returns results for all entries in the 90 | given path. 91 | 92 | If ``lookup`` is true and the ``path`` is to a track, the returned 93 | ``data`` is a future which will contain the results from looking up 94 | the URI with :meth:`mopidy.core.LibraryController.lookup`. If 95 | ``lookup`` is false and the ``path`` is to a track, the returned 96 | ``data`` will be a :class:`mopidy.models.Ref` for the track. 97 | 98 | For all entries that are not tracks, the returned ``data`` will be 99 | :class:`None`. 100 | """ 101 | 102 | path_parts: list[str] = re.findall(r"[^/]+", path or "") 103 | root_path: str = "/".join(["", *path_parts]) 104 | 105 | uri = self.uri_map.uri_from_name(root_path) 106 | if uri is None: 107 | for part in path_parts: 108 | for ref in self.core.library.browse(uri).get(): 109 | if ref.type != ref.TRACK and ref.name == part: 110 | uri = ref.uri 111 | break 112 | else: 113 | raise exceptions.MpdNoExistError("Not found") 114 | root_path = self.uri_map.insert(root_path, uri) 115 | 116 | if recursive: 117 | yield (root_path, None) 118 | 119 | path_and_futures = [(root_path, self.core.library.browse(uri))] 120 | while path_and_futures: 121 | base_path, future = path_and_futures.pop() 122 | for ref in future.get(): 123 | if ref.name is None or ref.uri is None: 124 | continue 125 | 126 | path = "/".join([base_path, ref.name.replace("/", "")]) 127 | path = self.uri_map.insert(path, ref.uri) 128 | 129 | if ref.type == ref.TRACK: 130 | if lookup: 131 | # TODO: can we lookup all the refs at once now? 132 | yield (path, self.core.library.lookup(uris=[ref.uri])) 133 | else: 134 | yield (path, ref) 135 | else: 136 | yield (path, None) 137 | if recursive: 138 | path_and_futures.append( 139 | (path, self.core.library.browse(ref.uri)) 140 | ) 141 | -------------------------------------------------------------------------------- /src/mopidy_mpd/dispatcher.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | from collections.abc import Callable 5 | from typing import ( 6 | TYPE_CHECKING, 7 | NewType, 8 | TypeAlias, 9 | TypeVar, 10 | ) 11 | 12 | import pykka 13 | 14 | from mopidy_mpd import context, exceptions, protocol, tokenize, types 15 | 16 | if TYPE_CHECKING: 17 | from mopidy.core import CoreProxy 18 | 19 | from mopidy_mpd.session import MpdSession 20 | from mopidy_mpd.uri_mapper import MpdUriMapper 21 | 22 | 23 | logger = logging.getLogger(__name__) 24 | 25 | protocol.load_protocol_modules() 26 | 27 | T = TypeVar("T") 28 | Request: TypeAlias = str 29 | Response = NewType("Response", list[str]) 30 | Filter: TypeAlias = Callable[[Request, Response, list["Filter"]], Response] 31 | 32 | 33 | class MpdDispatcher: 34 | """ 35 | The MPD session feeds the MPD dispatcher with requests. The dispatcher 36 | finds the correct handler, processes the request, and sends the response 37 | back to the MPD session. 38 | """ 39 | 40 | #: The active subsystems that have pending events. 41 | subsystem_events: set[str] 42 | 43 | #: The subsystems that we want to be notified about in idle mode. 44 | subsystem_subscriptions: set[str] 45 | 46 | def __init__( 47 | self, 48 | config: types.Config, 49 | core: CoreProxy, 50 | uri_map: MpdUriMapper, 51 | session: MpdSession, 52 | ) -> None: 53 | self.config = config 54 | self.session = session 55 | 56 | self.authenticated = False 57 | 58 | self.command_list_receiving = False 59 | self.command_list_ok = False 60 | self.command_list = [] 61 | self.command_list_index = None 62 | 63 | self.subsystem_events = set() 64 | self.subsystem_subscriptions = set() 65 | 66 | self.context = context.MpdContext( 67 | config=config, 68 | core=core, 69 | uri_map=uri_map, 70 | session=session, 71 | dispatcher=self, 72 | ) 73 | 74 | def handle_request( 75 | self, 76 | request: Request, 77 | current_command_list_index: int | None = None, 78 | ) -> Response: 79 | """Dispatch incoming requests to the correct handler.""" 80 | self.command_list_index = current_command_list_index 81 | response: Response = Response([]) 82 | filter_chain: list[Filter] = [ 83 | self._catch_mpd_ack_errors_filter, 84 | self._authenticate_filter, 85 | self._command_list_filter, 86 | self._idle_filter, 87 | self._add_ok_filter, 88 | self._call_handler_filter, 89 | ] 90 | return self._call_next_filter(request, response, filter_chain) 91 | 92 | def handle_idle(self, subsystem: str) -> None: 93 | # TODO: validate against mopidy_mpd.protocol.status.SUBSYSTEMS 94 | self.subsystem_events.add(subsystem) 95 | 96 | subsystems = self.subsystem_subscriptions.intersection(self.subsystem_events) 97 | if not subsystems: 98 | return 99 | 100 | response = [*[f"changed: {s}" for s in subsystems], "OK"] 101 | self.subsystem_events = set() 102 | self.subsystem_subscriptions = set() 103 | self.session.send_lines(response) 104 | 105 | def _call_next_filter( 106 | self, request: Request, response: Response, filter_chain: list[Filter] 107 | ) -> Response: 108 | if filter_chain: 109 | next_filter = filter_chain.pop(0) 110 | return next_filter(request, response, filter_chain) 111 | return response 112 | 113 | # --- Filter: catch MPD ACK errors 114 | 115 | def _catch_mpd_ack_errors_filter( 116 | self, 117 | request: Request, 118 | response: Response, 119 | filter_chain: list[Filter], 120 | ) -> Response: 121 | try: 122 | return self._call_next_filter(request, response, filter_chain) 123 | except exceptions.MpdAckError as mpd_ack_error: 124 | if self.command_list_index is not None: 125 | mpd_ack_error.index = self.command_list_index 126 | return Response([mpd_ack_error.get_mpd_ack()]) 127 | 128 | # --- Filter: authenticate 129 | 130 | def _authenticate_filter( 131 | self, 132 | request: Request, 133 | response: Response, 134 | filter_chain: list[Filter], 135 | ) -> Response: 136 | if self.authenticated: 137 | return self._call_next_filter(request, response, filter_chain) 138 | 139 | if self.config["mpd"]["password"] is None: 140 | self.authenticated = True 141 | return self._call_next_filter(request, response, filter_chain) 142 | 143 | command_name = request.split(" ")[0] 144 | command = protocol.commands.handlers.get(command_name) 145 | 146 | if command and not command.auth_required: 147 | return self._call_next_filter(request, response, filter_chain) 148 | 149 | raise exceptions.MpdPermissionError(command=command_name) 150 | 151 | # --- Filter: command list 152 | 153 | def _command_list_filter( 154 | self, 155 | request: Request, 156 | response: Response, 157 | filter_chain: list[Filter], 158 | ) -> Response: 159 | if self._is_receiving_command_list(request): 160 | self.command_list.append(request) 161 | return Response([]) 162 | 163 | response = self._call_next_filter(request, response, filter_chain) 164 | if ( 165 | ( 166 | self._is_receiving_command_list(request) 167 | or self._is_processing_command_list(request) 168 | ) 169 | and response 170 | and response[-1] == "OK" 171 | ): 172 | response = Response(response[:-1]) 173 | return response 174 | 175 | def _is_receiving_command_list(self, request: Request) -> bool: 176 | return self.command_list_receiving and request != "command_list_end" 177 | 178 | def _is_processing_command_list(self, request: Request) -> bool: 179 | return self.command_list_index is not None and request != "command_list_end" 180 | 181 | # --- Filter: idle 182 | 183 | def _idle_filter( 184 | self, 185 | request: Request, 186 | response: Response, 187 | filter_chain: list[Filter], 188 | ) -> Response: 189 | if self._is_currently_idle() and request != "noidle": 190 | logger.debug( 191 | "Client sent us %s, only %s is allowed while in the idle state", 192 | repr(request), 193 | repr("noidle"), 194 | ) 195 | self.session.close() 196 | return Response([]) 197 | 198 | if not self._is_currently_idle() and request == "noidle": 199 | return Response([]) # noidle was called before idle 200 | 201 | response = self._call_next_filter(request, response, filter_chain) 202 | 203 | if self._is_currently_idle(): 204 | return Response([]) 205 | 206 | return response 207 | 208 | def _is_currently_idle(self) -> bool: 209 | return bool(self.subsystem_subscriptions) 210 | 211 | # --- Filter: add OK 212 | 213 | def _add_ok_filter( 214 | self, 215 | request: Request, 216 | response: Response, 217 | filter_chain: list[Filter], 218 | ) -> Response: 219 | response = self._call_next_filter(request, response, filter_chain) 220 | if not self._has_error(response): 221 | response.append("OK") 222 | return response 223 | 224 | def _has_error(self, response: Response) -> bool: 225 | return bool(response) and response[-1].startswith("ACK") 226 | 227 | # --- Filter: call handler 228 | 229 | def _call_handler_filter( 230 | self, 231 | request: Request, 232 | response: Response, 233 | filter_chain: list[Filter], 234 | ) -> Response: 235 | try: 236 | result = self._call_handler(request) 237 | response = _format_response(result) 238 | return self._call_next_filter(request, response, filter_chain) 239 | except pykka.ActorDeadError as exc: 240 | logger.warning("Tried to communicate with dead actor.") 241 | raise exceptions.MpdSystemError(str(exc)) from exc 242 | 243 | def _call_handler(self, request: Request) -> protocol.Result: 244 | tokens = tokenize.split(request) 245 | # TODO: check that blacklist items are valid commands? 246 | blacklist = self.config["mpd"]["command_blacklist"] 247 | if tokens and tokens[0] in blacklist: 248 | logger.warning("MPD client used blacklisted command: %s", tokens[0]) 249 | raise exceptions.MpdDisabledError(command=tokens[0]) 250 | try: 251 | return protocol.commands.call( 252 | context=self.context, 253 | tokens=tokens, 254 | ) 255 | except exceptions.MpdAckError as exc: 256 | if exc.command is None: 257 | exc.command = tokens[0] 258 | raise 259 | 260 | 261 | def _format_response(result: protocol.Result) -> Response: 262 | response = Response([]) 263 | for element in _listify_result(result): 264 | response.extend(_format_lines(element)) 265 | return response 266 | 267 | 268 | def _listify_result(result: protocol.Result) -> protocol.ResultList: 269 | match result: 270 | case None: 271 | return [] 272 | case list(): 273 | return _flatten(result) 274 | case _: 275 | return [result] 276 | 277 | 278 | def _flatten(lst: protocol.ResultList) -> protocol.ResultList: 279 | result: protocol.ResultList = [] 280 | for element in lst: 281 | if isinstance(element, list): 282 | result.extend(_flatten(element)) 283 | else: 284 | result.append(element) 285 | return result 286 | 287 | 288 | def _format_lines( 289 | element: protocol.ResultDict | protocol.ResultTuple | str, 290 | ) -> Response: 291 | if isinstance(element, dict): 292 | return Response([f"{key}: {value}" for (key, value) in element.items()]) 293 | if isinstance(element, tuple): 294 | (key, value) = element 295 | return Response([f"{key}: {value}"]) 296 | return Response([element]) 297 | -------------------------------------------------------------------------------- /src/mopidy_mpd/exceptions.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from mopidy.exceptions import MopidyException 4 | from mopidy.types import UriScheme 5 | 6 | 7 | class MpdAckError(MopidyException): 8 | """See fields on this class for available MPD error codes""" 9 | 10 | ACK_ERROR_NOT_LIST = 1 11 | ACK_ERROR_ARG = 2 12 | ACK_ERROR_PASSWORD = 3 13 | ACK_ERROR_PERMISSION = 4 14 | ACK_ERROR_UNKNOWN = 5 15 | ACK_ERROR_NO_EXIST = 50 16 | ACK_ERROR_PLAYLIST_MAX = 51 17 | ACK_ERROR_SYSTEM = 52 18 | ACK_ERROR_PLAYLIST_LOAD = 53 19 | ACK_ERROR_UPDATE_ALREADY = 54 20 | ACK_ERROR_PLAYER_SYNC = 55 21 | ACK_ERROR_EXIST = 56 22 | 23 | error_code = 0 24 | 25 | def __init__( 26 | self, 27 | message: str = "", 28 | index: int = 0, 29 | command: str | None = None, 30 | ) -> None: 31 | super().__init__(message, index, command) 32 | self.message = message 33 | self.index = index 34 | self.command = command 35 | 36 | def get_mpd_ack(self) -> str: 37 | """ 38 | MPD error code format:: 39 | 40 | ACK [%(error_code)i@%(index)i] {%(command)s} description 41 | """ 42 | return ( 43 | f"ACK [{self.__class__.error_code:d}@{self.index:d}] " 44 | f"{{{self.command}}} {self.message}" 45 | ) 46 | 47 | 48 | class MpdArgError(MpdAckError): 49 | error_code = MpdAckError.ACK_ERROR_ARG 50 | 51 | 52 | class MpdPasswordError(MpdAckError): 53 | error_code = MpdAckError.ACK_ERROR_PASSWORD 54 | 55 | 56 | class MpdPermissionError(MpdAckError): 57 | error_code = MpdAckError.ACK_ERROR_PERMISSION 58 | 59 | def __init__(self, *args: Any, **kwargs: Any) -> None: 60 | super().__init__(*args, **kwargs) 61 | assert self.command is not None, "command must be given explicitly" 62 | self.message = f'you don\'t have permission for "{self.command}"' 63 | 64 | 65 | class MpdUnknownError(MpdAckError): 66 | error_code = MpdAckError.ACK_ERROR_UNKNOWN 67 | 68 | 69 | class MpdUnknownCommandError(MpdUnknownError): 70 | def __init__(self, *args: Any, **kwargs: Any) -> None: 71 | super().__init__(*args, **kwargs) 72 | assert self.command is not None, "command must be given explicitly" 73 | self.message = f'unknown command "{self.command}"' 74 | self.command = "" 75 | 76 | 77 | class MpdNoCommandError(MpdUnknownCommandError): 78 | def __init__(self, *args: Any, **kwargs: Any) -> None: 79 | kwargs["command"] = "" 80 | super().__init__(*args, **kwargs) 81 | self.message = "No command given" 82 | 83 | 84 | class MpdNoExistError(MpdAckError): 85 | error_code = MpdAckError.ACK_ERROR_NO_EXIST 86 | 87 | 88 | class MpdExistError(MpdAckError): 89 | error_code = MpdAckError.ACK_ERROR_EXIST 90 | 91 | 92 | class MpdSystemError(MpdAckError): 93 | error_code = MpdAckError.ACK_ERROR_SYSTEM 94 | 95 | 96 | class MpdInvalidPlaylistNameError(MpdAckError): 97 | error_code = MpdAckError.ACK_ERROR_ARG 98 | 99 | def __init__(self, *args: Any, **kwargs: Any) -> None: 100 | super().__init__(*args, **kwargs) 101 | self.message = ( 102 | "playlist name is invalid: playlist names may not " 103 | "contain slashes, newlines or carriage returns" 104 | ) 105 | 106 | 107 | class MpdNotImplementedError(MpdAckError): 108 | error_code = 0 109 | 110 | def __init__(self, *args: Any, **kwargs: Any) -> None: 111 | super().__init__(*args, **kwargs) 112 | self.message = "Not implemented" 113 | 114 | 115 | class MpdInvalidTrackForPlaylistError(MpdAckError): 116 | # NOTE: This is a custom error for Mopidy that does not exist in MPD. 117 | error_code = 0 118 | 119 | def __init__( 120 | self, 121 | playlist_scheme: UriScheme, 122 | track_scheme: UriScheme, 123 | *args: Any, 124 | **kwargs: Any, 125 | ) -> None: 126 | super().__init__(*args, **kwargs) 127 | self.message = ( 128 | f'Playlist with scheme "{playlist_scheme}" ' 129 | f'can\'t store track scheme "{track_scheme}"' 130 | ) 131 | 132 | 133 | class MpdFailedToSavePlaylistError(MpdAckError): 134 | # NOTE: This is a custom error for Mopidy that does not exist in MPD. 135 | error_code = 0 136 | 137 | def __init__( 138 | self, 139 | backend_scheme: UriScheme, 140 | *args: Any, 141 | **kwargs: Any, 142 | ) -> None: 143 | super().__init__(*args, **kwargs) 144 | self.message = f'Backend with scheme "{backend_scheme}" failed to save playlist' 145 | 146 | 147 | class MpdDisabledError(MpdAckError): 148 | # NOTE: This is a custom error for Mopidy that does not exist in MPD. 149 | error_code = 0 150 | 151 | def __init__(self, *args: Any, **kwargs: Any) -> None: 152 | super().__init__(*args, **kwargs) 153 | assert self.command is not None, "command must be given explicitly" 154 | self.message = f'"{self.command}" has been disabled in the server' 155 | -------------------------------------------------------------------------------- /src/mopidy_mpd/ext.conf: -------------------------------------------------------------------------------- 1 | [mpd] 2 | enabled = true 3 | hostname = 127.0.0.1 4 | port = 6600 5 | password = 6 | max_connections = 20 7 | connection_timeout = 60 8 | zeroconf = Mopidy MPD server on $hostname 9 | command_blacklist = listall,listallinfo 10 | default_playlist_scheme = m3u 11 | -------------------------------------------------------------------------------- /src/mopidy_mpd/formatting.py: -------------------------------------------------------------------------------- 1 | def indent( 2 | value: str, 3 | *, 4 | places: int = 4, 5 | linebreak: str = "\n", 6 | singles: bool = False, 7 | ) -> str: 8 | lines = value.split(linebreak) 9 | if not singles and len(lines) == 1: 10 | return value 11 | for i, line in enumerate(lines): 12 | lines[i] = " " * places + line 13 | result = linebreak.join(lines) 14 | if not singles: 15 | result = linebreak + result 16 | return result 17 | -------------------------------------------------------------------------------- /src/mopidy_mpd/protocol/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | This is Mopidy's MPD protocol implementation. 3 | 4 | This is partly based upon the `MPD protocol documentation 5 | `_, which is a useful resource, but it is 6 | rather incomplete with regards to data formats, both for requests and 7 | responses. Thus, we have had to talk a great deal with the the original `MPD 8 | server `_ using telnet to get the details we need to 9 | implement our own MPD server which is compatible with the numerous existing 10 | `MPD clients `_. 11 | """ 12 | 13 | from __future__ import annotations 14 | 15 | import inspect 16 | from collections.abc import Callable 17 | from typing import TYPE_CHECKING, Any, TypeAlias 18 | 19 | from mopidy_mpd import exceptions 20 | 21 | if TYPE_CHECKING: 22 | from mopidy_mpd.context import MpdContext 23 | 24 | #: The MPD protocol uses UTF-8 for encoding all data. 25 | ENCODING = "utf-8" 26 | 27 | #: The MPD protocol uses ``\n`` as line terminator. 28 | LINE_TERMINATOR = b"\n" 29 | 30 | #: The MPD protocol version is 0.19.0. 31 | VERSION = "0.19.0" 32 | 33 | 34 | ResultValue: TypeAlias = str | int 35 | ResultDict: TypeAlias = dict[str, ResultValue] 36 | ResultTuple: TypeAlias = tuple[str, ResultValue] 37 | ResultList: TypeAlias = list[ResultTuple | ResultDict] 38 | Result: TypeAlias = None | ResultDict | ResultTuple | ResultList 39 | HandlerFunc: TypeAlias = Callable[..., Result] 40 | 41 | 42 | def load_protocol_modules() -> None: 43 | """ 44 | The protocol modules must be imported to get them registered in 45 | :attr:`commands`. 46 | """ 47 | from . import ( # noqa: F401 48 | audio_output, 49 | channels, 50 | command_list, 51 | connection, 52 | current_playlist, 53 | mount, 54 | music_db, 55 | playback, 56 | reflection, 57 | status, 58 | stickers, 59 | stored_playlists, 60 | ) 61 | 62 | 63 | def INT(value: str) -> int: # noqa: N802 64 | r"""Converts a value that matches [+-]?\d+ into an integer.""" 65 | if value is None: 66 | raise ValueError("None is not a valid integer") 67 | # TODO: check for whitespace via value != value.strip()? 68 | return int(value) 69 | 70 | 71 | def UINT(value: str) -> int: # noqa: N802 72 | r"""Converts a value that matches \d+ into an integer.""" 73 | if value is None: 74 | raise ValueError("None is not a valid integer") 75 | if not value.isdigit(): 76 | raise ValueError("Only positive numbers are allowed") 77 | return int(value) 78 | 79 | 80 | def FLOAT(value: str) -> float: # noqa: N802 81 | r"""Converts a value that matches [+-]\d+(.\d+)? into a float.""" 82 | if value is None: 83 | raise ValueError("None is not a valid float") 84 | return float(value) 85 | 86 | 87 | def UFLOAT(value: str) -> float: # noqa: N802 88 | r"""Converts a value that matches \d+(.\d+)? into a float.""" 89 | if value is None: 90 | raise ValueError("None is not a valid float") 91 | result = float(value) 92 | if result < 0: 93 | raise ValueError("Only positive numbers are allowed") 94 | return result 95 | 96 | 97 | def BOOL(value: str) -> bool: # noqa: N802 98 | """Convert the values 0 and 1 into booleans.""" 99 | if value in ("1", "0"): 100 | return bool(int(value)) 101 | raise ValueError(f"{value!r} is not 0 or 1") 102 | 103 | 104 | def RANGE(value: str) -> slice: # noqa: N802 105 | """Convert a single integer or range spec into a slice 106 | 107 | ``n`` should become ``slice(n, n+1)`` 108 | ``n:`` should become ``slice(n, None)`` 109 | ``n:m`` should become ``slice(n, m)`` and ``m > n`` must hold 110 | """ 111 | if ":" in value: 112 | start, stop = value.split(":", 1) 113 | start = UINT(start) 114 | if stop.strip(): 115 | stop = UINT(stop) 116 | if start >= stop: 117 | raise ValueError("End must be larger than start") 118 | else: 119 | stop = None 120 | else: 121 | start = UINT(value) 122 | stop = start + 1 123 | return slice(start, stop) 124 | 125 | 126 | class Commands: 127 | """Collection of MPD commands to expose to users. 128 | 129 | Normally used through the global instance which command handlers have been 130 | installed into. 131 | """ 132 | 133 | def __init__(self) -> None: 134 | self.handlers: dict[str, Handler] = {} 135 | 136 | # TODO: consider removing auth_required and list_command in favour of 137 | # additional command instances to register in? 138 | def add( 139 | self, 140 | name: str, 141 | *, 142 | auth_required: bool = True, 143 | list_command: bool = True, 144 | **validators: Callable[[str], Any], 145 | ) -> Callable[[HandlerFunc], HandlerFunc]: 146 | """Create a decorator that registers a handler and validation rules. 147 | 148 | Additional keyword arguments are treated as converters/validators to 149 | apply to tokens converting them to proper Python types. 150 | 151 | Requirements for valid handlers: 152 | 153 | - must accept a context argument as the first arg. 154 | - may not use variable keyword arguments, ``**kwargs``. 155 | - may use variable arguments ``*args`` *or* a mix of required and 156 | optional arguments. 157 | 158 | Decorator returns the unwrapped function so that tests etc can use the 159 | functions with values with correct python types instead of strings. 160 | 161 | :param name: Name of the command being registered. 162 | :param auth_required: If authorization is required. 163 | :param list_command: If command should be listed in reflection. 164 | """ 165 | 166 | def wrapper(func: HandlerFunc) -> HandlerFunc: 167 | if name in self.handlers: 168 | raise ValueError(f"{name} already registered") 169 | self.handlers[name] = Handler( 170 | name=name, 171 | func=func, 172 | auth_required=auth_required, 173 | list_command=list_command, 174 | validators=validators, 175 | ) 176 | return func 177 | 178 | return wrapper 179 | 180 | def call( 181 | self, 182 | *, 183 | context: MpdContext, 184 | tokens: list[str], 185 | ) -> Result: 186 | """Find and run the handler registered for the given command. 187 | 188 | If the handler was registered with any converters/validators they will 189 | be run before calling the real handler. 190 | 191 | :param context: MPD context 192 | :param tokens: List of tokens to process 193 | """ 194 | if not tokens: 195 | raise exceptions.MpdNoCommandError 196 | command, tokens = tokens[0], tokens[1:] 197 | if command not in self.handlers: 198 | raise exceptions.MpdUnknownCommandError(command=command) 199 | return self.handlers[command](context, *tokens) 200 | 201 | 202 | #: Global instance to install commands into 203 | commands = Commands() 204 | 205 | 206 | class Handler: 207 | def __init__( 208 | self, 209 | *, 210 | name: str, 211 | func: HandlerFunc, 212 | auth_required: bool, 213 | list_command: bool, 214 | validators: dict[str, Callable[[str], Any]], 215 | ) -> None: 216 | self.name = name 217 | self.func = func 218 | self.auth_required = auth_required 219 | self.list_command = list_command 220 | self.validators = validators 221 | 222 | self.spec = inspect.getfullargspec(func) 223 | 224 | if not self.spec.args and not self.spec.varargs: 225 | raise TypeError("Handler must accept at least one argument.") 226 | 227 | if len(self.spec.args) > 1 and self.spec.varargs: 228 | raise TypeError("*args may not be combined with regular arguments") 229 | 230 | if not set(self.validators.keys()).issubset(self.spec.args): 231 | raise TypeError("Validator for non-existent arg passed") 232 | 233 | if self.spec.varkw or self.spec.kwonlyargs: 234 | raise TypeError("Keyword arguments are not permitted") 235 | 236 | self.defaults = dict( 237 | zip( 238 | self.spec.args[-len(self.spec.defaults or []) :], 239 | self.spec.defaults or [], 240 | strict=False, 241 | ) 242 | ) 243 | 244 | def __call__(self, *args: Any, **kwargs: Any) -> Result: 245 | if self.spec.varargs: 246 | return self.func(*args, **kwargs) 247 | 248 | try: 249 | ba = inspect.signature(self.func).bind(*args, **kwargs) 250 | ba.apply_defaults() 251 | callargs = ba.arguments 252 | except TypeError as exc: 253 | raise exceptions.MpdArgError( 254 | f'wrong number of arguments for "{self.name}"' 255 | ) from exc 256 | 257 | for key, value in callargs.items(): 258 | if value == self.defaults.get(key, object()): 259 | continue 260 | if validator := self.validators.get(key): 261 | try: 262 | callargs[key] = validator(value) 263 | except ValueError as exc: 264 | raise exceptions.MpdArgError("incorrect arguments") from exc 265 | 266 | return self.func(**callargs) 267 | -------------------------------------------------------------------------------- /src/mopidy_mpd/protocol/audio_output.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from mopidy_mpd import exceptions, protocol 6 | 7 | if TYPE_CHECKING: 8 | from mopidy_mpd.context import MpdContext 9 | 10 | 11 | @protocol.commands.add("disableoutput", outputid=protocol.UINT) 12 | def disableoutput(context: MpdContext, outputid: int) -> None: 13 | """ 14 | *musicpd.org, audio output section:* 15 | 16 | ``disableoutput {ID}`` 17 | 18 | Turns an output off. 19 | """ 20 | if outputid == 0: 21 | success = context.core.mixer.set_mute(False).get() 22 | if not success: 23 | raise exceptions.MpdSystemError("problems disabling output") 24 | else: 25 | raise exceptions.MpdNoExistError("No such audio output") 26 | 27 | 28 | @protocol.commands.add("enableoutput", outputid=protocol.UINT) 29 | def enableoutput(context: MpdContext, outputid: int) -> None: 30 | """ 31 | *musicpd.org, audio output section:* 32 | 33 | ``enableoutput {ID}`` 34 | 35 | Turns an output on. 36 | """ 37 | if outputid == 0: 38 | success = context.core.mixer.set_mute(True).get() 39 | if not success: 40 | raise exceptions.MpdSystemError("problems enabling output") 41 | else: 42 | raise exceptions.MpdNoExistError("No such audio output") 43 | 44 | 45 | @protocol.commands.add("toggleoutput", outputid=protocol.UINT) 46 | def toggleoutput(context: MpdContext, outputid: int) -> None: 47 | """ 48 | *musicpd.org, audio output section:* 49 | 50 | ``toggleoutput {ID}`` 51 | 52 | Turns an output on or off, depending on the current state. 53 | """ 54 | if outputid == 0: 55 | mute_status = context.core.mixer.get_mute().get() 56 | success = context.core.mixer.set_mute(not mute_status) 57 | if not success: 58 | raise exceptions.MpdSystemError("problems toggling output") 59 | else: 60 | raise exceptions.MpdNoExistError("No such audio output") 61 | 62 | 63 | @protocol.commands.add("outputs") 64 | def outputs(context: MpdContext) -> protocol.Result: 65 | """ 66 | *musicpd.org, audio output section:* 67 | 68 | ``outputs`` 69 | 70 | Shows information about all outputs. 71 | """ 72 | muted = 1 if context.core.mixer.get_mute().get() else 0 73 | return [ 74 | ("outputid", 0), 75 | ("outputname", "Mute"), 76 | ("outputenabled", muted), 77 | ] 78 | -------------------------------------------------------------------------------- /src/mopidy_mpd/protocol/channels.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Never 4 | 5 | from mopidy_mpd import exceptions, protocol 6 | 7 | if TYPE_CHECKING: 8 | from mopidy_mpd.context import MpdContext 9 | 10 | 11 | @protocol.commands.add("subscribe") 12 | def subscribe(context: MpdContext, channel: str) -> Never: 13 | """ 14 | *musicpd.org, client to client section:* 15 | 16 | ``subscribe {NAME}`` 17 | 18 | Subscribe to a channel. The channel is created if it does not exist 19 | already. The name may consist of alphanumeric ASCII characters plus 20 | underscore, dash, dot and colon. 21 | """ 22 | # TODO: match channel against [A-Za-z0-9:._-]+ 23 | raise exceptions.MpdNotImplementedError # TODO 24 | 25 | 26 | @protocol.commands.add("unsubscribe") 27 | def unsubscribe(context: MpdContext, channel: str) -> Never: 28 | """ 29 | *musicpd.org, client to client section:* 30 | 31 | ``unsubscribe {NAME}`` 32 | 33 | Unsubscribe from a channel. 34 | """ 35 | # TODO: match channel against [A-Za-z0-9:._-]+ 36 | raise exceptions.MpdNotImplementedError # TODO 37 | 38 | 39 | @protocol.commands.add("channels") 40 | def channels(context: MpdContext) -> Never: 41 | """ 42 | *musicpd.org, client to client section:* 43 | 44 | ``channels`` 45 | 46 | Obtain a list of all channels. The response is a list of "channel:" 47 | lines. 48 | """ 49 | raise exceptions.MpdNotImplementedError # TODO 50 | 51 | 52 | @protocol.commands.add("readmessages") 53 | def readmessages(context: MpdContext) -> Never: 54 | """ 55 | *musicpd.org, client to client section:* 56 | 57 | ``readmessages`` 58 | 59 | Reads messages for this client. The response is a list of "channel:" 60 | and "message:" lines. 61 | """ 62 | raise exceptions.MpdNotImplementedError # TODO 63 | 64 | 65 | @protocol.commands.add("sendmessage") 66 | def sendmessage(context: MpdContext, channel: str, text: str) -> Never: 67 | """ 68 | *musicpd.org, client to client section:* 69 | 70 | ``sendmessage {CHANNEL} {TEXT}`` 71 | 72 | Send a message to the specified channel. 73 | """ 74 | # TODO: match channel against [A-Za-z0-9:._-]+ 75 | raise exceptions.MpdNotImplementedError # TODO 76 | -------------------------------------------------------------------------------- /src/mopidy_mpd/protocol/command_list.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from mopidy_mpd import exceptions, protocol 6 | 7 | if TYPE_CHECKING: 8 | from mopidy_mpd.context import MpdContext 9 | 10 | 11 | @protocol.commands.add("command_list_begin", list_command=False) 12 | def command_list_begin(context: MpdContext) -> None: 13 | """ 14 | *musicpd.org, command list section:* 15 | 16 | To facilitate faster adding of files etc. you can pass a list of 17 | commands all at once using a command list. The command list begins 18 | with ``command_list_begin`` or ``command_list_ok_begin`` and ends 19 | with ``command_list_end``. 20 | 21 | It does not execute any commands until the list has ended. The 22 | return value is whatever the return for a list of commands is. On 23 | success for all commands, ``OK`` is returned. If a command fails, 24 | no more commands are executed and the appropriate ``ACK`` error is 25 | returned. If ``command_list_ok_begin`` is used, ``list_OK`` is 26 | returned for each successful command executed in the command list. 27 | """ 28 | context.dispatcher.command_list_receiving = True 29 | context.dispatcher.command_list_ok = False 30 | context.dispatcher.command_list = [] 31 | 32 | 33 | @protocol.commands.add("command_list_end", list_command=False) 34 | def command_list_end(context: MpdContext) -> protocol.Result: 35 | """See :meth:`command_list_begin()`.""" 36 | # TODO: batch consecutive add commands 37 | if not context.dispatcher.command_list_receiving: 38 | raise exceptions.MpdUnknownCommandError(command="command_list_end") 39 | context.dispatcher.command_list_receiving = False 40 | (command_list, context.dispatcher.command_list) = ( 41 | context.dispatcher.command_list, 42 | [], 43 | ) 44 | (command_list_ok, context.dispatcher.command_list_ok) = ( 45 | context.dispatcher.command_list_ok, 46 | False, 47 | ) 48 | command_list_response = [] 49 | for index, command in enumerate(command_list): 50 | response = context.dispatcher.handle_request( 51 | command, current_command_list_index=index 52 | ) 53 | command_list_response.extend(response) 54 | if command_list_response and command_list_response[-1].startswith("ACK"): 55 | return command_list_response 56 | if command_list_ok: 57 | command_list_response.append("list_OK") 58 | return command_list_response 59 | 60 | 61 | @protocol.commands.add("command_list_ok_begin", list_command=False) 62 | def command_list_ok_begin(context: MpdContext) -> None: 63 | """See :meth:`command_list_begin()`.""" 64 | context.dispatcher.command_list_receiving = True 65 | context.dispatcher.command_list_ok = True 66 | context.dispatcher.command_list = [] 67 | -------------------------------------------------------------------------------- /src/mopidy_mpd/protocol/connection.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Never 4 | 5 | from mopidy_mpd import exceptions, protocol 6 | from mopidy_mpd.protocol import tagtype_list 7 | 8 | if TYPE_CHECKING: 9 | from mopidy_mpd.context import MpdContext 10 | 11 | 12 | @protocol.commands.add("close", auth_required=False) 13 | def close(context: MpdContext) -> None: 14 | """ 15 | *musicpd.org, connection section:* 16 | 17 | ``close`` 18 | 19 | Closes the connection to MPD. 20 | """ 21 | context.session.close() 22 | 23 | 24 | @protocol.commands.add("kill", list_command=False) 25 | def kill(context: MpdContext) -> Never: 26 | """ 27 | *musicpd.org, connection section:* 28 | 29 | ``kill`` 30 | 31 | Kills MPD. 32 | """ 33 | raise exceptions.MpdPermissionError(command="kill") 34 | 35 | 36 | @protocol.commands.add("password", auth_required=False) 37 | def password(context: MpdContext, password: str) -> None: 38 | """ 39 | *musicpd.org, connection section:* 40 | 41 | ``password {PASSWORD}`` 42 | 43 | This is used for authentication with the server. ``PASSWORD`` is 44 | simply the plaintext password. 45 | """ 46 | if password == context.config["mpd"]["password"]: 47 | context.dispatcher.authenticated = True 48 | else: 49 | raise exceptions.MpdPasswordError("incorrect password") 50 | 51 | 52 | @protocol.commands.add("ping", auth_required=False) 53 | def ping(context: MpdContext) -> None: 54 | """ 55 | *musicpd.org, connection section:* 56 | 57 | ``ping`` 58 | 59 | Does nothing but return ``OK``. 60 | """ 61 | 62 | 63 | @protocol.commands.add("tagtypes") 64 | def tagtypes(context: MpdContext, *args: str) -> protocol.Result: 65 | """ 66 | *mpd.readthedocs.io, connection settings section:* 67 | 68 | ``tagtypes`` 69 | 70 | Shows a list of available song metadata. 71 | 72 | ``tagtypes disable {NAME...}`` 73 | 74 | Remove one or more tags from the list of tag types the client is interested in. 75 | 76 | ``tagtypes enable {NAME...}`` 77 | 78 | Re-enable one or more tags from the list of tag types for this client. 79 | 80 | ``tagtypes clear`` 81 | 82 | Clear the list of tag types this client is interested in. 83 | 84 | ``tagtypes all`` 85 | 86 | Announce that this client is interested in all tag types. 87 | """ 88 | parameters = list(args) 89 | if parameters: 90 | subcommand = parameters.pop(0).lower() 91 | match subcommand: 92 | case "all": 93 | context.session.tagtypes.update(tagtype_list.TAGTYPE_LIST) 94 | case "clear": 95 | context.session.tagtypes.clear() 96 | case "disable": 97 | _validate_tagtypes(parameters) 98 | context.session.tagtypes.difference_update(parameters) 99 | case "enable": 100 | _validate_tagtypes(parameters) 101 | context.session.tagtypes.update(parameters) 102 | case _: 103 | raise exceptions.MpdArgError("Unknown sub command") 104 | return None 105 | return [("tagtype", tagtype) for tagtype in context.session.tagtypes] 106 | 107 | 108 | def _validate_tagtypes(parameters: list[str]) -> None: 109 | param_set = set(parameters) 110 | if not param_set: 111 | raise exceptions.MpdArgError("Not enough arguments") 112 | if not param_set.issubset(tagtype_list.TAGTYPE_LIST): 113 | raise exceptions.MpdArgError("Unknown tag type") 114 | -------------------------------------------------------------------------------- /src/mopidy_mpd/protocol/mount.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Never 4 | 5 | from mopidy_mpd import exceptions, protocol 6 | 7 | if TYPE_CHECKING: 8 | from mopidy.types import Uri 9 | 10 | from mopidy_mpd.context import MpdContext 11 | 12 | 13 | @protocol.commands.add("mount") 14 | def mount(context: MpdContext, path: str, uri: Uri) -> Never: 15 | """ 16 | *musicpd.org, mounts and neighbors section:* 17 | 18 | ``mount {PATH} {URI}`` 19 | 20 | Mount the specified remote storage URI at the given path. Example:: 21 | 22 | mount foo nfs://192.168.1.4/export/mp3 23 | 24 | .. versionadded:: 0.19 25 | New in MPD protocol version 0.19 26 | """ 27 | raise exceptions.MpdNotImplementedError # TODO 28 | 29 | 30 | @protocol.commands.add("unmount") 31 | def unmount(context: MpdContext, path: str) -> Never: 32 | """ 33 | *musicpd.org, mounts and neighbors section:* 34 | 35 | ``unmount {PATH}`` 36 | 37 | Unmounts the specified path. Example:: 38 | 39 | unmount foo 40 | 41 | .. versionadded:: 0.19 42 | New in MPD protocol version 0.19 43 | """ 44 | raise exceptions.MpdNotImplementedError # TODO 45 | 46 | 47 | @protocol.commands.add("listmounts") 48 | def listmounts(context: MpdContext) -> Never: 49 | """ 50 | *musicpd.org, mounts and neighbors section:* 51 | 52 | ``listmounts`` 53 | 54 | Queries a list of all mounts. By default, this contains just the 55 | configured music_directory. Example:: 56 | 57 | listmounts 58 | mount: 59 | storage: /home/foo/music 60 | mount: foo 61 | storage: nfs://192.168.1.4/export/mp3 62 | OK 63 | 64 | .. versionadded:: 0.19 65 | New in MPD protocol version 0.19 66 | """ 67 | raise exceptions.MpdNotImplementedError # TODO 68 | 69 | 70 | @protocol.commands.add("listneighbors") 71 | def listneighbors(context: MpdContext) -> Never: 72 | """ 73 | *musicpd.org, mounts and neighbors section:* 74 | 75 | ``listneighbors`` 76 | 77 | Queries a list of "neighbors" (e.g. accessible file servers on the 78 | local net). Items on that list may be used with the mount command. 79 | Example:: 80 | 81 | listneighbors 82 | neighbor: smb://FOO 83 | name: FOO (Samba 4.1.11-Debian) 84 | OK 85 | 86 | .. versionadded:: 0.19 87 | New in MPD protocol version 0.19 88 | """ 89 | raise exceptions.MpdNotImplementedError # TODO 90 | -------------------------------------------------------------------------------- /src/mopidy_mpd/protocol/reflection.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Never 4 | 5 | from mopidy_mpd import exceptions, protocol 6 | 7 | if TYPE_CHECKING: 8 | from mopidy_mpd.context import MpdContext 9 | 10 | 11 | @protocol.commands.add("config", list_command=False) 12 | def config(context: MpdContext) -> Never: 13 | """ 14 | *musicpd.org, reflection section:* 15 | 16 | ``config`` 17 | 18 | Dumps configuration values that may be interesting for the client. This 19 | command is only permitted to "local" clients (connected via UNIX domain 20 | socket). 21 | """ 22 | raise exceptions.MpdPermissionError(command="config") 23 | 24 | 25 | @protocol.commands.add("commands", auth_required=False) 26 | def commands(context: MpdContext) -> protocol.Result: 27 | """ 28 | *musicpd.org, reflection section:* 29 | 30 | ``commands`` 31 | 32 | Shows which commands the current user has access to. 33 | """ 34 | command_names = set() 35 | for name, handler in protocol.commands.handlers.items(): 36 | if not handler.list_command: 37 | continue 38 | if context.dispatcher.authenticated or not handler.auth_required: 39 | command_names.add(name) 40 | 41 | return [("command", command_name) for command_name in sorted(command_names)] 42 | 43 | 44 | @protocol.commands.add("decoders") 45 | def decoders(context: MpdContext) -> None: 46 | """ 47 | *musicpd.org, reflection section:* 48 | 49 | ``decoders`` 50 | 51 | Print a list of decoder plugins, followed by their supported 52 | suffixes and MIME types. Example response:: 53 | 54 | plugin: mad 55 | suffix: mp3 56 | suffix: mp2 57 | mime_type: audio/mpeg 58 | plugin: mpcdec 59 | suffix: mpc 60 | 61 | *Clarifications:* 62 | 63 | - ncmpcpp asks for decoders the first time you open the browse view. By 64 | returning nothing and OK instead of an not implemented error, we avoid 65 | "Not implemented" showing up in the ncmpcpp interface, and we get the 66 | list of playlists without having to enter the browse interface twice. 67 | """ 68 | return # TODO 69 | 70 | 71 | @protocol.commands.add("notcommands", auth_required=False) 72 | def notcommands(context: MpdContext) -> protocol.Result: 73 | """ 74 | *musicpd.org, reflection section:* 75 | 76 | ``notcommands`` 77 | 78 | Shows which commands the current user does not have access to. 79 | """ 80 | command_names = {"config", "kill"} # No permission to use 81 | for name, handler in protocol.commands.handlers.items(): 82 | if not handler.list_command: 83 | continue 84 | if not context.dispatcher.authenticated and handler.auth_required: 85 | command_names.add(name) 86 | 87 | return [("command", command_name) for command_name in sorted(command_names)] 88 | 89 | 90 | @protocol.commands.add("urlhandlers") 91 | def urlhandlers(context: MpdContext) -> protocol.Result: 92 | """ 93 | *musicpd.org, reflection section:* 94 | 95 | ``urlhandlers`` 96 | 97 | Gets a list of available URL handlers. 98 | """ 99 | return [ 100 | ("handler", uri_scheme) for uri_scheme in context.core.get_uri_schemes().get() 101 | ] 102 | -------------------------------------------------------------------------------- /src/mopidy_mpd/protocol/status.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from mopidy.core import PlaybackState 6 | 7 | from mopidy_mpd import exceptions, protocol, translator 8 | 9 | if TYPE_CHECKING: 10 | from mopidy.models import Track 11 | from mopidy.types import DurationMs 12 | 13 | from mopidy_mpd.context import MpdContext 14 | 15 | 16 | #: Subsystems that can be registered with idle command. 17 | SUBSYSTEMS = [ 18 | "database", 19 | "mixer", 20 | "options", 21 | "output", 22 | "player", 23 | "playlist", 24 | "stored_playlist", 25 | "update", 26 | ] 27 | 28 | 29 | @protocol.commands.add("clearerror") 30 | def clearerror(context: MpdContext) -> protocol.Result: 31 | """ 32 | *musicpd.org, status section:* 33 | 34 | ``clearerror`` 35 | 36 | Clears the current error message in status (this is also 37 | accomplished by any command that starts playback). 38 | """ 39 | raise exceptions.MpdNotImplementedError # TODO 40 | 41 | 42 | @protocol.commands.add("currentsong") 43 | def currentsong(context: MpdContext) -> protocol.Result: 44 | """ 45 | *musicpd.org, status section:* 46 | 47 | ``currentsong`` 48 | 49 | Displays the song info of the current song (same song that is 50 | identified in status). 51 | """ 52 | tl_track = context.core.playback.get_current_tl_track().get() 53 | stream_title = context.core.playback.get_stream_title().get() 54 | if tl_track is not None: 55 | position = context.core.tracklist.index(tl_track).get() 56 | return translator.track_to_mpd_format( 57 | tl_track, 58 | position=position, 59 | stream_title=stream_title, 60 | tagtypes=context.session.tagtypes, 61 | ) 62 | return None 63 | 64 | 65 | @protocol.commands.add("idle") 66 | def idle(context: MpdContext, *args: str) -> protocol.Result: 67 | """ 68 | *musicpd.org, status section:* 69 | 70 | ``idle [SUBSYSTEMS...]`` 71 | 72 | Waits until there is a noteworthy change in one or more of MPD's 73 | subsystems. As soon as there is one, it lists all changed systems 74 | in a line in the format ``changed: SUBSYSTEM``, where ``SUBSYSTEM`` 75 | is one of the following: 76 | 77 | - ``database``: the song database has been modified after update. 78 | - ``update``: a database update has started or finished. If the 79 | database was modified during the update, the database event is 80 | also emitted. 81 | - ``stored_playlist``: a stored playlist has been modified, 82 | renamed, created or deleted 83 | - ``playlist``: the current playlist has been modified 84 | - ``player``: the player has been started, stopped or seeked 85 | - ``mixer``: the volume has been changed 86 | - ``output``: an audio output has been enabled or disabled 87 | - ``options``: options like repeat, random, crossfade, replay gain 88 | 89 | While a client is waiting for idle results, the server disables 90 | timeouts, allowing a client to wait for events as long as MPD runs. 91 | The idle command can be canceled by sending the command ``noidle`` 92 | (no other commands are allowed). MPD will then leave idle mode and 93 | print results immediately; might be empty at this time. 94 | 95 | If the optional ``SUBSYSTEMS`` argument is used, MPD will only send 96 | notifications when something changed in one of the specified 97 | subsystems. 98 | """ 99 | # TODO: test against valid subsystems 100 | 101 | subsystems = list(args) if args else SUBSYSTEMS 102 | 103 | for subsystem in subsystems: 104 | context.dispatcher.subsystem_subscriptions.add(subsystem) 105 | 106 | active = context.dispatcher.subsystem_subscriptions.intersection( 107 | context.dispatcher.subsystem_events 108 | ) 109 | if not active: 110 | context.session.prevent_timeout = True 111 | return None 112 | 113 | response: protocol.ResultList = [("changed", subsystem) for subsystem in active] 114 | context.dispatcher.subsystem_events = set() 115 | context.dispatcher.subsystem_subscriptions = set() 116 | 117 | return response 118 | 119 | 120 | @protocol.commands.add("noidle", list_command=False) 121 | def noidle(context: MpdContext) -> None: 122 | """See :meth:`_status_idle`.""" 123 | if not context.dispatcher.subsystem_subscriptions: 124 | return 125 | context.dispatcher.subsystem_subscriptions = set() 126 | context.dispatcher.subsystem_events = set() 127 | context.session.prevent_timeout = False 128 | 129 | 130 | @protocol.commands.add("stats") 131 | def stats(context: MpdContext) -> protocol.Result: 132 | """ 133 | *musicpd.org, status section:* 134 | 135 | ``stats`` 136 | 137 | Displays statistics. 138 | 139 | - ``artists``: number of artists 140 | - ``songs``: number of albums 141 | - ``uptime``: daemon uptime in seconds 142 | - ``db_playtime``: sum of all song times in the db 143 | - ``db_update``: last db update in UNIX time 144 | - ``playtime``: time length of music played 145 | """ 146 | return { 147 | "artists": 0, # TODO 148 | "albums": 0, # TODO 149 | "songs": 0, # TODO 150 | "uptime": 0, # TODO 151 | "db_playtime": 0, # TODO 152 | "db_update": 0, # TODO 153 | "playtime": 0, # TODO 154 | } 155 | 156 | 157 | @protocol.commands.add("status") 158 | def status(context: MpdContext) -> protocol.Result: 159 | """ 160 | *musicpd.org, status section:* 161 | 162 | ``status`` 163 | 164 | Reports the current status of the player and the volume level. 165 | 166 | - ``volume``: 0-100 or -1 167 | - ``repeat``: 0 or 1 168 | - ``single``: 0 or 1 169 | - ``consume``: 0 or 1 170 | - ``playlist``: 31-bit unsigned integer, the playlist version 171 | number 172 | - ``playlistlength``: integer, the length of the playlist 173 | - ``state``: play, stop, or pause 174 | - ``song``: playlist song number of the current song stopped on or 175 | playing 176 | - ``songid``: playlist songid of the current song stopped on or 177 | playing 178 | - ``nextsong``: playlist song number of the next song to be played 179 | - ``nextsongid``: playlist songid of the next song to be played 180 | - ``time``: total time elapsed (of current playing/paused song) 181 | - ``elapsed``: Total time elapsed within the current song, but with 182 | higher resolution. 183 | - ``bitrate``: instantaneous bitrate in kbps 184 | - ``xfade``: crossfade in seconds 185 | - ``audio``: sampleRate``:bits``:channels 186 | - ``updatings_db``: job id 187 | - ``error``: if there is an error, returns message here 188 | 189 | *Clarifications based on experience implementing* 190 | - ``volume``: can also be -1 if no output is set. 191 | - ``elapsed``: Higher resolution means time in seconds with three 192 | decimal places for millisecond precision. 193 | """ 194 | # Fire these off first, as other futures depends on them 195 | f_current_tl_track = context.core.playback.get_current_tl_track() 196 | f_next_tlid = context.core.tracklist.get_next_tlid() 197 | 198 | # ...and wait for them to complete 199 | current_tl_track = f_current_tl_track.get() 200 | current_tlid = current_tl_track.tlid if current_tl_track else None 201 | current_track = current_tl_track.track if current_tl_track else None 202 | next_tlid = f_next_tlid.get() 203 | 204 | # Then fire off the rest... 205 | f_current_index = context.core.tracklist.index(tlid=current_tlid) 206 | f_mixer_volume = context.core.mixer.get_volume() 207 | f_next_index = context.core.tracklist.index(tlid=next_tlid) 208 | f_playback_state = context.core.playback.get_state() 209 | f_playback_time_position = context.core.playback.get_time_position() 210 | f_tracklist_consume = context.core.tracklist.get_consume() 211 | f_tracklist_length = context.core.tracklist.get_length() 212 | f_tracklist_random = context.core.tracklist.get_random() 213 | f_tracklist_repeat = context.core.tracklist.get_repeat() 214 | f_tracklist_single = context.core.tracklist.get_single() 215 | f_tracklist_version = context.core.tracklist.get_version() 216 | 217 | # ...and wait for them to complete 218 | current_index = f_current_index.get() 219 | mixer_volume = f_mixer_volume.get() 220 | next_index = f_next_index.get() 221 | playback_state = f_playback_state.get() 222 | playback_time_position = f_playback_time_position.get() 223 | tracklist_consume = f_tracklist_consume.get() 224 | tracklist_length = f_tracklist_length.get() 225 | tracklist_random = f_tracklist_random.get() 226 | tracklist_repeat = f_tracklist_repeat.get() 227 | tracklist_single = f_tracklist_single.get() 228 | tracklist_version = f_tracklist_version.get() 229 | 230 | result = [ 231 | ("volume", mixer_volume if mixer_volume is not None else -1), 232 | ("repeat", int(tracklist_repeat)), 233 | ("random", int(tracklist_random)), 234 | ("single", int(tracklist_single)), 235 | ("consume", int(tracklist_consume)), 236 | ("playlist", tracklist_version), 237 | ("playlistlength", tracklist_length), 238 | ("xfade", 0), # Not supported 239 | ("state", _status_state(playback_state)), 240 | ] 241 | if current_tlid is not None and current_index is not None: 242 | result.append(("song", current_index)) 243 | result.append(("songid", current_tlid)) 244 | if next_tlid is not None and next_index is not None: 245 | result.append(("nextsong", next_index)) 246 | result.append(("nextsongid", next_tlid)) 247 | if ( 248 | playback_state in (PlaybackState.PLAYING, PlaybackState.PAUSED) 249 | and current_track is not None 250 | ): 251 | result.append(("time", _status_time(playback_time_position, current_track))) 252 | result.append(("elapsed", _status_time_elapsed(playback_time_position))) 253 | result.append(("bitrate", current_track.bitrate or 0)) 254 | return result 255 | 256 | 257 | def _status_state(playback_state: PlaybackState) -> str: 258 | match playback_state: 259 | case PlaybackState.PLAYING: 260 | return "play" 261 | case PlaybackState.STOPPED: 262 | return "stop" 263 | case PlaybackState.PAUSED: 264 | return "pause" 265 | 266 | 267 | def _status_time(playback_time_position: DurationMs, current_track: Track) -> str: 268 | position = playback_time_position // 1000 269 | total = (current_track.length or 0) // 1000 270 | return f"{position:d}:{total:d}" 271 | 272 | 273 | def _status_time_elapsed(playback_time_position: DurationMs) -> str: 274 | elapsed = playback_time_position / 1000.0 275 | return f"{elapsed:.3f}" 276 | -------------------------------------------------------------------------------- /src/mopidy_mpd/protocol/stickers.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Never 4 | 5 | from mopidy_mpd import exceptions, protocol 6 | 7 | if TYPE_CHECKING: 8 | from mopidy.types import Uri 9 | 10 | from mopidy_mpd.context import MpdContext 11 | 12 | 13 | @protocol.commands.add("sticker", list_command=False) 14 | def sticker( # noqa: PLR0913 15 | context: MpdContext, 16 | action: str, 17 | field: str, 18 | uri: Uri, 19 | name: str | None = None, 20 | value: str | None = None, 21 | ) -> Never: 22 | """ 23 | *musicpd.org, sticker section:* 24 | 25 | ``sticker list {TYPE} {URI}`` 26 | 27 | Lists the stickers for the specified object. 28 | 29 | ``sticker find {TYPE} {URI} {NAME}`` 30 | 31 | Searches the sticker database for stickers with the specified name, 32 | below the specified directory (``URI``). For each matching song, it 33 | prints the ``URI`` and that one sticker's value. 34 | 35 | ``sticker get {TYPE} {URI} {NAME}`` 36 | 37 | Reads a sticker value for the specified object. 38 | 39 | ``sticker set {TYPE} {URI} {NAME} {VALUE}`` 40 | 41 | Adds a sticker value to the specified object. If a sticker item 42 | with that name already exists, it is replaced. 43 | 44 | ``sticker delete {TYPE} {URI} [NAME]`` 45 | 46 | Deletes a sticker value from the specified object. If you do not 47 | specify a sticker name, all sticker values are deleted. 48 | 49 | """ 50 | # TODO: check that action in ('list', 'find', 'get', 'set', 'delete') 51 | # TODO: check name/value matches with action 52 | raise exceptions.MpdNotImplementedError # TODO 53 | -------------------------------------------------------------------------------- /src/mopidy_mpd/protocol/tagtype_list.py: -------------------------------------------------------------------------------- 1 | TAGTYPE_LIST = { 2 | "Artist", 3 | "ArtistSort", 4 | "Album", 5 | "AlbumArtist", 6 | "AlbumArtistSort", 7 | "Title", 8 | "Track", 9 | "Name", 10 | "Genre", 11 | "Date", 12 | "Composer", 13 | "Performer", 14 | "Comment", 15 | "Disc", 16 | "MUSICBRAINZ_ARTISTID", 17 | "MUSICBRAINZ_ALBUMID", 18 | "MUSICBRAINZ_ALBUMARTISTID", 19 | "MUSICBRAINZ_TRACKID", 20 | "X-AlbumUri", 21 | } 22 | -------------------------------------------------------------------------------- /src/mopidy_mpd/session.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | from typing import TYPE_CHECKING, Never, TypedDict 5 | 6 | from mopidy_mpd import dispatcher, formatting, network, protocol, types 7 | from mopidy_mpd.protocol import tagtype_list 8 | 9 | if TYPE_CHECKING: 10 | from mopidy.core import CoreProxy 11 | from mopidy_mpd.uri_mapper import MpdUriMapper 12 | 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | 17 | class MpdSessionKwargs(TypedDict): 18 | config: types.Config 19 | core: CoreProxy 20 | uri_map: MpdUriMapper 21 | connection: network.Connection 22 | 23 | 24 | class MpdSession(network.LineProtocol): 25 | """ 26 | The MPD client session. Keeps track of a single client session. Any 27 | requests from the client is passed on to the MPD request dispatcher. 28 | """ 29 | 30 | terminator = protocol.LINE_TERMINATOR 31 | encoding = protocol.ENCODING 32 | 33 | def __init__( 34 | self, 35 | *, 36 | config: types.Config, 37 | core: CoreProxy, 38 | uri_map: MpdUriMapper, 39 | connection: network.Connection, 40 | ) -> None: 41 | super().__init__(connection) 42 | self.dispatcher = dispatcher.MpdDispatcher( 43 | config=config, 44 | core=core, 45 | uri_map=uri_map, 46 | session=self, 47 | ) 48 | self.tagtypes = tagtype_list.TAGTYPE_LIST.copy() 49 | 50 | def on_start(self) -> None: 51 | logger.info("New MPD connection from %s", self.connection) 52 | self.send_lines([f"OK MPD {protocol.VERSION}"]) 53 | 54 | def on_line_received(self, line: str) -> None: 55 | logger.debug("Request from %s: %s", self.connection, line) 56 | 57 | # All mpd commands start with a lowercase alphabetic character 58 | # To prevent CSRF attacks, requests starting with an invalid 59 | # character are immediately dropped. 60 | if len(line) == 0 or not (line[0].islower() and line[0].isalpha()): 61 | self.connection.stop("Malformed command") 62 | return 63 | 64 | response = self.dispatcher.handle_request(line) 65 | if not response: 66 | return 67 | 68 | logger.debug( 69 | "Response to %s: %s", 70 | self.connection, 71 | formatting.indent(self.decode(self.terminator).join(response)), 72 | ) 73 | 74 | self.send_lines(response) 75 | 76 | def on_event(self, subsystem: str) -> None: 77 | self.dispatcher.handle_idle(subsystem) 78 | 79 | def decode(self, line: bytes) -> str: 80 | try: 81 | return super().decode(line) 82 | except ValueError: 83 | logger.warning( 84 | "Stopping actor due to unescaping error, data " 85 | "supplied by client was not valid." 86 | ) 87 | self.stop() 88 | return Never # pyright: ignore[reportReturnType] 89 | 90 | def close(self) -> None: 91 | self.stop() 92 | -------------------------------------------------------------------------------- /src/mopidy_mpd/tokenize.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from mopidy_mpd import exceptions 4 | 5 | WORD_RE = re.compile( 6 | r""" 7 | ^ 8 | (\s*) # Leading whitespace not allowed, capture it to report. 9 | ([a-z][a-z0-9_]*) # A command name 10 | (?:\s+|$) # trailing whitespace or EOS 11 | (.*) # Possibly a remainder to be parsed 12 | """, 13 | re.VERBOSE, 14 | ) 15 | 16 | # Quotes matching is an unrolled version of "(?:[^"\\]|\\.)*" 17 | PARAM_RE = re.compile( 18 | r""" 19 | ^ # Leading whitespace is not allowed 20 | (?: 21 | ([^{unprintable}"']+) # ord(char) < 0x20, not ", not ' 22 | | # or 23 | "([^"\\]*(?:\\.[^"\\]*)*)" # anything surrounded by quotes 24 | ) 25 | (?:\s+|$) # trailing whitespace or EOS 26 | (.*) # Possibly a remainder to be parsed 27 | """.format(unprintable="".join(map(chr, range(0x21)))), 28 | re.VERBOSE, 29 | ) 30 | 31 | BAD_QUOTED_PARAM_RE = re.compile( 32 | r""" 33 | ^ 34 | "[^"\\]*(?:\\.[^"\\]*)* # start of a quoted value 35 | (?: # followed by: 36 | ("[^\s]) # non-escaped quote, followed by non-whitespace 37 | | # or 38 | ([^"]) # anything that is not a quote 39 | ) 40 | """, 41 | re.VERBOSE, 42 | ) 43 | 44 | UNESCAPE_RE = re.compile(r"\\(.)") # Backslash escapes any following char. 45 | 46 | 47 | def split(line: str) -> list[str]: 48 | """Splits a line into tokens using same rules as MPD. 49 | 50 | - Lines may not start with whitespace 51 | - Tokens are split by arbitrary amount of spaces or tabs 52 | - First token must match `[a-z][a-z0-9_]*` 53 | - Remaining tokens can be unquoted or quoted tokens. 54 | - Unquoted tokens consist of all printable characters except double quotes, 55 | single quotes, spaces and tabs. 56 | - Quoted tokens are surrounded by a matching pair of double quotes. 57 | - The closing quote must be followed by space, tab or end of line. 58 | - Any value is allowed inside a quoted token. Including double quotes, 59 | assuming it is correctly escaped. 60 | - Backslash inside a quoted token is used to escape the following 61 | character. 62 | 63 | For examples see the tests for this function. 64 | """ 65 | if not line.strip(): 66 | raise exceptions.MpdNoCommandError("No command given") 67 | match = WORD_RE.match(line) 68 | if not match: 69 | raise exceptions.MpdUnknownError("Invalid word character") 70 | whitespace, command, remainder = match.groups() 71 | if whitespace: 72 | raise exceptions.MpdUnknownError("Letter expected") 73 | 74 | result: list[str] = [command] 75 | while remainder: 76 | match = PARAM_RE.match(remainder) 77 | if not match: 78 | msg = _determine_error_message(remainder) 79 | raise exceptions.MpdArgError(msg, command=command) 80 | unquoted, quoted, remainder = match.groups() 81 | result.append(unquoted or UNESCAPE_RE.sub(r"\g<1>", quoted)) 82 | return result 83 | 84 | 85 | def _determine_error_message(remainder: str) -> str: 86 | """Helper to emulate MPD errors.""" 87 | # Following checks are simply to match MPD error messages: 88 | match = BAD_QUOTED_PARAM_RE.match(remainder) 89 | if not match: 90 | return "Invalid unquoted character" 91 | if match.group(1): 92 | return "Space expected after closing '\"'" 93 | return "Missing closing '\"'" 94 | -------------------------------------------------------------------------------- /src/mopidy_mpd/translator.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import datetime 4 | import logging 5 | from typing import TYPE_CHECKING 6 | 7 | from mopidy.models import Album, Artist, Playlist, TlTrack, Track 8 | 9 | from mopidy_mpd.protocol import tagtype_list 10 | 11 | if TYPE_CHECKING: 12 | from collections.abc import Iterable, Sequence 13 | 14 | from mopidy_mpd import protocol 15 | 16 | logger = logging.getLogger(__name__) 17 | 18 | 19 | def track_to_mpd_format( # noqa: C901, PLR0912, PLR0915 20 | obj: Track | TlTrack, 21 | tagtypes: set[str], 22 | *, 23 | position: int | None = None, 24 | stream_title: str | None = None, 25 | ) -> protocol.ResultList: 26 | """ 27 | Format track for output to MPD client. 28 | 29 | :param obj: the track 30 | :param tagtypes: the MPD tagtypes enabled by the client 31 | :param position: track's position in playlist 32 | :param stream_title: the current streams title 33 | """ 34 | match obj: 35 | case TlTrack() as tl_track: 36 | tlid = tl_track.tlid 37 | track = tl_track.track 38 | case Track() as track: 39 | tlid = None 40 | 41 | if not track.uri: 42 | logger.warning("Ignoring track without uri") 43 | return [] 44 | 45 | result: list[protocol.ResultTuple] = [ 46 | ("file", track.uri), 47 | ("Time", (track.length and (track.length // 1000)) or 0), 48 | *multi_tag_list(track.artists, "name", "Artist"), 49 | ("Album", (track.album and track.album.name) or ""), 50 | ] 51 | 52 | if stream_title is not None: 53 | result.append(("Title", stream_title)) 54 | if track.name: 55 | result.append(("Name", track.name)) 56 | else: 57 | result.append(("Title", track.name or "")) 58 | 59 | if track.date: 60 | result.append(("Date", track.date)) 61 | 62 | if track.album is not None and track.album.num_tracks is not None: 63 | result.append(("Track", f"{track.track_no or 0}/{track.album.num_tracks}")) 64 | else: 65 | result.append(("Track", track.track_no or 0)) 66 | if position is not None and tlid is not None: 67 | result.append(("Pos", position)) 68 | result.append(("Id", tlid)) 69 | if track.album is not None and track.album.musicbrainz_id is not None: 70 | result.append(("MUSICBRAINZ_ALBUMID", str(track.album.musicbrainz_id))) 71 | 72 | if track.album is not None and track.album.artists: 73 | result += multi_tag_list(track.album.artists, "name", "AlbumArtist") 74 | 75 | musicbrainz_ids = concat_multi_values(track.album.artists, "musicbrainz_id") 76 | if musicbrainz_ids: 77 | result.append(("MUSICBRAINZ_ALBUMARTISTID", musicbrainz_ids)) 78 | 79 | if track.artists: 80 | musicbrainz_ids = concat_multi_values(track.artists, "musicbrainz_id") 81 | if musicbrainz_ids: 82 | result.append(("MUSICBRAINZ_ARTISTID", musicbrainz_ids)) 83 | 84 | if track.composers: 85 | result += multi_tag_list(track.composers, "name", "Composer") 86 | 87 | if track.performers: 88 | result += multi_tag_list(track.performers, "name", "Performer") 89 | 90 | if track.genre: 91 | result.append(("Genre", track.genre)) 92 | 93 | if track.disc_no: 94 | result.append(("Disc", track.disc_no)) 95 | 96 | if track.last_modified: 97 | datestring = datetime.datetime.fromtimestamp( 98 | track.last_modified // 1000, tz=datetime.UTC 99 | ).isoformat(timespec="seconds") 100 | result.append(("Last-Modified", datestring.replace("+00:00", "Z"))) 101 | 102 | if track.musicbrainz_id is not None: 103 | result.append(("MUSICBRAINZ_TRACKID", str(track.musicbrainz_id))) 104 | 105 | if track.album and track.album.uri: 106 | result.append(("X-AlbumUri", track.album.uri)) 107 | 108 | return [ 109 | (tagtype, value) 110 | for (tagtype, value) in result 111 | if _has_value(tagtypes, tagtype, value) 112 | ] 113 | 114 | 115 | def _has_value( 116 | tagtypes: set[str], 117 | tagtype: str, 118 | value: protocol.ResultValue, 119 | ) -> bool: 120 | """ 121 | Determine whether to add the tagtype to the output or not. The tagtype must 122 | be in the list of tagtypes configured for the client. 123 | 124 | :param tagtypes: the MPD tagtypes enabled by the client 125 | :param tagtype: the MPD tagtype 126 | :param value: the tag value 127 | """ 128 | if tagtype in tagtype_list.TAGTYPE_LIST: 129 | if tagtype not in tagtypes: 130 | return False 131 | return bool(value) 132 | return True 133 | 134 | 135 | def concat_multi_values( 136 | models: Iterable[Artist | Album | Track], 137 | attribute: str, 138 | ) -> str: 139 | """ 140 | Format Mopidy model values for output to MPD client. 141 | 142 | :param models: the models 143 | :param attribute: the attribute to use 144 | """ 145 | # Don't sort the values. MPD doesn't appear to (or if it does it's not 146 | # strict alphabetical). If we just use them in the order in which they come 147 | # in then the musicbrainz ids have a higher chance of staying in sync 148 | return ";".join( 149 | str(value) for m in models if (value := getattr(m, attribute, None)) is not None 150 | ) 151 | 152 | 153 | def multi_tag_list( 154 | models: Iterable[Artist | Album | Track], 155 | attribute: str, 156 | tag: str, 157 | ) -> list[protocol.ResultTuple]: 158 | """ 159 | Format multiple objects for output to MPD client in a list with one tag per 160 | value. 161 | 162 | :param models: the model objects 163 | :param attribute: the attribute to use 164 | :param tag: the name of the tag 165 | """ 166 | 167 | return [ 168 | (tag, value if isinstance(value, int) else str(value)) 169 | for obj in models 170 | if (value := getattr(obj, attribute, None)) is not None 171 | ] 172 | 173 | 174 | def tracks_to_mpd_format( 175 | tracks: Sequence[Track | TlTrack], 176 | tagtypes: set[str], 177 | *, 178 | start: int = 0, 179 | end: int | None = None, 180 | ) -> protocol.ResultList: 181 | """ 182 | Format list of tracks for output to MPD client. 183 | 184 | Optionally limit output to the slice ``[start:end]`` of the list. 185 | 186 | :param tracks: the tracks 187 | :param tagtypes: the MPD tagtypes enabled by the client 188 | :param start: position of first track to include in output 189 | :param end: position after last track to include in output, or ``None`` for 190 | end of list 191 | """ 192 | if end is None: 193 | end = len(tracks) 194 | tracks = tracks[start:end] 195 | positions = range(start, end) 196 | assert len(tracks) == len(positions) 197 | result: protocol.ResultList = [] 198 | for track, position in zip(tracks, positions, strict=True): 199 | formatted_track = track_to_mpd_format(track, tagtypes, position=position) 200 | if formatted_track: 201 | result.extend(formatted_track) 202 | return result 203 | 204 | 205 | def playlist_to_mpd_format( 206 | playlist: Playlist, 207 | tagtypes: set[str], 208 | *, 209 | start: int = 0, 210 | end: int | None = None, 211 | ) -> protocol.ResultList: 212 | """ 213 | Format playlist for output to MPD client. 214 | 215 | :param playlist: the playlist 216 | :param tagtypes: the MPD tagtypes enabled by the client 217 | :param start: position of first track to include in output 218 | :param end: position after last track to include in output 219 | """ 220 | return tracks_to_mpd_format(list(playlist.tracks), tagtypes, start=start, end=end) 221 | -------------------------------------------------------------------------------- /src/mopidy_mpd/types.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, TypeAlias, TypedDict 4 | 5 | from mopidy.config import Config as MopidyConfig 6 | 7 | if TYPE_CHECKING: 8 | from mopidy.types import UriScheme 9 | 10 | 11 | class Config(MopidyConfig): 12 | mpd: MpdConfig 13 | 14 | 15 | class MpdConfig(TypedDict): 16 | hostname: str 17 | port: int 18 | password: str | None 19 | max_connections: int 20 | connection_timeout: int 21 | zeroconf: str 22 | command_blacklist: list[str] 23 | default_playlist_scheme: UriScheme 24 | 25 | 26 | SocketAddress: TypeAlias = tuple[str, int | None] 27 | -------------------------------------------------------------------------------- /src/mopidy_mpd/uri_mapper.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import re 4 | from typing import TYPE_CHECKING 5 | 6 | if TYPE_CHECKING: 7 | from mopidy.core import CoreProxy 8 | from mopidy.types import Uri 9 | 10 | 11 | class MpdUriMapper: 12 | """ 13 | Maintains the mappings between uniquified MPD names and URIs. 14 | """ 15 | 16 | # TODO: refactor this into a generic mapper that does not know about browse 17 | # or playlists and then use one instance for each case? 18 | 19 | #: The Mopidy core API. 20 | core: CoreProxy 21 | 22 | _invalid_browse_chars = re.compile(r"[\n\r]") 23 | _invalid_playlist_chars = re.compile(r"[/]") 24 | 25 | def __init__(self, core: CoreProxy) -> None: 26 | self.core = core 27 | self._uri_from_name: dict[str, Uri | None] = {} 28 | self._browse_name_from_uri: dict[Uri | None, str] = {} 29 | self._playlist_name_from_uri: dict[Uri | None, str] = {} 30 | 31 | def _create_unique_name(self, name: str, uri: Uri | None) -> str: 32 | stripped_name = self._invalid_browse_chars.sub(" ", name) 33 | name = stripped_name 34 | i = 2 35 | while name in self._uri_from_name: 36 | if self._uri_from_name[name] == uri: 37 | return name 38 | name = f"{stripped_name} [{i:d}]" 39 | i += 1 40 | return name 41 | 42 | def insert(self, name: str, uri: Uri | None, *, playlist: bool = False) -> str: 43 | """ 44 | Create a unique and MPD compatible name that maps to the given URI. 45 | """ 46 | name = self._create_unique_name(name, uri) 47 | self._uri_from_name[name] = uri 48 | if playlist: 49 | self._playlist_name_from_uri[uri] = name 50 | else: 51 | self._browse_name_from_uri[uri] = name 52 | return name 53 | 54 | def uri_from_name(self, name: str) -> Uri | None: 55 | """ 56 | Return the URI for the given MPD name. 57 | """ 58 | return self._uri_from_name.get(name) 59 | 60 | def refresh_playlists_mapping(self) -> None: 61 | """ 62 | Maintain map between playlists and unique playlist names to be used by 63 | MPD. 64 | """ 65 | if self.core is None: 66 | return 67 | 68 | for playlist_ref in self.core.playlists.as_list().get(): 69 | if not playlist_ref.name: 70 | continue 71 | name = self._invalid_playlist_chars.sub("|", playlist_ref.name) 72 | self.insert(name, playlist_ref.uri, playlist=True) 73 | 74 | def playlist_uri_from_name(self, name: str) -> Uri | None: 75 | """ 76 | Helper function to retrieve a playlist URI from its unique MPD name. 77 | """ 78 | if name not in self._uri_from_name: 79 | self.refresh_playlists_mapping() 80 | return self._uri_from_name.get(name) 81 | 82 | def playlist_name_from_uri(self, uri: Uri) -> str: 83 | """ 84 | Helper function to retrieve the unique MPD playlist name from its URI. 85 | """ 86 | if uri not in self._playlist_name_from_uri: 87 | self.refresh_playlists_mapping() 88 | return self._playlist_name_from_uri[uri] 89 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | class IsA: 2 | def __init__(self, klass): 3 | self.klass = klass 4 | 5 | def __eq__(self, rhs): 6 | try: 7 | return isinstance(rhs, self.klass) 8 | except TypeError: 9 | return type(rhs) == type(self.klass) # noqa: E721 10 | 11 | def __ne__(self, rhs): 12 | return not self.__eq__(rhs) 13 | 14 | def __repr__(self): 15 | return str(self.klass) 16 | 17 | 18 | any_int = IsA(int) 19 | any_str = IsA(str) 20 | any_unicode = any_str # TODO remove 21 | -------------------------------------------------------------------------------- /tests/dummy_audio.py: -------------------------------------------------------------------------------- 1 | """A dummy audio actor for use in tests. 2 | 3 | This class implements the audio API in the simplest way possible. It is used in 4 | tests of the core and backends. 5 | """ 6 | 7 | import pykka 8 | from mopidy import audio 9 | 10 | 11 | def create_proxy(config=None, mixer=None): 12 | return DummyAudio.start(config, mixer).proxy() 13 | 14 | 15 | # TODO: reset position on track change? 16 | class DummyAudio(pykka.ThreadingActor): 17 | def __init__(self, config=None, mixer=None): 18 | super().__init__() 19 | self.state = audio.PlaybackState.STOPPED 20 | self._volume = 0 21 | self._position = 0 22 | self._source_setup_callback = None 23 | self._about_to_finish_callback = None 24 | self._uri = None 25 | self._stream_changed = False 26 | self._live_stream = False 27 | self._tags = {} 28 | self._bad_uris = set() 29 | 30 | def set_uri(self, uri, live_stream=False, download=False): 31 | assert self._uri is None, "prepare change not called before set" 32 | self._position = 0 33 | self._uri = uri 34 | self._stream_changed = True 35 | self._live_stream = live_stream 36 | self._tags = {} 37 | 38 | def set_appsrc(self, *args, **kwargs): 39 | pass 40 | 41 | def emit_data(self, buffer_): 42 | pass 43 | 44 | def get_position(self): 45 | return self._position 46 | 47 | def set_position(self, position): 48 | self._position = position 49 | audio.AudioListener.send("position_changed", position=position) 50 | return True 51 | 52 | def start_playback(self): 53 | return self._change_state(audio.PlaybackState.PLAYING) 54 | 55 | def pause_playback(self): 56 | return self._change_state(audio.PlaybackState.PAUSED) 57 | 58 | def prepare_change(self): 59 | self._uri = None 60 | self._source_setup_callback = None 61 | return True 62 | 63 | def stop_playback(self): 64 | return self._change_state(audio.PlaybackState.STOPPED) 65 | 66 | def get_volume(self): 67 | return self._volume 68 | 69 | def set_volume(self, volume): 70 | self._volume = volume 71 | return True 72 | 73 | def set_metadata(self, track): 74 | pass 75 | 76 | def get_current_tags(self): 77 | return self._tags 78 | 79 | def set_source_setup_callback(self, callback): 80 | self._source_setup_callback = callback 81 | 82 | def set_about_to_finish_callback(self, callback): 83 | self._about_to_finish_callback = callback 84 | 85 | def enable_sync_handler(self): 86 | pass 87 | 88 | def wait_for_state_change(self): 89 | pass 90 | 91 | def _change_state(self, new_state): 92 | if not self._uri: 93 | return False 94 | 95 | if new_state == audio.PlaybackState.STOPPED and self._uri: 96 | self._stream_changed = True 97 | self._uri = None 98 | 99 | if self._stream_changed: 100 | self._stream_changed = False 101 | audio.AudioListener.send("stream_changed", uri=self._uri) 102 | 103 | if self._uri is not None: 104 | audio.AudioListener.send("position_changed", position=0) 105 | 106 | old_state, self.state = self.state, new_state 107 | audio.AudioListener.send( 108 | "state_changed", 109 | old_state=old_state, 110 | new_state=new_state, 111 | target_state=None, 112 | ) 113 | 114 | if new_state == audio.PlaybackState.PLAYING: 115 | self._tags["audio-codec"] = ["fake info..."] 116 | audio.AudioListener.send("tags_changed", tags=["audio-codec"]) 117 | 118 | return self._uri not in self._bad_uris 119 | 120 | def trigger_fake_playback_failure(self, uri): 121 | self._bad_uris.add(uri) 122 | 123 | def trigger_fake_tags_changed(self, tags): 124 | self._tags.update(tags) 125 | audio.AudioListener.send("tags_changed", tags=self._tags.keys()) 126 | 127 | def get_source_setup_callback(self): 128 | # This needs to be called from outside the actor or we lock up. 129 | def wrapper(): 130 | if self._source_setup_callback: 131 | self._source_setup_callback() 132 | 133 | return wrapper 134 | 135 | def get_about_to_finish_callback(self): 136 | # This needs to be called from outside the actor or we lock up. 137 | def wrapper(): 138 | if self._about_to_finish_callback: 139 | self.prepare_change() 140 | self._about_to_finish_callback() 141 | 142 | if not self._uri or not self._about_to_finish_callback: 143 | self._tags = {} 144 | audio.AudioListener.send("reached_end_of_stream") 145 | else: 146 | audio.AudioListener.send("position_changed", position=0) 147 | audio.AudioListener.send("stream_changed", uri=self._uri) 148 | 149 | return wrapper 150 | -------------------------------------------------------------------------------- /tests/dummy_backend.py: -------------------------------------------------------------------------------- 1 | """A dummy backend for use in tests. 2 | 3 | This backend implements the backend API in the simplest way possible. It is 4 | used in tests of the frontends. 5 | """ 6 | 7 | import pykka 8 | from mopidy import backend 9 | from mopidy.models import Playlist, Ref, SearchResult 10 | 11 | 12 | def create_proxy(config=None, audio=None): 13 | return DummyBackend.start(config=config, audio=audio).proxy() 14 | 15 | 16 | class DummyBackend(pykka.ThreadingActor, backend.Backend): 17 | def __init__(self, config, audio): 18 | super().__init__() 19 | 20 | self.library = DummyLibraryProvider(backend=self) 21 | if audio: 22 | self.playback = backend.PlaybackProvider(audio=audio, backend=self) 23 | else: 24 | self.playback = DummyPlaybackProvider(audio=audio, backend=self) 25 | self.playlists = DummyPlaylistsProvider(backend=self) 26 | 27 | self.uri_schemes = ["dummy"] 28 | 29 | 30 | class DummyLibraryProvider(backend.LibraryProvider): 31 | root_directory = Ref.directory(uri="dummy:/", name="dummy") 32 | 33 | def __init__(self, *args, **kwargs): 34 | super().__init__(*args, **kwargs) 35 | self.dummy_library = [] 36 | self.dummy_get_distinct_result = {} 37 | self.dummy_browse_result = {} 38 | self.dummy_find_exact_result = SearchResult() 39 | self.dummy_search_result = SearchResult() 40 | 41 | def browse(self, path): 42 | return self.dummy_browse_result.get(path, []) 43 | 44 | def get_distinct(self, field, query=None): 45 | return self.dummy_get_distinct_result.get(field, set()) 46 | 47 | def lookup(self, uri): 48 | uri = Ref.track(uri=uri).uri 49 | return [t for t in self.dummy_library if uri == t.uri] 50 | 51 | def refresh(self, uri=None): 52 | pass 53 | 54 | def search(self, query=None, uris=None, exact=False): 55 | if exact: # TODO: remove uses of dummy_find_exact_result 56 | return self.dummy_find_exact_result 57 | return self.dummy_search_result 58 | 59 | 60 | class DummyPlaybackProvider(backend.PlaybackProvider): 61 | def __init__(self, *args, **kwargs): 62 | super().__init__(*args, **kwargs) 63 | self._uri = None 64 | self._time_position = 0 65 | 66 | def pause(self): 67 | return True 68 | 69 | def play(self): 70 | return self._uri and self._uri != "dummy:error" 71 | 72 | def change_track(self, track): 73 | """Pass a track with URI 'dummy:error' to force failure""" 74 | self._uri = track.uri 75 | self._time_position = 0 76 | return True 77 | 78 | def prepare_change(self): 79 | pass 80 | 81 | def resume(self): 82 | return True 83 | 84 | def seek(self, time_position): 85 | self._time_position = time_position 86 | return True 87 | 88 | def stop(self): 89 | self._uri = None 90 | return True 91 | 92 | def get_time_position(self): 93 | return self._time_position 94 | 95 | 96 | class DummyPlaylistsProvider(backend.PlaylistsProvider): 97 | def __init__(self, backend): 98 | super().__init__(backend) 99 | self._playlists = [] 100 | self._allow_save = True 101 | 102 | def set_dummy_playlists(self, playlists): 103 | """For tests using the dummy provider through an actor proxy.""" 104 | self._playlists = playlists 105 | 106 | def set_allow_save(self, enabled): 107 | self._allow_save = enabled 108 | 109 | def as_list(self): 110 | return [Ref.playlist(uri=pl.uri, name=pl.name) for pl in self._playlists] 111 | 112 | def get_items(self, uri): 113 | playlist = self.lookup(uri) 114 | if playlist is None: 115 | return None 116 | return [Ref.track(uri=t.uri, name=t.name) for t in playlist.tracks] 117 | 118 | def lookup(self, uri): 119 | uri = Ref.playlist(uri=uri).uri 120 | for playlist in self._playlists: 121 | if playlist.uri == uri: 122 | return playlist 123 | return None 124 | 125 | def refresh(self): 126 | pass 127 | 128 | def create(self, name): 129 | playlist = Playlist(name=name, uri=f"dummy:{name}") 130 | self._playlists.append(playlist) 131 | return playlist 132 | 133 | def delete(self, uri): 134 | playlist = self.lookup(uri) 135 | if playlist: 136 | self._playlists.remove(playlist) 137 | 138 | def save(self, playlist): 139 | if not self._allow_save: 140 | return None 141 | 142 | old_playlist = self.lookup(playlist.uri) 143 | 144 | if old_playlist is not None: 145 | index = self._playlists.index(old_playlist) 146 | self._playlists[index] = playlist 147 | else: 148 | self._playlists.append(playlist) 149 | 150 | return playlist 151 | -------------------------------------------------------------------------------- /tests/dummy_mixer.py: -------------------------------------------------------------------------------- 1 | import pykka 2 | from mopidy import mixer 3 | 4 | 5 | def create_proxy(config=None): 6 | return DummyMixer.start(config=None).proxy() 7 | 8 | 9 | class DummyMixer(pykka.ThreadingActor, mixer.Mixer): 10 | def __init__(self, config): 11 | super().__init__() 12 | self._volume = None 13 | self._mute = None 14 | 15 | def get_volume(self): 16 | return self._volume 17 | 18 | def set_volume(self, volume): 19 | self._volume = volume 20 | self.trigger_volume_changed(volume=volume) 21 | return True 22 | 23 | def get_mute(self): 24 | return self._mute 25 | 26 | def set_mute(self, mute): 27 | self._mute = mute 28 | self.trigger_mute_changed(mute=mute) 29 | return True 30 | -------------------------------------------------------------------------------- /tests/network/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mopidy/mopidy-mpd/b18a79475820e3d266441ed91905e3efdea34ce9/tests/network/__init__.py -------------------------------------------------------------------------------- /tests/network/test_lineprotocol.py: -------------------------------------------------------------------------------- 1 | import re 2 | import unittest 3 | from unittest.mock import Mock, sentinel 4 | 5 | from mopidy_mpd import network 6 | from tests import any_unicode 7 | 8 | 9 | class LineProtocolTest(unittest.TestCase): 10 | def setUp(self): 11 | self.mock = Mock(spec=network.LineProtocol) 12 | 13 | self.mock.terminator = network.LineProtocol.terminator 14 | self.mock.encoding = network.LineProtocol.encoding 15 | self.mock.delimiter = network.LineProtocol.delimiter 16 | self.mock.prevent_timeout = False 17 | 18 | def prepare_on_receive_test(self, return_value=None): 19 | self.mock.connection = Mock(spec=network.Connection) 20 | self.mock.recv_buffer = b"" 21 | self.mock.parse_lines.return_value = return_value or [] 22 | 23 | def test_init_stores_values_in_attributes(self): 24 | network.LineProtocol.__init__(self.mock, sentinel.connection) 25 | assert sentinel.connection == self.mock.connection 26 | assert self.mock.recv_buffer == b"" 27 | assert not self.mock.prevent_timeout 28 | 29 | def test_on_receive_close_calls_stop(self): 30 | self.prepare_on_receive_test() 31 | 32 | network.LineProtocol.on_receive(self.mock, {"close": True}) 33 | self.mock.connection.stop.assert_called_once_with(any_unicode) 34 | 35 | def test_on_receive_no_new_lines_adds_to_recv_buffer(self): 36 | self.prepare_on_receive_test() 37 | 38 | network.LineProtocol.on_receive(self.mock, {"received": b"data"}) 39 | assert self.mock.recv_buffer == b"data" 40 | self.mock.parse_lines.assert_called_once_with() 41 | assert self.mock.on_line_received.call_count == 0 42 | 43 | def test_on_receive_toggles_timeout(self): 44 | self.prepare_on_receive_test() 45 | 46 | network.LineProtocol.on_receive(self.mock, {"received": b"data"}) 47 | self.mock.connection.disable_timeout.assert_called_once_with() 48 | self.mock.connection.enable_timeout.assert_called_once_with() 49 | 50 | def test_on_receive_toggles_unless_prevent_timeout_is_set(self): 51 | self.prepare_on_receive_test() 52 | self.mock.prevent_timeout = True 53 | 54 | network.LineProtocol.on_receive(self.mock, {"received": b"data"}) 55 | self.mock.connection.disable_timeout.assert_called_once_with() 56 | assert self.mock.connection.enable_timeout.call_count == 0 57 | 58 | def test_on_receive_no_new_lines_calls_parse_lines(self): 59 | self.prepare_on_receive_test() 60 | 61 | network.LineProtocol.on_receive(self.mock, {"received": b"data"}) 62 | self.mock.parse_lines.assert_called_once_with() 63 | assert self.mock.on_line_received.call_count == 0 64 | 65 | def test_on_receive_with_new_line_calls_decode(self): 66 | self.prepare_on_receive_test([sentinel.line]) 67 | 68 | network.LineProtocol.on_receive(self.mock, {"received": b"data\n"}) 69 | self.mock.parse_lines.assert_called_once_with() 70 | self.mock.decode.assert_called_once_with(sentinel.line) 71 | 72 | def test_on_receive_with_new_line_calls_on_recieve(self): 73 | self.prepare_on_receive_test([sentinel.line]) 74 | self.mock.decode.return_value = sentinel.decoded 75 | 76 | network.LineProtocol.on_receive(self.mock, {"received": b"data\n"}) 77 | self.mock.on_line_received.assert_called_once_with(sentinel.decoded) 78 | 79 | def test_on_receive_with_new_line_with_failed_decode(self): 80 | self.prepare_on_receive_test([sentinel.line]) 81 | self.mock.decode.return_value = None 82 | 83 | network.LineProtocol.on_receive(self.mock, {"received": b"data\n"}) 84 | assert self.mock.on_line_received.call_count == 0 85 | 86 | def test_on_receive_with_new_lines_calls_on_recieve(self): 87 | self.prepare_on_receive_test(["line1", "line2"]) 88 | self.mock.decode.return_value = sentinel.decoded 89 | 90 | network.LineProtocol.on_receive(self.mock, {"received": b"line1\nline2\n"}) 91 | assert self.mock.on_line_received.call_count == 2 92 | 93 | def test_on_failure_calls_stop(self): 94 | self.mock.connection = Mock(spec=network.Connection) 95 | 96 | network.LineProtocol.on_failure(self.mock, None, None, None) 97 | self.mock.connection.stop.assert_called_once_with("Actor failed.") 98 | 99 | def prepare_parse_lines_test(self, recv_data=""): 100 | self.mock.terminator = b"\n" 101 | self.mock.delimiter = re.compile(rb"\n") 102 | self.mock.recv_buffer = recv_data.encode() 103 | 104 | def test_parse_lines_emtpy_buffer(self): 105 | self.prepare_parse_lines_test() 106 | 107 | lines = network.LineProtocol.parse_lines(self.mock) 108 | with self.assertRaises(StopIteration): 109 | next(lines) 110 | 111 | def test_parse_lines_no_terminator(self): 112 | self.prepare_parse_lines_test("data") 113 | 114 | lines = network.LineProtocol.parse_lines(self.mock) 115 | with self.assertRaises(StopIteration): 116 | next(lines) 117 | 118 | def test_parse_lines_terminator(self): 119 | self.prepare_parse_lines_test("data\n") 120 | 121 | lines = network.LineProtocol.parse_lines(self.mock) 122 | assert next(lines) == b"data" 123 | with self.assertRaises(StopIteration): 124 | next(lines) 125 | assert self.mock.recv_buffer == b"" 126 | 127 | def test_parse_lines_terminator_with_carriage_return(self): 128 | self.prepare_parse_lines_test("data\r\n") 129 | self.mock.delimiter = re.compile(rb"\r?\n") 130 | 131 | lines = network.LineProtocol.parse_lines(self.mock) 132 | assert next(lines) == b"data" 133 | with self.assertRaises(StopIteration): 134 | next(lines) 135 | assert self.mock.recv_buffer == b"" 136 | 137 | def test_parse_lines_no_data_before_terminator(self): 138 | self.prepare_parse_lines_test("\n") 139 | 140 | lines = network.LineProtocol.parse_lines(self.mock) 141 | assert next(lines) == b"" 142 | with self.assertRaises(StopIteration): 143 | next(lines) 144 | assert self.mock.recv_buffer == b"" 145 | 146 | def test_parse_lines_extra_data_after_terminator(self): 147 | self.prepare_parse_lines_test("data1\ndata2") 148 | 149 | lines = network.LineProtocol.parse_lines(self.mock) 150 | assert next(lines) == b"data1" 151 | with self.assertRaises(StopIteration): 152 | next(lines) 153 | assert self.mock.recv_buffer == b"data2" 154 | 155 | def test_parse_lines_non_ascii(self): 156 | self.prepare_parse_lines_test("æøå\n") 157 | 158 | lines = network.LineProtocol.parse_lines(self.mock) 159 | assert "æøå".encode() == next(lines) 160 | with self.assertRaises(StopIteration): 161 | next(lines) 162 | assert self.mock.recv_buffer == b"" 163 | 164 | def test_parse_lines_multiple_lines(self): 165 | self.prepare_parse_lines_test("abc\ndef\nghi\njkl") 166 | 167 | lines = network.LineProtocol.parse_lines(self.mock) 168 | assert next(lines) == b"abc" 169 | assert next(lines) == b"def" 170 | assert next(lines) == b"ghi" 171 | with self.assertRaises(StopIteration): 172 | next(lines) 173 | assert self.mock.recv_buffer == b"jkl" 174 | 175 | def test_parse_lines_multiple_calls(self): 176 | self.prepare_parse_lines_test("data1") 177 | 178 | lines = network.LineProtocol.parse_lines(self.mock) 179 | with self.assertRaises(StopIteration): 180 | next(lines) 181 | assert self.mock.recv_buffer == b"data1" 182 | 183 | self.mock.recv_buffer += b"\ndata2" 184 | 185 | lines = network.LineProtocol.parse_lines(self.mock) 186 | assert next(lines) == b"data1" 187 | with self.assertRaises(StopIteration): 188 | next(lines) 189 | assert self.mock.recv_buffer == b"data2" 190 | 191 | def test_send_lines_called_with_no_lines(self): 192 | self.mock.connection = Mock(spec=network.Connection) 193 | 194 | network.LineProtocol.send_lines(self.mock, []) 195 | assert self.mock.encode.call_count == 0 196 | assert self.mock.connection.queue_send.call_count == 0 197 | 198 | def test_send_lines_calls_join_lines(self): 199 | self.mock.connection = Mock(spec=network.Connection) 200 | self.mock.join_lines.return_value = "lines" 201 | 202 | network.LineProtocol.send_lines(self.mock, ["line 1", "line 2"]) 203 | self.mock.join_lines.assert_called_once_with(["line 1", "line 2"]) 204 | 205 | def test_send_line_encodes_joined_lines_with_final_terminator(self): 206 | self.mock.connection = Mock(spec=network.Connection) 207 | self.mock.join_lines.return_value = "lines\n" 208 | 209 | network.LineProtocol.send_lines(self.mock, ["line 1", "line 2"]) 210 | self.mock.encode.assert_called_once_with("lines\n") 211 | 212 | def test_send_lines_sends_encoded_string(self): 213 | self.mock.connection = Mock(spec=network.Connection) 214 | self.mock.join_lines.return_value = "lines" 215 | self.mock.encode.return_value = sentinel.data 216 | 217 | network.LineProtocol.send_lines(self.mock, ["line 1", "line 2"]) 218 | self.mock.connection.queue_send.assert_called_once_with(sentinel.data) 219 | 220 | def test_join_lines_returns_empty_string_for_no_lines(self): 221 | assert network.LineProtocol.join_lines(self.mock, []) == "" 222 | 223 | def test_join_lines_returns_joined_lines(self): 224 | self.mock.decode.return_value = "\n" 225 | assert network.LineProtocol.join_lines(self.mock, ["1", "2"]) == "1\n2\n" 226 | 227 | def test_decode_calls_decode_on_string(self): 228 | string = Mock() 229 | 230 | network.LineProtocol.decode(self.mock, string) 231 | string.decode.assert_called_once_with(self.mock.encoding) 232 | 233 | def test_decode_plain_ascii(self): 234 | result = network.LineProtocol.decode(self.mock, b"abc") 235 | assert result == "abc" 236 | assert isinstance(result, str) 237 | 238 | def test_decode_utf8(self): 239 | result = network.LineProtocol.decode(self.mock, "æøå".encode()) 240 | assert result == "æøå" 241 | assert isinstance(result, str) 242 | 243 | def test_decode_invalid_data(self): 244 | string = Mock() 245 | string.decode.side_effect = UnicodeError 246 | 247 | network.LineProtocol.decode(self.mock, string) 248 | self.mock.stop.assert_called_once_with() 249 | 250 | def test_encode_calls_encode_on_string(self): 251 | string = Mock() 252 | 253 | network.LineProtocol.encode(self.mock, string) 254 | string.encode.assert_called_once_with(self.mock.encoding) 255 | 256 | def test_encode_plain_ascii(self): 257 | result = network.LineProtocol.encode(self.mock, "abc") 258 | assert result == b"abc" 259 | assert isinstance(result, bytes) 260 | 261 | def test_encode_utf8(self): 262 | result = network.LineProtocol.encode(self.mock, "æøå") 263 | assert "æøå".encode() == result 264 | assert isinstance(result, bytes) 265 | 266 | def test_encode_invalid_data(self): 267 | string = Mock() 268 | string.encode.side_effect = UnicodeError 269 | 270 | network.LineProtocol.encode(self.mock, string) 271 | self.mock.stop.assert_called_once_with() 272 | 273 | def test_host_property(self): 274 | mock = Mock(spec=network.Connection) 275 | mock.host = sentinel.host 276 | 277 | lineprotocol = network.LineProtocol(mock) 278 | assert sentinel.host == lineprotocol.host 279 | 280 | def test_port_property(self): 281 | mock = Mock(spec=network.Connection) 282 | mock.port = sentinel.port 283 | 284 | lineprotocol = network.LineProtocol(mock) 285 | assert sentinel.port == lineprotocol.port 286 | -------------------------------------------------------------------------------- /tests/network/test_utils.py: -------------------------------------------------------------------------------- 1 | import socket 2 | import unittest 3 | from unittest.mock import Mock, patch, sentinel 4 | 5 | from mopidy_mpd import network 6 | 7 | 8 | class FormatHostnameTest(unittest.TestCase): 9 | @patch("mopidy_mpd.network.has_ipv6", True) 10 | def test_format_hostname_prefixes_ipv4_addresses_when_ipv6_available(self): 11 | network.has_ipv6 = True 12 | assert network.format_hostname("0.0.0.0") == "::ffff:0.0.0.0" # noqa: S104 13 | assert network.format_hostname("1.0.0.1") == "::ffff:1.0.0.1" 14 | 15 | @patch("mopidy_mpd.network.has_ipv6", False) 16 | def test_format_hostname_does_nothing_when_only_ipv4_available(self): 17 | network.has_ipv6 = False 18 | assert network.format_hostname("0.0.0.0") == "0.0.0.0" # noqa: S104 19 | 20 | 21 | class FormatAddressTest(unittest.TestCase): 22 | def test_format_address_ipv4(self): 23 | address = (sentinel.host, sentinel.port) 24 | assert network.format_address(address) == f"[{sentinel.host}]:{sentinel.port}" 25 | 26 | def test_format_address_ipv6(self): 27 | address = (sentinel.host, sentinel.port, sentinel.flow, sentinel.scope) 28 | assert network.format_address(address) == f"[{sentinel.host}]:{sentinel.port}" 29 | 30 | def test_format_address_unix(self): 31 | address = (sentinel.path, None) 32 | assert network.format_address(address) == f"[{sentinel.path}]" 33 | 34 | 35 | class GetSocketAddress(unittest.TestCase): 36 | def test_get_socket_address(self): 37 | host = str(sentinel.host) 38 | port = sentinel.port 39 | assert network.get_socket_address(host, port) == (host, port) 40 | 41 | def test_get_socket_address_unix(self): 42 | host = str(sentinel.host) 43 | port = sentinel.port 44 | assert network.get_socket_address(("unix:" + host), port) == ( 45 | host, 46 | None, 47 | ) 48 | 49 | 50 | class TryIPv6SocketTest(unittest.TestCase): 51 | @patch("socket.has_ipv6", False) 52 | def test_system_that_claims_no_ipv6_support(self): 53 | assert not network.try_ipv6_socket() 54 | 55 | @patch("socket.has_ipv6", True) 56 | @patch("socket.socket") 57 | def test_system_with_broken_ipv6(self, socket_mock): 58 | socket_mock.side_effect = OSError() 59 | assert not network.try_ipv6_socket() 60 | 61 | @patch("socket.has_ipv6", True) 62 | @patch("socket.socket") 63 | def test_with_working_ipv6(self, socket_mock): 64 | socket_mock.return_value = Mock() 65 | assert network.try_ipv6_socket() 66 | 67 | 68 | class CreateSocketTest(unittest.TestCase): 69 | @patch("mopidy_mpd.network.has_ipv6", False) 70 | @patch("socket.socket") 71 | def test_ipv4_socket(self, socket_mock): 72 | network.create_tcp_socket() 73 | assert socket_mock.call_args[0] == (socket.AF_INET, socket.SOCK_STREAM) 74 | 75 | @patch("mopidy_mpd.network.has_ipv6", True) 76 | @patch("socket.socket") 77 | def test_ipv6_socket(self, socket_mock): 78 | network.create_tcp_socket() 79 | assert socket_mock.call_args[0] == (socket.AF_INET6, socket.SOCK_STREAM) 80 | 81 | @unittest.SkipTest 82 | def test_ipv6_only_is_set(self): 83 | pass 84 | -------------------------------------------------------------------------------- /tests/path_utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | # TODO: replace with mock usage in tests. 5 | class Mtime: 6 | def __init__(self): 7 | self.fake = None 8 | 9 | def __call__(self, path): 10 | if self.fake is not None: 11 | return self.fake 12 | return int(os.stat(path).st_mtime) # noqa: PTH116 13 | 14 | def set_fake_time(self, time): 15 | self.fake = time 16 | 17 | def undo_fake(self): 18 | self.fake = None 19 | 20 | 21 | mtime = Mtime() 22 | -------------------------------------------------------------------------------- /tests/protocol/__init__.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from typing import cast 3 | from unittest import mock 4 | 5 | import pykka 6 | from mopidy import core 7 | 8 | from mopidy_mpd import session, uri_mapper 9 | from tests import dummy_audio, dummy_backend, dummy_mixer 10 | 11 | 12 | class MockConnection(mock.Mock): 13 | def __init__(self, *args, **kwargs): 14 | super().__init__(*args, **kwargs) 15 | self.host = mock.sentinel.host 16 | self.port = mock.sentinel.port 17 | self.response = [] 18 | 19 | def queue_send(self, data): 20 | data = data.decode() 21 | lines = (line for line in data.split("\n") if line) 22 | self.response.extend(lines) 23 | 24 | 25 | class BaseTestCase(unittest.TestCase): 26 | enable_mixer = True 27 | 28 | def get_config(self): 29 | return { 30 | "core": {"max_tracklist_length": 10000}, 31 | "mpd": { 32 | "command_blacklist": [], 33 | "default_playlist_scheme": "dummy", 34 | "password": None, 35 | }, 36 | } 37 | 38 | def setUp(self): 39 | if self.enable_mixer: 40 | self.mixer = dummy_mixer.create_proxy() 41 | else: 42 | self.mixer = None 43 | self.audio = dummy_audio.create_proxy() 44 | self.backend = dummy_backend.create_proxy(audio=self.audio) 45 | 46 | self.core = cast( 47 | core.CoreProxy, 48 | core.Core.start( 49 | self.get_config(), 50 | audio=self.audio, 51 | mixer=self.mixer, 52 | backends=[self.backend], 53 | ).proxy(), 54 | ) 55 | 56 | self.connection = MockConnection() 57 | self.session = session.MpdSession( 58 | config=self.get_config(), 59 | core=self.core, 60 | uri_map=uri_mapper.MpdUriMapper(self.core), 61 | connection=self.connection, 62 | ) 63 | self.dispatcher = self.session.dispatcher 64 | self.context = self.dispatcher.context 65 | 66 | def tearDown(self): 67 | pykka.ActorRegistry.stop_all() 68 | 69 | def send_request(self, request): 70 | self.connection.response = [] 71 | request = f"{request}\n".encode() 72 | self.session.on_receive({"received": request}) 73 | return self.connection.response 74 | 75 | def assertNoResponse(self): # noqa: N802 76 | assert self.connection.response == [] 77 | 78 | def assertInResponse(self, value): # noqa: N802 79 | assert value in self.connection.response, ( 80 | f"Did not find {value!r} in {self.connection.response!r}" 81 | ) 82 | 83 | def assertOnceInResponse(self, value): # noqa: N802 84 | matched = len([r for r in self.connection.response if r == value]) 85 | assert matched == 1, ( 86 | f"Expected to find {value!r} once in {self.connection.response!r}" 87 | ) 88 | 89 | def assertNotInResponse(self, value): # noqa: N802 90 | assert value not in self.connection.response, ( 91 | f"Found {value!r} in {self.connection.response!r}" 92 | ) 93 | 94 | def assertEqualResponse(self, value): # noqa: N802 95 | assert len(self.connection.response) == 1 96 | assert value == self.connection.response[0] 97 | 98 | def assertResponseLength(self, value): # noqa: N802 99 | assert value == len(self.connection.response) 100 | -------------------------------------------------------------------------------- /tests/protocol/test_audio_output.py: -------------------------------------------------------------------------------- 1 | from tests import protocol 2 | 3 | 4 | class AudioOutputHandlerTest(protocol.BaseTestCase): 5 | def test_enableoutput(self): 6 | self.core.mixer.set_mute(False) 7 | 8 | self.send_request('enableoutput "0"') 9 | 10 | self.assertInResponse("OK") 11 | assert self.core.mixer.get_mute().get() is True 12 | 13 | def test_enableoutput_unknown_outputid(self): 14 | self.send_request('enableoutput "7"') 15 | 16 | self.assertInResponse("ACK [50@0] {enableoutput} No such audio output") 17 | 18 | def test_disableoutput(self): 19 | self.core.mixer.set_mute(True) 20 | 21 | self.send_request('disableoutput "0"') 22 | 23 | self.assertInResponse("OK") 24 | assert self.core.mixer.get_mute().get() is False 25 | 26 | def test_disableoutput_unknown_outputid(self): 27 | self.send_request('disableoutput "7"') 28 | 29 | self.assertInResponse("ACK [50@0] {disableoutput} No such audio output") 30 | 31 | def test_outputs_when_unmuted(self): 32 | self.core.mixer.set_mute(False) 33 | 34 | self.send_request("outputs") 35 | 36 | self.assertInResponse("outputid: 0") 37 | self.assertInResponse("outputname: Mute") 38 | self.assertInResponse("outputenabled: 0") 39 | self.assertInResponse("OK") 40 | 41 | def test_outputs_when_muted(self): 42 | self.core.mixer.set_mute(True) 43 | 44 | self.send_request("outputs") 45 | 46 | self.assertInResponse("outputid: 0") 47 | self.assertInResponse("outputname: Mute") 48 | self.assertInResponse("outputenabled: 1") 49 | self.assertInResponse("OK") 50 | 51 | def test_outputs_toggleoutput(self): 52 | self.core.mixer.set_mute(False) 53 | 54 | self.send_request('toggleoutput "0"') 55 | self.send_request("outputs") 56 | 57 | self.assertInResponse("outputid: 0") 58 | self.assertInResponse("outputname: Mute") 59 | self.assertInResponse("outputenabled: 1") 60 | self.assertInResponse("OK") 61 | 62 | self.send_request('toggleoutput "0"') 63 | self.send_request("outputs") 64 | 65 | self.assertInResponse("outputid: 0") 66 | self.assertInResponse("outputname: Mute") 67 | self.assertInResponse("outputenabled: 0") 68 | self.assertInResponse("OK") 69 | 70 | self.send_request('toggleoutput "0"') 71 | self.send_request("outputs") 72 | 73 | self.assertInResponse("outputid: 0") 74 | self.assertInResponse("outputname: Mute") 75 | self.assertInResponse("outputenabled: 1") 76 | self.assertInResponse("OK") 77 | 78 | def test_outputs_toggleoutput_unknown_outputid(self): 79 | self.send_request('toggleoutput "7"') 80 | 81 | self.assertInResponse("ACK [50@0] {toggleoutput} No such audio output") 82 | 83 | 84 | class AudioOutputHandlerNoneMixerTest(protocol.BaseTestCase): 85 | enable_mixer = False 86 | 87 | def test_enableoutput(self): 88 | assert self.core.mixer.get_mute().get() is None 89 | 90 | self.send_request('enableoutput "0"') 91 | self.assertInResponse("ACK [52@0] {enableoutput} problems enabling output") 92 | 93 | assert self.core.mixer.get_mute().get() is None 94 | 95 | def test_disableoutput(self): 96 | assert self.core.mixer.get_mute().get() is None 97 | 98 | self.send_request('disableoutput "0"') 99 | self.assertInResponse("ACK [52@0] {disableoutput} problems disabling output") 100 | 101 | assert self.core.mixer.get_mute().get() is None 102 | 103 | def test_outputs_when_unmuted(self): 104 | self.core.mixer.set_mute(False) 105 | 106 | self.send_request("outputs") 107 | 108 | self.assertInResponse("outputid: 0") 109 | self.assertInResponse("outputname: Mute") 110 | self.assertInResponse("outputenabled: 0") 111 | self.assertInResponse("OK") 112 | 113 | def test_outputs_when_muted(self): 114 | self.core.mixer.set_mute(True) 115 | 116 | self.send_request("outputs") 117 | 118 | self.assertInResponse("outputid: 0") 119 | self.assertInResponse("outputname: Mute") 120 | self.assertInResponse("outputenabled: 0") 121 | self.assertInResponse("OK") 122 | 123 | def test_outputs_toggleoutput(self): 124 | self.core.mixer.set_mute(False) 125 | 126 | self.send_request('toggleoutput "0"') 127 | self.send_request("outputs") 128 | 129 | self.assertInResponse("outputid: 0") 130 | self.assertInResponse("outputname: Mute") 131 | self.assertInResponse("outputenabled: 0") 132 | self.assertInResponse("OK") 133 | 134 | self.send_request('toggleoutput "0"') 135 | self.send_request("outputs") 136 | 137 | self.assertInResponse("outputid: 0") 138 | self.assertInResponse("outputname: Mute") 139 | self.assertInResponse("outputenabled: 0") 140 | self.assertInResponse("OK") 141 | -------------------------------------------------------------------------------- /tests/protocol/test_authentication.py: -------------------------------------------------------------------------------- 1 | from tests import protocol 2 | 3 | 4 | class AuthenticationActiveTest(protocol.BaseTestCase): 5 | def get_config(self): 6 | config = super().get_config() 7 | config["mpd"]["password"] = "topsecret" # noqa: S105 8 | return config 9 | 10 | def test_authentication_with_valid_password_is_accepted(self): 11 | self.send_request('password "topsecret"') 12 | assert self.dispatcher.authenticated 13 | self.assertInResponse("OK") 14 | 15 | def test_authentication_with_invalid_password_is_not_accepted(self): 16 | self.send_request('password "secret"') 17 | assert not self.dispatcher.authenticated 18 | self.assertEqualResponse("ACK [3@0] {password} incorrect password") 19 | 20 | def test_authentication_without_password_fails(self): 21 | self.send_request("password") 22 | assert not self.dispatcher.authenticated 23 | self.assertEqualResponse( 24 | 'ACK [2@0] {password} wrong number of arguments for "password"' 25 | ) 26 | 27 | def test_anything_when_not_authenticated_should_fail(self): 28 | self.send_request("any request at all") 29 | assert not self.dispatcher.authenticated 30 | self.assertEqualResponse('ACK [4@0] {any} you don\'t have permission for "any"') 31 | 32 | def test_close_is_allowed_without_authentication(self): 33 | self.send_request("close") 34 | assert not self.dispatcher.authenticated 35 | 36 | def test_commands_is_allowed_without_authentication(self): 37 | self.send_request("commands") 38 | assert not self.dispatcher.authenticated 39 | self.assertInResponse("OK") 40 | 41 | def test_notcommands_is_allowed_without_authentication(self): 42 | self.send_request("notcommands") 43 | assert not self.dispatcher.authenticated 44 | self.assertInResponse("OK") 45 | 46 | def test_ping_is_allowed_without_authentication(self): 47 | self.send_request("ping") 48 | assert not self.dispatcher.authenticated 49 | self.assertInResponse("OK") 50 | 51 | 52 | class AuthenticationInactiveTest(protocol.BaseTestCase): 53 | def test_authentication_with_anything_when_password_check_turned_off(self): 54 | self.send_request("any request at all") 55 | assert self.dispatcher.authenticated 56 | self.assertEqualResponse('ACK [5@0] {} unknown command "any"') 57 | 58 | def test_any_password_is_not_accepted_when_password_check_turned_off(self): 59 | self.send_request('password "secret"') 60 | self.assertEqualResponse("ACK [3@0] {password} incorrect password") 61 | -------------------------------------------------------------------------------- /tests/protocol/test_channels.py: -------------------------------------------------------------------------------- 1 | from tests import protocol 2 | 3 | 4 | class ChannelsHandlerTest(protocol.BaseTestCase): 5 | def test_subscribe(self): 6 | self.send_request('subscribe "topic"') 7 | self.assertEqualResponse("ACK [0@0] {subscribe} Not implemented") 8 | 9 | def test_unsubscribe(self): 10 | self.send_request('unsubscribe "topic"') 11 | self.assertEqualResponse("ACK [0@0] {unsubscribe} Not implemented") 12 | 13 | def test_channels(self): 14 | self.send_request("channels") 15 | self.assertEqualResponse("ACK [0@0] {channels} Not implemented") 16 | 17 | def test_readmessages(self): 18 | self.send_request("readmessages") 19 | self.assertEqualResponse("ACK [0@0] {readmessages} Not implemented") 20 | 21 | def test_sendmessage(self): 22 | self.send_request('sendmessage "topic" "a message"') 23 | self.assertEqualResponse("ACK [0@0] {sendmessage} Not implemented") 24 | -------------------------------------------------------------------------------- /tests/protocol/test_command_list.py: -------------------------------------------------------------------------------- 1 | from tests import protocol 2 | 3 | 4 | class CommandListsTest(protocol.BaseTestCase): 5 | def test_command_list_begin(self): 6 | response = self.send_request("command_list_begin") 7 | assert response == [] 8 | 9 | def test_command_list_end(self): 10 | self.send_request("command_list_begin") 11 | self.send_request("command_list_end") 12 | self.assertInResponse("OK") 13 | 14 | def test_command_list_end_without_start_first_is_an_unknown_command(self): 15 | self.send_request("command_list_end") 16 | self.assertEqualResponse('ACK [5@0] {} unknown command "command_list_end"') 17 | 18 | def test_command_list_with_ping(self): 19 | self.send_request("command_list_begin") 20 | assert self.dispatcher.command_list_receiving 21 | assert not self.dispatcher.command_list_ok 22 | assert self.dispatcher.command_list == [] 23 | 24 | self.send_request("ping") 25 | assert "ping" in self.dispatcher.command_list 26 | 27 | self.send_request("command_list_end") 28 | self.assertInResponse("OK") 29 | assert not self.dispatcher.command_list_receiving 30 | assert not self.dispatcher.command_list_ok 31 | assert self.dispatcher.command_list == [] 32 | 33 | def test_command_list_with_error_returns_ack_with_correct_index(self): 34 | self.send_request("command_list_begin") 35 | self.send_request("play") # Known command 36 | self.send_request("paly") # Unknown command 37 | self.send_request("command_list_end") 38 | self.assertEqualResponse('ACK [5@1] {} unknown command "paly"') 39 | 40 | def test_command_list_ok_begin(self): 41 | response = self.send_request("command_list_ok_begin") 42 | assert response == [] 43 | 44 | def test_command_list_ok_with_ping(self): 45 | self.send_request("command_list_ok_begin") 46 | assert self.dispatcher.command_list_receiving 47 | assert self.dispatcher.command_list_ok 48 | assert self.dispatcher.command_list == [] 49 | 50 | self.send_request("ping") 51 | assert "ping" in self.dispatcher.command_list 52 | 53 | self.send_request("command_list_end") 54 | self.assertInResponse("list_OK") 55 | self.assertInResponse("OK") 56 | assert not self.dispatcher.command_list_receiving 57 | assert not self.dispatcher.command_list_ok 58 | assert self.dispatcher.command_list == [] 59 | 60 | # TODO: this should also include the special handling of idle within a 61 | # command list. That is that once a idle/noidle command is found inside a 62 | # commad list, the rest of the list seems to be ignored. 63 | -------------------------------------------------------------------------------- /tests/protocol/test_connection.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | 3 | from mopidy_mpd.protocol import tagtype_list 4 | from tests import protocol 5 | 6 | 7 | class ConnectionHandlerTest(protocol.BaseTestCase): 8 | def test_close_closes_the_client_connection(self): 9 | with patch.object(self.session, "close") as close_mock: 10 | self.send_request("close") 11 | close_mock.assert_called_once_with() 12 | self.assertEqualResponse("OK") 13 | 14 | def test_empty_request(self): 15 | self.send_request("") 16 | self.assertNoResponse() 17 | 18 | self.send_request(" ") 19 | self.assertNoResponse() 20 | 21 | def test_kill(self): 22 | self.send_request("kill") 23 | self.assertEqualResponse( 24 | 'ACK [4@0] {kill} you don\'t have permission for "kill"' 25 | ) 26 | 27 | def test_ping(self): 28 | self.send_request("ping") 29 | self.assertEqualResponse("OK") 30 | 31 | def test_malformed_comamnd(self): 32 | self.send_request("GET / HTTP/1.1") 33 | self.assertNoResponse() 34 | self.connection.stop.assert_called_once_with("Malformed command") 35 | 36 | def test_tagtypes(self): 37 | self.send_request("tagtypes") 38 | self.assertInResponse("tagtype: Artist") 39 | self.assertInResponse("tagtype: ArtistSort") 40 | self.assertInResponse("tagtype: Album") 41 | self.assertInResponse("tagtype: AlbumArtist") 42 | self.assertInResponse("tagtype: Title") 43 | self.assertInResponse("tagtype: Track") 44 | self.assertInResponse("tagtype: Name") 45 | self.assertInResponse("tagtype: Genre") 46 | self.assertInResponse("tagtype: Date") 47 | self.assertInResponse("tagtype: Composer") 48 | self.assertInResponse("tagtype: Performer") 49 | self.assertInResponse("tagtype: Disc") 50 | self.assertInResponse("tagtype: MUSICBRAINZ_ARTISTID") 51 | self.assertInResponse("tagtype: MUSICBRAINZ_ALBUMID") 52 | self.assertInResponse("tagtype: MUSICBRAINZ_ALBUMARTISTID") 53 | self.assertInResponse("tagtype: MUSICBRAINZ_TRACKID") 54 | self.assertInResponse("OK") 55 | 56 | def test_tagtypes_clear(self): 57 | self.send_request("tagtypes clear") 58 | self.assertEqualResponse("OK") 59 | self.send_request("tagtypes") 60 | self.assertEqualResponse("OK") 61 | 62 | def test_tagtypes_all(self): 63 | self.send_request("tagtypes all") 64 | self.assertEqualResponse("OK") 65 | self.send_request("tagtypes") 66 | self.assertInResponse("tagtype: Artist") 67 | self.assertInResponse("tagtype: Album") 68 | self.assertInResponse("tagtype: AlbumArtist") 69 | self.assertInResponse("tagtype: Title") 70 | self.assertInResponse("tagtype: Track") 71 | self.assertInResponse("tagtype: Name") 72 | self.assertInResponse("tagtype: Genre") 73 | self.assertInResponse("tagtype: Date") 74 | self.assertInResponse("tagtype: Composer") 75 | self.assertInResponse("tagtype: Performer") 76 | self.assertInResponse("tagtype: Disc") 77 | self.assertInResponse("tagtype: MUSICBRAINZ_ARTISTID") 78 | self.assertInResponse("tagtype: MUSICBRAINZ_ALBUMID") 79 | self.assertInResponse("tagtype: MUSICBRAINZ_ALBUMARTISTID") 80 | self.assertInResponse("tagtype: MUSICBRAINZ_TRACKID") 81 | self.assertInResponse("OK") 82 | self.assertResponseLength(len(tagtype_list.TAGTYPE_LIST) + 1) 83 | 84 | def test_tagtypes_disable(self): 85 | self.send_request("tagtypes all") 86 | self.send_request( 87 | "tagtypes disable MUSICBRAINZ_ARTISTID MUSICBRAINZ_ALBUMID " 88 | "MUSICBRAINZ_ALBUMARTISTID MUSICBRAINZ_TRACKID" 89 | ) 90 | self.assertEqualResponse("OK") 91 | self.send_request("tagtypes") 92 | self.assertInResponse("tagtype: Artist") 93 | self.assertInResponse("tagtype: Album") 94 | self.assertInResponse("tagtype: AlbumArtist") 95 | self.assertInResponse("tagtype: Title") 96 | self.assertInResponse("tagtype: Track") 97 | self.assertInResponse("tagtype: Name") 98 | self.assertInResponse("tagtype: Genre") 99 | self.assertInResponse("tagtype: Date") 100 | self.assertInResponse("tagtype: Composer") 101 | self.assertInResponse("tagtype: Performer") 102 | self.assertInResponse("tagtype: Disc") 103 | self.assertNotInResponse("tagtype: MUSICBRAINZ_ARTISTID") 104 | self.assertNotInResponse("tagtype: MUSICBRAINZ_ALBUMID") 105 | self.assertNotInResponse("tagtype: MUSICBRAINZ_ALBUMARTISTID") 106 | self.assertNotInResponse("tagtype: MUSICBRAINZ_TRACKID") 107 | self.assertInResponse("OK") 108 | 109 | def test_tagtypes_enable(self): 110 | self.send_request("tagtypes clear") 111 | self.send_request("tagtypes enable Artist Album Title Track Name Genre") 112 | self.assertEqualResponse("OK") 113 | self.send_request("tagtypes") 114 | self.assertInResponse("tagtype: Artist") 115 | self.assertInResponse("tagtype: Album") 116 | self.assertInResponse("tagtype: Title") 117 | self.assertInResponse("tagtype: Track") 118 | self.assertInResponse("tagtype: Name") 119 | self.assertInResponse("tagtype: Genre") 120 | self.assertNotInResponse("tagtype: ArtistSort") 121 | self.assertNotInResponse("tagtype: AlbumArtist") 122 | self.assertNotInResponse("tagtype: AlbumArtistSort") 123 | self.assertNotInResponse("tagtype: Date") 124 | self.assertNotInResponse("tagtype: Composer") 125 | self.assertNotInResponse("tagtype: Performer") 126 | self.assertNotInResponse("tagtype: Disc") 127 | self.assertNotInResponse("tagtype: MUSICBRAINZ_ARTISTID") 128 | self.assertNotInResponse("tagtype: MUSICBRAINZ_ALBUMID") 129 | self.assertNotInResponse("tagtype: MUSICBRAINZ_ALBUMARTISTID") 130 | self.assertNotInResponse("tagtype: MUSICBRAINZ_TRACKID") 131 | self.assertInResponse("OK") 132 | 133 | def test_tagtypes_disable_x(self): 134 | self.send_request("tagtypes disable x") 135 | self.assertEqualResponse("ACK [2@0] {tagtypes} Unknown tag type") 136 | 137 | def test_tagtypes_enable_x(self): 138 | self.send_request("tagtypes enable x") 139 | self.assertEqualResponse("ACK [2@0] {tagtypes} Unknown tag type") 140 | 141 | def test_tagtypes_disable_empty(self): 142 | self.send_request("tagtypes disable") 143 | self.assertEqualResponse("ACK [2@0] {tagtypes} Not enough arguments") 144 | 145 | def test_tagtypes_enable_empty(self): 146 | self.send_request("tagtypes enable") 147 | self.assertEqualResponse("ACK [2@0] {tagtypes} Not enough arguments") 148 | 149 | def test_tagtypes_bogus(self): 150 | self.send_request("tagtypes bogus") 151 | self.assertEqualResponse("ACK [2@0] {tagtypes} Unknown sub command") 152 | -------------------------------------------------------------------------------- /tests/protocol/test_idle.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | 3 | from mopidy_mpd.protocol.status import SUBSYSTEMS 4 | from tests import protocol 5 | 6 | 7 | class IdleHandlerTest(protocol.BaseTestCase): 8 | def idle_event(self, subsystem): 9 | self.session.on_event(subsystem) 10 | 11 | def assertEqualEvents(self, events): # noqa: N802 12 | assert self.dispatcher.subsystem_events == set(events) 13 | 14 | def assertEqualSubscriptions(self, events): # noqa: N802 15 | assert self.dispatcher.subsystem_subscriptions == set(events) 16 | 17 | def assertNoEvents(self): # noqa: N802 18 | self.assertEqualEvents([]) 19 | 20 | def assertNoSubscriptions(self): # noqa: N802 21 | self.assertEqualSubscriptions([]) 22 | 23 | def test_base_state(self): 24 | self.assertNoSubscriptions() 25 | self.assertNoEvents() 26 | self.assertNoResponse() 27 | 28 | def test_idle(self): 29 | self.send_request("idle") 30 | self.assertEqualSubscriptions(SUBSYSTEMS) 31 | self.assertNoEvents() 32 | self.assertNoResponse() 33 | 34 | def test_idle_disables_timeout(self): 35 | self.send_request("idle") 36 | self.connection.disable_timeout.assert_called_once_with() 37 | 38 | def test_noidle(self): 39 | self.send_request("noidle") 40 | self.assertNoSubscriptions() 41 | self.assertNoEvents() 42 | self.assertNoResponse() 43 | 44 | def test_idle_player(self): 45 | self.send_request("idle player") 46 | self.assertEqualSubscriptions(["player"]) 47 | self.assertNoEvents() 48 | self.assertNoResponse() 49 | 50 | def test_idle_output(self): 51 | self.send_request("idle output") 52 | self.assertEqualSubscriptions(["output"]) 53 | self.assertNoEvents() 54 | self.assertNoResponse() 55 | 56 | def test_idle_player_playlist(self): 57 | self.send_request("idle player playlist") 58 | self.assertEqualSubscriptions(["player", "playlist"]) 59 | self.assertNoEvents() 60 | self.assertNoResponse() 61 | 62 | def test_idle_then_noidle(self): 63 | self.send_request("idle") 64 | self.send_request("noidle") 65 | self.assertNoSubscriptions() 66 | self.assertNoEvents() 67 | self.assertOnceInResponse("OK") 68 | 69 | def test_idle_then_noidle_enables_timeout(self): 70 | self.send_request("idle") 71 | self.send_request("noidle") 72 | self.connection.enable_timeout.assert_called_once_with() 73 | 74 | def test_idle_then_play(self): 75 | with patch.object(self.session, "stop") as stop_mock: 76 | self.send_request("idle") 77 | self.send_request("play") 78 | stop_mock.assert_called_once_with() 79 | 80 | def test_idle_then_idle(self): 81 | with patch.object(self.session, "stop") as stop_mock: 82 | self.send_request("idle") 83 | self.send_request("idle") 84 | stop_mock.assert_called_once_with() 85 | 86 | def test_idle_player_then_play(self): 87 | with patch.object(self.session, "stop") as stop_mock: 88 | self.send_request("idle player") 89 | self.send_request("play") 90 | stop_mock.assert_called_once_with() 91 | 92 | def test_idle_then_player(self): 93 | self.send_request("idle") 94 | self.idle_event("player") 95 | self.assertNoSubscriptions() 96 | self.assertNoEvents() 97 | self.assertOnceInResponse("changed: player") 98 | self.assertOnceInResponse("OK") 99 | 100 | def test_idle_player_then_event_player(self): 101 | self.send_request("idle player") 102 | self.idle_event("player") 103 | self.assertNoSubscriptions() 104 | self.assertNoEvents() 105 | self.assertOnceInResponse("changed: player") 106 | self.assertOnceInResponse("OK") 107 | 108 | def test_idle_then_output(self): 109 | self.send_request("idle") 110 | self.idle_event("output") 111 | self.assertNoSubscriptions() 112 | self.assertNoEvents() 113 | self.assertOnceInResponse("changed: output") 114 | self.assertOnceInResponse("OK") 115 | 116 | def test_idle_output_then_event_output(self): 117 | self.send_request("idle output") 118 | self.idle_event("output") 119 | self.assertNoSubscriptions() 120 | self.assertNoEvents() 121 | self.assertOnceInResponse("changed: output") 122 | self.assertOnceInResponse("OK") 123 | 124 | def test_idle_player_then_noidle(self): 125 | self.send_request("idle player") 126 | self.send_request("noidle") 127 | self.assertNoSubscriptions() 128 | self.assertNoEvents() 129 | self.assertOnceInResponse("OK") 130 | 131 | def test_idle_player_playlist_then_noidle(self): 132 | self.send_request("idle player playlist") 133 | self.send_request("noidle") 134 | self.assertNoEvents() 135 | self.assertNoSubscriptions() 136 | self.assertOnceInResponse("OK") 137 | 138 | def test_idle_player_playlist_then_player(self): 139 | self.send_request("idle player playlist") 140 | self.idle_event("player") 141 | self.assertNoEvents() 142 | self.assertNoSubscriptions() 143 | self.assertOnceInResponse("changed: player") 144 | self.assertNotInResponse("changed: playlist") 145 | self.assertOnceInResponse("OK") 146 | 147 | def test_idle_playlist_then_player(self): 148 | self.send_request("idle playlist") 149 | self.idle_event("player") 150 | self.assertEqualEvents(["player"]) 151 | self.assertEqualSubscriptions(["playlist"]) 152 | self.assertNoResponse() 153 | 154 | def test_idle_playlist_then_player_then_playlist(self): 155 | self.send_request("idle playlist") 156 | self.idle_event("player") 157 | self.idle_event("playlist") 158 | self.assertNoEvents() 159 | self.assertNoSubscriptions() 160 | self.assertNotInResponse("changed: player") 161 | self.assertOnceInResponse("changed: playlist") 162 | self.assertOnceInResponse("OK") 163 | 164 | def test_player(self): 165 | self.idle_event("player") 166 | self.assertEqualEvents(["player"]) 167 | self.assertNoSubscriptions() 168 | self.assertNoResponse() 169 | 170 | def test_player_then_idle_player(self): 171 | self.idle_event("player") 172 | self.send_request("idle player") 173 | self.assertNoEvents() 174 | self.assertNoSubscriptions() 175 | self.assertOnceInResponse("changed: player") 176 | self.assertNotInResponse("changed: playlist") 177 | self.assertOnceInResponse("OK") 178 | 179 | def test_player_then_playlist(self): 180 | self.idle_event("player") 181 | self.idle_event("playlist") 182 | self.assertEqualEvents(["player", "playlist"]) 183 | self.assertNoSubscriptions() 184 | self.assertNoResponse() 185 | 186 | def test_player_then_idle(self): 187 | self.idle_event("player") 188 | self.send_request("idle") 189 | self.assertNoEvents() 190 | self.assertNoSubscriptions() 191 | self.assertOnceInResponse("changed: player") 192 | self.assertOnceInResponse("OK") 193 | 194 | def test_player_then_playlist_then_idle(self): 195 | self.idle_event("player") 196 | self.idle_event("playlist") 197 | self.send_request("idle") 198 | self.assertNoEvents() 199 | self.assertNoSubscriptions() 200 | self.assertOnceInResponse("changed: player") 201 | self.assertOnceInResponse("changed: playlist") 202 | self.assertOnceInResponse("OK") 203 | 204 | def test_player_then_idle_playlist(self): 205 | self.idle_event("player") 206 | self.send_request("idle playlist") 207 | self.assertEqualEvents(["player"]) 208 | self.assertEqualSubscriptions(["playlist"]) 209 | self.assertNoResponse() 210 | 211 | def test_player_then_idle_playlist_then_noidle(self): 212 | self.idle_event("player") 213 | self.send_request("idle playlist") 214 | self.send_request("noidle") 215 | self.assertNoEvents() 216 | self.assertNoSubscriptions() 217 | self.assertOnceInResponse("OK") 218 | 219 | def test_player_then_playlist_then_idle_playlist(self): 220 | self.idle_event("player") 221 | self.idle_event("playlist") 222 | self.send_request("idle playlist") 223 | self.assertNoEvents() 224 | self.assertNoSubscriptions() 225 | self.assertNotInResponse("changed: player") 226 | self.assertOnceInResponse("changed: playlist") 227 | self.assertOnceInResponse("OK") 228 | 229 | def test_output_then_idle_toggleoutput(self): 230 | self.idle_event("output") 231 | self.send_request("idle output") 232 | self.assertNoEvents() 233 | self.assertNoSubscriptions() 234 | self.assertOnceInResponse("changed: output") 235 | self.assertOnceInResponse("OK") 236 | -------------------------------------------------------------------------------- /tests/protocol/test_mount.py: -------------------------------------------------------------------------------- 1 | from tests import protocol 2 | 3 | 4 | class MountTest(protocol.BaseTestCase): 5 | def test_mount(self): 6 | self.send_request("mount my_disk /dev/sda") 7 | self.assertEqualResponse("ACK [0@0] {mount} Not implemented") 8 | 9 | def test_unmount(self): 10 | self.send_request("unmount my_disk") 11 | self.assertEqualResponse("ACK [0@0] {unmount} Not implemented") 12 | 13 | def test_listmounts(self): 14 | self.send_request("listmounts") 15 | self.assertEqualResponse("ACK [0@0] {listmounts} Not implemented") 16 | 17 | def test_listneighbors(self): 18 | self.send_request("listneighbors") 19 | self.assertEqualResponse("ACK [0@0] {listneighbors} Not implemented") 20 | -------------------------------------------------------------------------------- /tests/protocol/test_reflection.py: -------------------------------------------------------------------------------- 1 | from tests import protocol 2 | 3 | 4 | class ReflectionHandlerTest(protocol.BaseTestCase): 5 | def test_config_is_not_allowed_across_the_network(self): 6 | self.send_request("config") 7 | self.assertEqualResponse( 8 | 'ACK [4@0] {config} you don\'t have permission for "config"' 9 | ) 10 | 11 | def test_commands_returns_list_of_all_commands(self): 12 | self.send_request("commands") 13 | # Check if some random commands are included 14 | self.assertInResponse("command: commands") 15 | self.assertInResponse("command: play") 16 | self.assertInResponse("command: status") 17 | # Check if commands you do not have access to are not present 18 | self.assertNotInResponse("command: config") 19 | self.assertNotInResponse("command: kill") 20 | # Check if the blacklisted commands are not present 21 | self.assertNotInResponse("command: command_list_begin") 22 | self.assertNotInResponse("command: command_list_ok_begin") 23 | self.assertNotInResponse("command: command_list_end") 24 | self.assertInResponse("command: idle") 25 | self.assertNotInResponse("command: noidle") 26 | self.assertNotInResponse("command: sticker") 27 | self.assertInResponse("OK") 28 | 29 | def test_decoders(self): 30 | self.send_request("decoders") 31 | self.assertInResponse("OK") 32 | 33 | def test_notcommands_returns_only_config_and_kill_and_ok(self): 34 | response = self.send_request("notcommands") 35 | assert len(response) == 3 36 | self.assertInResponse("command: config") 37 | self.assertInResponse("command: kill") 38 | self.assertInResponse("OK") 39 | 40 | def test_urlhandlers(self): 41 | self.send_request("urlhandlers") 42 | self.assertInResponse("OK") 43 | self.assertInResponse("handler: dummy") 44 | 45 | 46 | class ReflectionWhenNotAuthedTest(protocol.BaseTestCase): 47 | def get_config(self): 48 | config = super().get_config() 49 | config["mpd"]["password"] = "topsecret" # noqa: S105 50 | return config 51 | 52 | def test_commands_show_less_if_auth_required_and_not_authed(self): 53 | self.send_request("commands") 54 | # Not requiring auth 55 | self.assertInResponse("command: close") 56 | self.assertInResponse("command: commands") 57 | self.assertInResponse("command: notcommands") 58 | self.assertInResponse("command: password") 59 | self.assertInResponse("command: ping") 60 | # Requiring auth 61 | self.assertNotInResponse("command: play") 62 | self.assertNotInResponse("command: status") 63 | 64 | def test_notcommands_returns_more_if_auth_required_and_not_authed(self): 65 | self.send_request("notcommands") 66 | # Not requiring auth 67 | self.assertNotInResponse("command: close") 68 | self.assertNotInResponse("command: commands") 69 | self.assertNotInResponse("command: notcommands") 70 | self.assertNotInResponse("command: password") 71 | self.assertNotInResponse("command: ping") 72 | # Requiring auth 73 | self.assertInResponse("command: play") 74 | self.assertInResponse("command: status") 75 | -------------------------------------------------------------------------------- /tests/protocol/test_regression.py: -------------------------------------------------------------------------------- 1 | import random 2 | from unittest import mock 3 | 4 | from mopidy.models import Playlist, Ref, Track 5 | 6 | from mopidy_mpd.protocol import stored_playlists 7 | from tests import protocol 8 | 9 | 10 | def mock_shuffle(foo): 11 | foo[:] = [foo[1], foo[2], foo[5], foo[3], foo[4], foo[0]] 12 | 13 | 14 | class IssueGH17RegressionTest(protocol.BaseTestCase): 15 | """ 16 | The issue: http://github.com/mopidy/mopidy/issues/17 17 | 18 | How to reproduce: 19 | 20 | - Play a playlist where one track cannot be played 21 | - Turn on random mode 22 | - Press next until you get to the unplayable track 23 | """ 24 | 25 | @mock.patch.object(protocol.core.tracklist.random, "shuffle", mock_shuffle) 26 | def test(self): 27 | tracks = [ 28 | Track(uri="dummy:a"), 29 | Track(uri="dummy:b"), 30 | Track(uri="dummy:error"), 31 | Track(uri="dummy:d"), 32 | Track(uri="dummy:e"), 33 | Track(uri="dummy:f"), 34 | ] 35 | self.audio.trigger_fake_playback_failure("dummy:error") 36 | self.backend.library.dummy_library = tracks 37 | self.core.tracklist.add(uris=[t.uri for t in tracks]).get() 38 | 39 | # Playlist order: abcfde 40 | 41 | self.send_request("play") 42 | assert self.core.playback.get_current_track().get().uri == "dummy:a" 43 | self.send_request('random "1"') 44 | self.send_request("next") 45 | assert self.core.playback.get_current_track().get().uri == "dummy:b" 46 | self.send_request("next") 47 | # Should now be at track 'c', but playback fails and it skips ahead 48 | assert self.core.playback.get_current_track().get().uri == "dummy:f" 49 | self.send_request("next") 50 | assert self.core.playback.get_current_track().get().uri == "dummy:d" 51 | self.send_request("next") 52 | assert self.core.playback.get_current_track().get().uri == "dummy:e" 53 | 54 | 55 | class IssueGH18RegressionTest(protocol.BaseTestCase): 56 | """ 57 | The issue: http://github.com/mopidy/mopidy/issues/18 58 | 59 | How to reproduce: 60 | 61 | Play, random on, next, random off, next, next. 62 | 63 | At this point it gives the same song over and over. 64 | """ 65 | 66 | def test(self): 67 | tracks = [ 68 | Track(uri="dummy:a"), 69 | Track(uri="dummy:b"), 70 | Track(uri="dummy:c"), 71 | Track(uri="dummy:d"), 72 | Track(uri="dummy:e"), 73 | Track(uri="dummy:f"), 74 | ] 75 | self.backend.library.dummy_library = tracks 76 | self.core.tracklist.add(uris=[t.uri for t in tracks]).get() 77 | 78 | random.seed(1) 79 | 80 | self.send_request("play") 81 | self.send_request('random "1"') 82 | self.send_request("next") 83 | self.send_request('random "0"') 84 | self.send_request("next") 85 | 86 | self.send_request("next") 87 | tl_track_1 = self.core.playback.get_current_tl_track().get() 88 | self.send_request("next") 89 | tl_track_2 = self.core.playback.get_current_tl_track().get() 90 | self.send_request("next") 91 | tl_track_3 = self.core.playback.get_current_tl_track().get() 92 | 93 | assert tl_track_1 != tl_track_2 94 | assert tl_track_2 != tl_track_3 95 | 96 | 97 | class IssueGH22RegressionTest(protocol.BaseTestCase): 98 | """ 99 | The issue: http://github.com/mopidy/mopidy/issues/22 100 | 101 | How to reproduce: 102 | 103 | Play, random on, remove all tracks from the current playlist (as in 104 | "delete" each one, not "clear"). 105 | 106 | Alternatively: Play, random on, remove a random track from the current 107 | playlist, press next until it crashes. 108 | """ 109 | 110 | def test(self): 111 | tracks = [ 112 | Track(uri="dummy:a"), 113 | Track(uri="dummy:b"), 114 | Track(uri="dummy:c"), 115 | Track(uri="dummy:d"), 116 | Track(uri="dummy:e"), 117 | Track(uri="dummy:f"), 118 | ] 119 | self.backend.library.dummy_library = tracks 120 | self.core.tracklist.add(uris=[t.uri for t in tracks]).get() 121 | 122 | random.seed(1) 123 | 124 | self.send_request("play") 125 | self.send_request('random "1"') 126 | self.send_request('deleteid "1"') 127 | self.send_request('deleteid "2"') 128 | self.send_request('deleteid "3"') 129 | self.send_request('deleteid "4"') 130 | self.send_request('deleteid "5"') 131 | self.send_request('deleteid "6"') 132 | self.send_request("status") 133 | 134 | 135 | class IssueGH69RegressionTest(protocol.BaseTestCase): 136 | """ 137 | The issue: https://github.com/mopidy/mopidy/issues/69 138 | 139 | How to reproduce: 140 | 141 | Play track, stop, clear current playlist, load a new playlist, status. 142 | 143 | The status response now contains "song: None". 144 | """ 145 | 146 | def test(self): 147 | self.core.playlists.create("foo") 148 | 149 | tracks = [ 150 | Track(uri="dummy:a"), 151 | Track(uri="dummy:b"), 152 | Track(uri="dummy:c"), 153 | Track(uri="dummy:d"), 154 | Track(uri="dummy:e"), 155 | Track(uri="dummy:f"), 156 | ] 157 | self.backend.library.dummy_library = tracks 158 | self.core.tracklist.add(uris=[t.uri for t in tracks]).get() 159 | 160 | self.send_request("play") 161 | self.send_request("stop") 162 | self.send_request("clear") 163 | self.send_request('load "foo"') 164 | self.assertNotInResponse("song: None") 165 | 166 | 167 | class IssueGH113RegressionTest(protocol.BaseTestCase): 168 | r""" 169 | The issue: https://github.com/mopidy/mopidy/issues/113 170 | 171 | How to reproduce: 172 | 173 | - Have a playlist with a name contining backslashes, like 174 | "all lart spotify:track:\w\{22\} pastes". 175 | - Try to load the playlist with the backslashes in the playlist name 176 | escaped. 177 | """ 178 | 179 | def test(self): 180 | self.core.playlists.create(r"all lart spotify:track:\w\{22\} pastes") 181 | 182 | self.send_request('lsinfo "/"') 183 | self.assertInResponse(r"playlist: all lart spotify:track:\w\{22\} pastes") 184 | 185 | self.send_request( 186 | r'listplaylistinfo "all lart spotify:track:\\w\\{22\\} pastes"' 187 | ) 188 | self.assertInResponse("OK") 189 | 190 | 191 | class IssueGH137RegressionTest(protocol.BaseTestCase): 192 | """ 193 | The issue: https://github.com/mopidy/mopidy/issues/137 194 | 195 | How to reproduce: 196 | 197 | - Send "list" query with mismatching quotes 198 | """ 199 | 200 | def test(self): 201 | self.send_request( 202 | 'list Date Artist "Anita Ward" ' 203 | 'Album "This Is Remixed Hits - Mashups & Rare 12" Mixes"' 204 | ) 205 | 206 | self.assertInResponse("ACK [2@0] {list} Invalid unquoted character") 207 | 208 | 209 | class IssueGH1120RegressionTest(protocol.BaseTestCase): 210 | """ 211 | The issue: https://github.com/mopidy/mopidy/issues/1120 212 | 213 | How to reproduce: 214 | 215 | - A playlist must be in both browse results and playlists 216 | - Call for instance ``lsinfo "/"`` to populate the cache with the 217 | playlist name from the playlist backend. 218 | - Call ``lsinfo "/dummy"`` to override the playlist name with the browse 219 | name. 220 | - Call ``lsinfo "/"`` and we now have an invalid name with ``/`` in it. 221 | 222 | """ 223 | 224 | @mock.patch.object(stored_playlists, "_get_last_modified") 225 | def test(self, last_modified_mock): 226 | last_modified_mock.return_value = "2015-08-05T22:51:06Z" 227 | self.backend.library.dummy_browse_result = { 228 | "dummy:/": [Ref.playlist(name="Top 100 tracks", uri="dummy:/1")], 229 | } 230 | self.backend.playlists.set_dummy_playlists( 231 | [Playlist(name="Top 100 tracks", uri="dummy:/1")] 232 | ) 233 | 234 | response1 = self.send_request('lsinfo "/"') 235 | self.send_request('lsinfo "/dummy"') 236 | 237 | response2 = self.send_request('lsinfo "/"') 238 | assert response1 == response2 239 | 240 | 241 | class IssueGH1348RegressionTest(protocol.BaseTestCase): 242 | """ 243 | The issue: http://github.com/mopidy/mopidy/issues/1348 244 | """ 245 | 246 | def test(self): 247 | self.backend.library.dummy_library = [Track(uri="dummy:a")] 248 | 249 | # Create a dummy playlist and trigger population of mapping 250 | self.send_request('playlistadd "testing1" "dummy:a"') 251 | self.send_request("listplaylists") 252 | 253 | # Create an other playlist which isn't in the map 254 | self.send_request('playlistadd "testing2" "dummy:a"') 255 | assert self.send_request('rm "testing2"') == ["OK"] 256 | 257 | playlists = self.backend.playlists.as_list().get() 258 | assert [ref.name for ref in playlists] == ["testing1"] 259 | -------------------------------------------------------------------------------- /tests/protocol/test_status.py: -------------------------------------------------------------------------------- 1 | from mopidy.models import Album, Track 2 | 3 | from tests import protocol 4 | 5 | 6 | class StatusHandlerTest(protocol.BaseTestCase): 7 | def test_clearerror(self): 8 | self.send_request("clearerror") 9 | self.assertEqualResponse("ACK [0@0] {clearerror} Not implemented") 10 | 11 | def test_currentsong(self): 12 | track = Track(uri="dummy:/a") 13 | self.backend.library.dummy_library = [track] 14 | self.core.tracklist.add(uris=[track.uri]).get() 15 | 16 | self.core.playback.play().get() 17 | self.send_request("currentsong") 18 | self.assertInResponse("file: dummy:/a") 19 | self.assertInResponse("Time: 0") 20 | self.assertNotInResponse("Artist: ") 21 | self.assertNotInResponse("Title: ") 22 | self.assertNotInResponse("Album: ") 23 | self.assertNotInResponse("Track: 0") 24 | self.assertNotInResponse("Date: ") 25 | self.assertInResponse("Pos: 0") 26 | self.assertInResponse("Id: 1") 27 | self.assertInResponse("OK") 28 | 29 | def test_currentsong_unicode(self): 30 | track = Track( 31 | uri="dummy:/à", 32 | name="a nàme", 33 | album=Album(uri="something:àlbum:12345"), 34 | ) 35 | self.backend.library.dummy_library = [track] 36 | self.core.tracklist.add(uris=[track.uri]).get() 37 | 38 | self.core.playback.play().get() 39 | self.send_request("currentsong") 40 | self.assertInResponse("file: dummy:/à") 41 | self.assertInResponse("Title: a nàme") 42 | self.assertInResponse("X-AlbumUri: something:àlbum:12345") 43 | 44 | def test_currentsong_without_song(self): 45 | self.send_request("currentsong") 46 | self.assertInResponse("OK") 47 | 48 | def test_stats_command(self): 49 | self.send_request("stats") 50 | self.assertInResponse("OK") 51 | 52 | def test_status_command(self): 53 | self.send_request("status") 54 | self.assertInResponse("OK") 55 | -------------------------------------------------------------------------------- /tests/protocol/test_stickers.py: -------------------------------------------------------------------------------- 1 | from tests import protocol 2 | 3 | 4 | class StickersHandlerTest(protocol.BaseTestCase): 5 | def test_sticker_get(self): 6 | self.send_request('sticker get "song" "file:///dev/urandom" "a_name"') 7 | self.assertEqualResponse("ACK [0@0] {sticker} Not implemented") 8 | 9 | def test_sticker_set(self): 10 | self.send_request('sticker set "song" "file:///dev/urandom" "a_name" "a_value"') 11 | self.assertEqualResponse("ACK [0@0] {sticker} Not implemented") 12 | 13 | def test_sticker_delete_with_name(self): 14 | self.send_request('sticker delete "song" "file:///dev/urandom" "a_name"') 15 | self.assertEqualResponse("ACK [0@0] {sticker} Not implemented") 16 | 17 | def test_sticker_delete_without_name(self): 18 | self.send_request('sticker delete "song" "file:///dev/urandom"') 19 | self.assertEqualResponse("ACK [0@0] {sticker} Not implemented") 20 | 21 | def test_sticker_list(self): 22 | self.send_request('sticker list "song" "file:///dev/urandom"') 23 | self.assertEqualResponse("ACK [0@0] {sticker} Not implemented") 24 | 25 | def test_sticker_find(self): 26 | self.send_request('sticker find "song" "file:///dev/urandom" "a_name"') 27 | self.assertEqualResponse("ACK [0@0] {sticker} Not implemented") 28 | -------------------------------------------------------------------------------- /tests/test_actor.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | import pytest 4 | 5 | from mopidy_mpd import actor 6 | 7 | # NOTE: Should be kept in sync with all events from mopidy.core.listener 8 | 9 | 10 | @pytest.mark.parametrize( 11 | ("event", "expected"), 12 | [ 13 | (["track_playback_paused", "tl_track", "time_position"], None), 14 | (["track_playback_resumed", "tl_track", "time_position"], None), 15 | (["track_playback_started", "tl_track"], None), 16 | (["track_playback_ended", "tl_track", "time_position"], None), 17 | (["playback_state_changed", "old_state", "new_state"], "player"), 18 | (["tracklist_changed"], "playlist"), 19 | (["playlists_loaded"], "stored_playlist"), 20 | (["playlist_changed", "playlist"], "stored_playlist"), 21 | (["playlist_deleted", "uri"], "stored_playlist"), 22 | (["options_changed"], "options"), 23 | (["volume_changed", "volume"], "mixer"), 24 | (["mute_changed", "mute"], "output"), 25 | (["seeked", "time_position"], "player"), 26 | (["stream_title_changed", "title"], "playlist"), 27 | ], 28 | ) 29 | def test_idle_hooked_up_correctly(event, expected): 30 | config = { 31 | "mpd": { 32 | "hostname": "foobar", 33 | "port": 1234, 34 | "zeroconf": None, 35 | "max_connections": None, 36 | "connection_timeout": None, 37 | } 38 | } 39 | 40 | with mock.patch.object(actor.MpdFrontend, "_setup_server"): 41 | frontend = actor.MpdFrontend(core=mock.Mock(), config=config) 42 | 43 | with mock.patch("mopidy.listener.send") as send_mock: 44 | frontend.on_event(event[0], **{e: None for e in event[1:]}) 45 | 46 | if expected is None: 47 | assert not send_mock.call_args 48 | else: 49 | send_mock.assert_called_once_with(mock.ANY, expected) 50 | -------------------------------------------------------------------------------- /tests/test_context.py: -------------------------------------------------------------------------------- 1 | from typing import cast 2 | 3 | import pykka 4 | import pytest 5 | from mopidy.backend import BackendProxy 6 | from mopidy.core import Core, CoreProxy 7 | from mopidy.models import Ref 8 | 9 | from mopidy_mpd import uri_mapper 10 | from mopidy_mpd.context import MpdContext 11 | from tests import dummy_backend 12 | 13 | 14 | @pytest.fixture 15 | def a_track() -> Ref: 16 | return Ref.track(uri="dummy:/a", name="a") 17 | 18 | 19 | @pytest.fixture 20 | def b_track() -> Ref: 21 | return Ref.track(uri="dummy:/foo/b", name="b") 22 | 23 | 24 | @pytest.fixture 25 | def backend_to_browse(a_track: Ref, b_track: Ref) -> BackendProxy: 26 | backend = cast(BackendProxy, dummy_backend.create_proxy()) 27 | backend.library.dummy_browse_result = { 28 | "dummy:/": [ 29 | a_track, 30 | Ref.directory(uri="dummy:/foo", name="foo"), 31 | ], 32 | "dummy:/foo": [ 33 | b_track, 34 | ], 35 | } 36 | return backend 37 | 38 | 39 | @pytest.fixture 40 | def mpd_context(backend_to_browse: BackendProxy) -> MpdContext: 41 | core = cast( 42 | CoreProxy, 43 | Core.start(config=None, backends=[backend_to_browse]).proxy(), 44 | ) 45 | return MpdContext( 46 | config=None, 47 | core=core, 48 | uri_map=uri_mapper.MpdUriMapper(core), 49 | dispatcher=None, 50 | session=None, 51 | ) 52 | 53 | 54 | class TestMpdContext: 55 | @classmethod 56 | def teardown_class(cls): 57 | pykka.ActorRegistry.stop_all() 58 | 59 | def test_browse_root(self, mpd_context, a_track): 60 | results = mpd_context.browse("dummy", recursive=False, lookup=False) 61 | 62 | assert [("/dummy/a", a_track), ("/dummy/foo", None)] == list(results) 63 | 64 | def test_browse_root_recursive(self, mpd_context, a_track, b_track): 65 | results = mpd_context.browse("dummy", recursive=True, lookup=False) 66 | 67 | assert [ 68 | ("/dummy", None), 69 | ("/dummy/a", a_track), 70 | ("/dummy/foo", None), 71 | ("/dummy/foo/b", b_track), 72 | ] == list(results) 73 | 74 | @pytest.mark.parametrize( 75 | "bad_ref", 76 | [ 77 | Ref.track(uri="dummy:/x"), 78 | Ref.directory(uri="dummy:/y"), 79 | ], 80 | ) 81 | def test_browse_skips_bad_refs( 82 | self, backend_to_browse, a_track, bad_ref, mpd_context 83 | ): 84 | backend_to_browse.library.dummy_browse_result = { 85 | "dummy:/": [bad_ref, a_track], 86 | } 87 | 88 | results = mpd_context.browse("dummy", recursive=False, lookup=False) 89 | 90 | assert [("/dummy/a", a_track)] == list(results) 91 | -------------------------------------------------------------------------------- /tests/test_dispatcher.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from typing import cast 3 | 4 | import pykka 5 | from mopidy.core import Core, CoreProxy 6 | 7 | from mopidy_mpd import uri_mapper 8 | from mopidy_mpd.dispatcher import MpdDispatcher 9 | from mopidy_mpd.exceptions import MpdAckError 10 | from tests import dummy_backend 11 | 12 | 13 | class MpdDispatcherTest(unittest.TestCase): 14 | def setUp(self): 15 | config = {"mpd": {"password": None, "command_blacklist": ["disabled"]}} 16 | self.backend = dummy_backend.create_proxy() 17 | self.core = cast( 18 | CoreProxy, Core.start(config=None, backends=[self.backend]).proxy() 19 | ) 20 | self.dispatcher = MpdDispatcher( 21 | config=config, 22 | core=self.core, 23 | uri_map=uri_mapper.MpdUriMapper(self.core), 24 | session=None, 25 | ) 26 | 27 | def tearDown(self): 28 | pykka.ActorRegistry.stop_all() 29 | 30 | def test_call_handler_for_unknown_command_raises_exception(self): 31 | with self.assertRaises(MpdAckError) as cm: 32 | self.dispatcher._call_handler("an_unknown_command with args") 33 | 34 | assert ( 35 | cm.exception.get_mpd_ack() 36 | == 'ACK [5@0] {} unknown command "an_unknown_command"' 37 | ) 38 | 39 | def test_handling_unknown_request_yields_error(self): 40 | result = self.dispatcher.handle_request("an unhandled request") 41 | assert result[0] == 'ACK [5@0] {} unknown command "an"' 42 | 43 | def test_handling_blacklisted_command(self): 44 | result = self.dispatcher.handle_request("disabled") 45 | assert ( 46 | result[0] 47 | == 'ACK [0@0] {disabled} "disabled" has been disabled in the server' 48 | ) 49 | -------------------------------------------------------------------------------- /tests/test_exceptions.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import pytest 4 | 5 | from mopidy_mpd.exceptions import ( 6 | MpdAckError, 7 | MpdNoCommandError, 8 | MpdNoExistError, 9 | MpdNotImplementedError, 10 | MpdPermissionError, 11 | MpdSystemError, 12 | MpdUnknownCommandError, 13 | ) 14 | 15 | 16 | class MpdExceptionsTest(unittest.TestCase): 17 | def test_mpd_not_implemented_is_a_mpd_ack_error(self): 18 | with pytest.raises(MpdAckError) as exc_info: 19 | raise MpdNotImplementedError 20 | 21 | assert exc_info.value.message == "Not implemented" 22 | 23 | def test_get_mpd_ack_with_default_values(self): 24 | e = MpdAckError("A description") 25 | 26 | assert e.get_mpd_ack() == "ACK [0@0] {None} A description" 27 | 28 | def test_get_mpd_ack_with_values(self): 29 | with pytest.raises(MpdAckError) as exc_info: 30 | raise MpdAckError("A description", index=7, command="foo") 31 | 32 | assert exc_info.value.get_mpd_ack() == "ACK [0@7] {foo} A description" 33 | 34 | def test_mpd_unknown_command(self): 35 | with pytest.raises(MpdAckError) as exc_info: 36 | raise MpdUnknownCommandError(command="play") 37 | 38 | assert exc_info.value.get_mpd_ack() == 'ACK [5@0] {} unknown command "play"' 39 | 40 | def test_mpd_no_command(self): 41 | with pytest.raises(MpdAckError) as exc_info: 42 | raise MpdNoCommandError 43 | 44 | assert exc_info.value.get_mpd_ack() == "ACK [5@0] {} No command given" 45 | 46 | def test_mpd_system_error(self): 47 | with pytest.raises(MpdSystemError) as exc_info: 48 | raise MpdSystemError("foo") 49 | 50 | assert exc_info.value.get_mpd_ack() == "ACK [52@0] {None} foo" 51 | 52 | def test_mpd_permission_error(self): 53 | with pytest.raises(MpdPermissionError) as exc_info: 54 | raise MpdPermissionError(command="foo") 55 | 56 | assert ( 57 | exc_info.value.get_mpd_ack() 58 | == 'ACK [4@0] {foo} you don\'t have permission for "foo"' 59 | ) 60 | 61 | def test_mpd_noexist_error(self): 62 | with pytest.raises(MpdNoExistError) as exc_info: 63 | raise MpdNoExistError(command="foo") 64 | 65 | assert exc_info.value.get_mpd_ack() == "ACK [50@0] {foo} " 66 | -------------------------------------------------------------------------------- /tests/test_extension.py: -------------------------------------------------------------------------------- 1 | from mopidy_mpd import Extension 2 | 3 | 4 | def test_get_default_config() -> None: 5 | ext = Extension() 6 | 7 | config = ext.get_default_config() 8 | 9 | assert "[mpd]" in config 10 | assert "enabled = true" in config 11 | 12 | 13 | def test_get_config_schema() -> None: 14 | ext = Extension() 15 | 16 | schema = ext.get_config_schema() 17 | 18 | assert "hostname" in schema 19 | assert "port" in schema 20 | assert "password" in schema 21 | assert "max_connections" in schema 22 | assert "connection_timeout" in schema 23 | assert "zeroconf" in schema 24 | assert "command_blacklist" in schema 25 | assert "default_playlist_scheme" in schema 26 | -------------------------------------------------------------------------------- /tests/test_path_utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | import unittest 3 | 4 | from tests import path_utils 5 | 6 | 7 | # TODO: kill this in favour of just os.path.getmtime + mocks 8 | class MtimeTest(unittest.TestCase): 9 | def tearDown(self): 10 | path_utils.mtime.undo_fake() 11 | 12 | def test_mtime_of_current_dir(self): 13 | mtime_dir = int(os.stat(".").st_mtime) # noqa: PTH116 14 | assert mtime_dir == path_utils.mtime(".") 15 | 16 | def test_fake_time_is_returned(self): 17 | path_utils.mtime.set_fake_time(123456) 18 | assert path_utils.mtime(".") == 123456 19 | -------------------------------------------------------------------------------- /tests/test_session.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from unittest.mock import Mock, sentinel 3 | 4 | from mopidy_mpd import dispatcher, network, session 5 | 6 | 7 | def test_on_start_logged(caplog): 8 | caplog.set_level(logging.INFO) 9 | connection = Mock(spec=network.Connection) 10 | 11 | session.MpdSession( 12 | config=None, 13 | core=None, 14 | uri_map=None, 15 | connection=connection, 16 | ).on_start() 17 | 18 | assert f"New MPD connection from {connection}" in caplog.text 19 | 20 | 21 | def test_on_line_received_logged(caplog): 22 | caplog.set_level(logging.DEBUG) 23 | connection = Mock(spec=network.Connection) 24 | mpd_session = session.MpdSession( 25 | config=None, 26 | core=None, 27 | uri_map=None, 28 | connection=connection, 29 | ) 30 | mpd_session.dispatcher = Mock(spec=dispatcher.MpdDispatcher) 31 | mpd_session.dispatcher.handle_request.return_value = [str(sentinel.resp)] 32 | 33 | mpd_session.on_line_received("foobar") 34 | 35 | assert f"Request from {connection}: foobar" in caplog.text 36 | assert f"Response to {connection}:" in caplog.text 37 | -------------------------------------------------------------------------------- /tests/test_status.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from typing import cast 3 | 4 | import pykka 5 | from mopidy import core 6 | from mopidy.core import PlaybackState 7 | from mopidy.models import Track 8 | 9 | from mopidy_mpd import dispatcher, uri_mapper 10 | from mopidy_mpd.protocol import status 11 | from tests import dummy_audio, dummy_backend, dummy_mixer 12 | 13 | PAUSED = PlaybackState.PAUSED 14 | PLAYING = PlaybackState.PLAYING 15 | STOPPED = PlaybackState.STOPPED 16 | 17 | # TODO: migrate to using protocol.BaseTestCase instead of status.stats directly? 18 | 19 | 20 | class StatusHandlerTest(unittest.TestCase): 21 | def setUp(self): 22 | config = { 23 | "core": {"max_tracklist_length": 10000}, 24 | "mpd": {"password": None}, 25 | } 26 | 27 | self.audio = dummy_audio.create_proxy() 28 | self.mixer = dummy_mixer.create_proxy() 29 | self.backend = dummy_backend.create_proxy(audio=self.audio) 30 | 31 | self.core = cast( 32 | core.CoreProxy, 33 | core.Core.start( 34 | config, 35 | audio=self.audio, 36 | mixer=self.mixer, 37 | backends=[self.backend], 38 | ).proxy(), 39 | ) 40 | 41 | self.dispatcher = dispatcher.MpdDispatcher( 42 | config=config, 43 | core=self.core, 44 | uri_map=uri_mapper.MpdUriMapper(self.core), 45 | session=None, 46 | ) 47 | self.context = self.dispatcher.context 48 | 49 | def tearDown(self): 50 | pykka.ActorRegistry.stop_all() 51 | 52 | def set_tracklist(self, tracks): 53 | self.backend.library.dummy_library = tracks 54 | self.core.tracklist.add(uris=[track.uri for track in tracks]).get() 55 | 56 | def test_stats_method(self): 57 | result = status.stats(self.context) 58 | assert "artists" in result 59 | assert int(result["artists"]) >= 0 60 | assert "albums" in result 61 | assert int(result["albums"]) >= 0 62 | assert "songs" in result 63 | assert int(result["songs"]) >= 0 64 | assert "uptime" in result 65 | assert int(result["uptime"]) >= 0 66 | assert "db_playtime" in result 67 | assert int(result["db_playtime"]) >= 0 68 | assert "db_update" in result 69 | assert int(result["db_update"]) >= 0 70 | assert "playtime" in result 71 | assert int(result["playtime"]) >= 0 72 | 73 | def test_status_method_contains_volume_with_na_value(self): 74 | result = dict(status.status(self.context)) 75 | assert "volume" in result 76 | assert int(result["volume"]) == (-1) 77 | 78 | def test_status_method_contains_volume(self): 79 | self.core.mixer.set_volume(17) 80 | result = dict(status.status(self.context)) 81 | assert "volume" in result 82 | assert int(result["volume"]) == 17 83 | 84 | def test_status_method_contains_repeat_is_0(self): 85 | result = dict(status.status(self.context)) 86 | assert "repeat" in result 87 | assert int(result["repeat"]) == 0 88 | 89 | def test_status_method_contains_repeat_is_1(self): 90 | self.core.tracklist.set_repeat(True) 91 | result = dict(status.status(self.context)) 92 | assert "repeat" in result 93 | assert int(result["repeat"]) == 1 94 | 95 | def test_status_method_contains_random_is_0(self): 96 | result = dict(status.status(self.context)) 97 | assert "random" in result 98 | assert int(result["random"]) == 0 99 | 100 | def test_status_method_contains_random_is_1(self): 101 | self.core.tracklist.set_random(True) 102 | result = dict(status.status(self.context)) 103 | assert "random" in result 104 | assert int(result["random"]) == 1 105 | 106 | def test_status_method_contains_single(self): 107 | result = dict(status.status(self.context)) 108 | assert "single" in result 109 | assert int(result["single"]) in (0, 1) 110 | 111 | def test_status_method_contains_consume_is_0(self): 112 | result = dict(status.status(self.context)) 113 | assert "consume" in result 114 | assert int(result["consume"]) == 0 115 | 116 | def test_status_method_contains_consume_is_1(self): 117 | self.core.tracklist.set_consume(True) 118 | result = dict(status.status(self.context)) 119 | assert "consume" in result 120 | assert int(result["consume"]) == 1 121 | 122 | def test_status_method_contains_playlist(self): 123 | result = dict(status.status(self.context)) 124 | assert "playlist" in result 125 | assert int(result["playlist"]) >= 0 126 | assert int(result["playlist"]) <= ((2**31) - 1) 127 | 128 | def test_status_method_contains_playlistlength(self): 129 | result = dict(status.status(self.context)) 130 | assert "playlistlength" in result 131 | assert int(result["playlistlength"]) >= 0 132 | 133 | def test_status_method_contains_xfade(self): 134 | result = dict(status.status(self.context)) 135 | assert "xfade" in result 136 | assert int(result["xfade"]) >= 0 137 | 138 | def test_status_method_contains_state_is_play(self): 139 | self.core.playback.set_state(PLAYING) 140 | result = dict(status.status(self.context)) 141 | assert "state" in result 142 | assert result["state"] == "play" 143 | 144 | def test_status_method_contains_state_is_stop(self): 145 | self.core.playback.set_state(STOPPED) 146 | result = dict(status.status(self.context)) 147 | assert "state" in result 148 | assert result["state"] == "stop" 149 | 150 | def test_status_method_contains_state_is_pause(self): 151 | self.core.playback.set_state(PLAYING) 152 | self.core.playback.set_state(PAUSED) 153 | result = dict(status.status(self.context)) 154 | assert "state" in result 155 | assert result["state"] == "pause" 156 | 157 | def test_status_method_when_playlist_loaded_contains_song(self): 158 | self.set_tracklist([Track(uri="dummy:/a")]) 159 | self.core.playback.play().get() 160 | result = dict(status.status(self.context)) 161 | assert "song" in result 162 | assert int(result["song"]) >= 0 163 | 164 | def test_status_method_when_playlist_loaded_contains_tlid_as_songid(self): 165 | self.set_tracklist([Track(uri="dummy:/a")]) 166 | self.core.playback.play().get() 167 | result = dict(status.status(self.context)) 168 | assert "songid" in result 169 | assert int(result["songid"]) == 1 170 | 171 | def test_status_method_when_playlist_loaded_contains_nextsong(self): 172 | self.set_tracklist([Track(uri="dummy:/a"), Track(uri="dummy:/b")]) 173 | self.core.playback.play().get() 174 | result = dict(status.status(self.context)) 175 | assert "nextsong" in result 176 | assert int(result["nextsong"]) >= 0 177 | 178 | def test_status_method_when_playlist_loaded_contains_nextsongid(self): 179 | self.set_tracklist([Track(uri="dummy:/a"), Track(uri="dummy:/b")]) 180 | self.core.playback.play().get() 181 | result = dict(status.status(self.context)) 182 | assert "nextsongid" in result 183 | assert int(result["nextsongid"]) == 2 184 | 185 | def test_status_method_when_playing_contains_time_with_no_length(self): 186 | self.set_tracklist([Track(uri="dummy:/a", length=None)]) 187 | self.core.playback.play().get() 188 | result = dict(status.status(self.context)) 189 | assert "time" in result 190 | (position, total) = result["time"].split(":") 191 | position = int(position) 192 | total = int(total) 193 | assert position <= total 194 | 195 | def test_status_method_when_playing_contains_time_with_length(self): 196 | self.set_tracklist([Track(uri="dummy:/a", length=10000)]) 197 | self.core.playback.play().get() 198 | result = dict(status.status(self.context)) 199 | assert "time" in result 200 | (position, total) = result["time"].split(":") 201 | position = int(position) 202 | total = int(total) 203 | assert position <= total 204 | 205 | def test_status_method_when_playing_contains_elapsed(self): 206 | self.set_tracklist([Track(uri="dummy:/a", length=60000)]) 207 | self.core.playback.play().get() 208 | self.core.playback.pause() 209 | self.core.playback.seek(59123) 210 | result = dict(status.status(self.context)) 211 | assert "elapsed" in result 212 | assert result["elapsed"] == "59.123" 213 | 214 | def test_status_method_when_starting_playing_contains_elapsed_zero(self): 215 | self.set_tracklist([Track(uri="dummy:/a", length=10000)]) 216 | self.core.playback.play().get() 217 | self.core.playback.pause() 218 | result = dict(status.status(self.context)) 219 | assert "elapsed" in result 220 | assert result["elapsed"] == "0.000" 221 | 222 | def test_status_method_when_playing_contains_bitrate(self): 223 | self.set_tracklist([Track(uri="dummy:/a", bitrate=3200)]) 224 | self.core.playback.play().get() 225 | result = dict(status.status(self.context)) 226 | assert "bitrate" in result 227 | assert int(result["bitrate"]) == 3200 228 | -------------------------------------------------------------------------------- /tests/test_tokenizer.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from mopidy_mpd import exceptions, tokenize 4 | 5 | 6 | class TestTokenizer(unittest.TestCase): 7 | def assertTokenizeEquals(self, expected, line): # noqa: N802 8 | assert expected == tokenize.split(line) 9 | 10 | def assertTokenizeRaises(self, exception, message, line): # noqa: N802 11 | with self.assertRaises(exception) as cm: 12 | tokenize.split(line) 13 | assert cm.exception.message == message 14 | 15 | def test_empty_string(self): 16 | ex = exceptions.MpdNoCommandError 17 | msg = "No command given" 18 | self.assertTokenizeRaises(ex, msg, "") 19 | self.assertTokenizeRaises(ex, msg, " ") 20 | self.assertTokenizeRaises(ex, msg, "\t\t\t") 21 | 22 | def test_command(self): 23 | self.assertTokenizeEquals(["test"], "test") 24 | self.assertTokenizeEquals(["test123"], "test123") 25 | self.assertTokenizeEquals(["foo_bar"], "foo_bar") 26 | 27 | def test_command_trailing_whitespace(self): 28 | self.assertTokenizeEquals(["test"], "test ") 29 | self.assertTokenizeEquals(["test"], "test\t\t\t") 30 | 31 | def test_command_leading_whitespace(self): 32 | ex = exceptions.MpdUnknownError 33 | msg = "Letter expected" 34 | self.assertTokenizeRaises(ex, msg, " test") 35 | self.assertTokenizeRaises(ex, msg, "\ttest") 36 | 37 | def test_invalid_command(self): 38 | ex = exceptions.MpdUnknownError 39 | msg = "Invalid word character" 40 | self.assertTokenizeRaises(ex, msg, "foo/bar") 41 | self.assertTokenizeRaises(ex, msg, "æøå") 42 | self.assertTokenizeRaises(ex, msg, "test?") 43 | self.assertTokenizeRaises(ex, msg, 'te"st') 44 | 45 | def test_unquoted_param(self): 46 | self.assertTokenizeEquals(["test", "param"], "test param") 47 | self.assertTokenizeEquals(["test", "param"], "test\tparam") 48 | 49 | def test_unquoted_param_leading_whitespace(self): 50 | self.assertTokenizeEquals(["test", "param"], "test param") 51 | self.assertTokenizeEquals(["test", "param"], "test\t\tparam") 52 | 53 | def test_unquoted_param_trailing_whitespace(self): 54 | self.assertTokenizeEquals(["test", "param"], "test param ") 55 | self.assertTokenizeEquals(["test", "param"], "test param\t\t") 56 | 57 | def test_unquoted_param_invalid_chars(self): 58 | ex = exceptions.MpdArgError 59 | msg = "Invalid unquoted character" 60 | self.assertTokenizeRaises(ex, msg, 'test par"m') 61 | self.assertTokenizeRaises(ex, msg, "test foo\bbar") 62 | self.assertTokenizeRaises(ex, msg, 'test foo"bar"baz') 63 | self.assertTokenizeRaises(ex, msg, "test foo'bar") 64 | 65 | def test_unquoted_param_numbers(self): 66 | self.assertTokenizeEquals(["test", "123"], "test 123") 67 | self.assertTokenizeEquals(["test", "+123"], "test +123") 68 | self.assertTokenizeEquals(["test", "-123"], "test -123") 69 | self.assertTokenizeEquals(["test", "3.14"], "test 3.14") 70 | 71 | def test_unquoted_param_extended_chars(self): 72 | self.assertTokenizeEquals(["test", "æøå"], "test æøå") 73 | self.assertTokenizeEquals(["test", "?#$"], "test ?#$") 74 | self.assertTokenizeEquals(["test", "/foo/bar/"], "test /foo/bar/") 75 | self.assertTokenizeEquals(["test", "foo\\bar"], "test foo\\bar") 76 | 77 | def test_unquoted_params(self): 78 | self.assertTokenizeEquals(["test", "foo", "bar"], "test foo bar") 79 | 80 | def test_quoted_param(self): 81 | self.assertTokenizeEquals(["test", "param"], 'test "param"') 82 | self.assertTokenizeEquals(["test", "param"], 'test\t"param"') 83 | 84 | def test_quoted_param_leading_whitespace(self): 85 | self.assertTokenizeEquals(["test", "param"], 'test "param"') 86 | self.assertTokenizeEquals(["test", "param"], 'test\t\t"param"') 87 | 88 | def test_quoted_param_trailing_whitespace(self): 89 | self.assertTokenizeEquals(["test", "param"], 'test "param" ') 90 | self.assertTokenizeEquals(["test", "param"], 'test "param"\t\t') 91 | 92 | def test_quoted_param_invalid_chars(self): 93 | ex = exceptions.MpdArgError 94 | msg = "Space expected after closing '\"'" 95 | self.assertTokenizeRaises(ex, msg, 'test "foo"bar"') 96 | self.assertTokenizeRaises(ex, msg, 'test "foo"bar" ') 97 | self.assertTokenizeRaises(ex, msg, 'test "foo"bar') 98 | self.assertTokenizeRaises(ex, msg, 'test "foo"bar ') 99 | 100 | def test_quoted_param_numbers(self): 101 | self.assertTokenizeEquals(["test", "123"], 'test "123"') 102 | self.assertTokenizeEquals(["test", "+123"], 'test "+123"') 103 | self.assertTokenizeEquals(["test", "-123"], 'test "-123"') 104 | self.assertTokenizeEquals(["test", "3.14"], 'test "3.14"') 105 | 106 | def test_quoted_param_spaces(self): 107 | self.assertTokenizeEquals(["test", "foo bar"], 'test "foo bar"') 108 | self.assertTokenizeEquals(["test", "foo bar"], 'test "foo bar"') 109 | self.assertTokenizeEquals(["test", " param\t"], 'test " param\t"') 110 | 111 | def test_quoted_param_extended_chars(self): 112 | self.assertTokenizeEquals(["test", "æøå"], 'test "æøå"') 113 | self.assertTokenizeEquals(["test", "?#$"], 'test "?#$"') 114 | self.assertTokenizeEquals(["test", "/foo/bar/"], 'test "/foo/bar/"') 115 | 116 | def test_quoted_param_escaping(self): 117 | self.assertTokenizeEquals(["test", "\\"], r'test "\\"') 118 | self.assertTokenizeEquals(["test", '"'], r'test "\""') 119 | self.assertTokenizeEquals(["test", " "], r'test "\ "') 120 | self.assertTokenizeEquals(["test", "\\n"], r'test "\\\n"') 121 | 122 | def test_quoted_params(self): 123 | self.assertTokenizeEquals(["test", "foo", "bar"], 'test "foo" "bar"') 124 | 125 | def test_mixed_params(self): 126 | self.assertTokenizeEquals(["test", "foo", "bar"], 'test foo "bar"') 127 | self.assertTokenizeEquals(["test", "foo", "bar"], 'test "foo" bar') 128 | self.assertTokenizeEquals(["test", "1", "2"], 'test 1 "2"') 129 | self.assertTokenizeEquals(["test", "1", "2"], 'test "1" 2') 130 | 131 | self.assertTokenizeEquals( 132 | ["test", "foo bar", "baz", "123"], 'test "foo bar" baz 123' 133 | ) 134 | self.assertTokenizeEquals( 135 | ["test", 'foo"bar', "baz", "123"], r'test "foo\"bar" baz 123' 136 | ) 137 | 138 | def test_unbalanced_quotes(self): 139 | ex = exceptions.MpdArgError 140 | msg = "Invalid unquoted character" 141 | self.assertTokenizeRaises(ex, msg, 'test "foo bar" baz"') 142 | 143 | def test_missing_closing_quote(self): 144 | ex = exceptions.MpdArgError 145 | msg = "Missing closing '\"'" 146 | self.assertTokenizeRaises(ex, msg, 'test "foo') 147 | self.assertTokenizeRaises(ex, msg, 'test "foo a ') 148 | -------------------------------------------------------------------------------- /tests/test_translator.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from mopidy.models import Album, Artist, Playlist, TlTrack, Track 4 | 5 | from mopidy_mpd import translator 6 | from mopidy_mpd.protocol import tagtype_list 7 | from tests import path_utils 8 | 9 | 10 | class TrackMpdFormatTest(unittest.TestCase): 11 | track = Track( 12 | uri="à uri", 13 | artists=[Artist(name="an artist"), Artist(name="yet another artist")], 14 | name="a nàme", 15 | album=Album( 16 | name="an album", 17 | num_tracks=13, 18 | artists=[ 19 | Artist(name="an other artist"), 20 | Artist(name="still another artist"), 21 | ], 22 | uri="urischeme:àlbum:12345", 23 | ), 24 | track_no=7, 25 | composers=[Artist(name="a composer"), Artist(name="another composer")], 26 | performers=[ 27 | Artist(name="a performer"), 28 | Artist(name="another performer"), 29 | ], 30 | genre="a genre", 31 | date="1977-01-01", 32 | disc_no=1, 33 | comment="a comment", 34 | length=137000, 35 | ) 36 | 37 | def setUp(self): 38 | self.media_dir = "/dir/subdir" 39 | path_utils.mtime.set_fake_time(1234567) 40 | 41 | def tearDown(self): 42 | path_utils.mtime.undo_fake() 43 | 44 | def test_track_to_mpd_format_for_empty_track(self): 45 | result = translator.track_to_mpd_format( 46 | Track(uri="a uri", length=137000), tagtype_list.TAGTYPE_LIST 47 | ) 48 | assert ("file", "a uri") in result 49 | assert ("Time", 137) in result 50 | assert ("Artist", "") not in result 51 | assert ("Title", "") not in result 52 | assert ("Album", "") not in result 53 | assert ("Track", 0) not in result 54 | assert ("Date", "") not in result 55 | assert len(result) == 2 56 | 57 | def test_track_to_mpd_format_with_position(self): 58 | result = translator.track_to_mpd_format( 59 | Track(), tagtype_list.TAGTYPE_LIST, position=1 60 | ) 61 | assert ("Pos", 1) not in result 62 | 63 | def test_track_to_mpd_format_with_tlid(self): 64 | result = translator.track_to_mpd_format( 65 | TlTrack(1, Track()), tagtype_list.TAGTYPE_LIST 66 | ) 67 | assert ("Id", 1) not in result 68 | 69 | def test_track_to_mpd_format_with_position_and_tlid(self): 70 | result = translator.track_to_mpd_format( 71 | TlTrack(2, Track(uri="a uri")), 72 | tagtype_list.TAGTYPE_LIST, 73 | position=1, 74 | ) 75 | assert ("Pos", 1) in result 76 | assert ("Id", 2) in result 77 | 78 | def test_track_to_mpd_format_for_nonempty_track(self): 79 | result = translator.track_to_mpd_format( 80 | TlTrack(122, self.track), tagtype_list.TAGTYPE_LIST, position=9 81 | ) 82 | assert ("file", "à uri") in result 83 | assert ("Time", 137) in result 84 | assert ("Artist", "an artist") in result 85 | assert ("Artist", "yet another artist") in result 86 | assert ("Title", "a nàme") in result 87 | assert ("Album", "an album") in result 88 | assert ("AlbumArtist", "an other artist") in result 89 | assert ("AlbumArtist", "still another artist") in result 90 | assert ("Composer", "a composer") in result 91 | assert ("Composer", "another composer") in result 92 | assert ("Performer", "a performer") in result 93 | assert ("Performer", "another performer") in result 94 | assert ("Genre", "a genre") in result 95 | assert ("Track", "7/13") in result 96 | assert ("Date", "1977-01-01") in result 97 | assert ("Disc", 1) in result 98 | assert ("Pos", 9) in result 99 | assert ("Id", 122) in result 100 | assert ("X-AlbumUri", "urischeme:àlbum:12345") in result 101 | assert ("Comment", "a comment") not in result 102 | assert len(result) == 19 103 | 104 | def test_track_to_mpd_format_with_last_modified(self): 105 | track = self.track.replace(last_modified=995303899000) 106 | result = translator.track_to_mpd_format(track, tagtype_list.TAGTYPE_LIST) 107 | assert ("Last-Modified", "2001-07-16T17:18:19Z") in result 108 | 109 | def test_track_to_mpd_format_with_last_modified_of_zero(self): 110 | track = self.track.replace(last_modified=0) 111 | result = translator.track_to_mpd_format(track, tagtype_list.TAGTYPE_LIST) 112 | keys = [k for k, v in result] 113 | assert "Last-Modified" not in keys 114 | 115 | def test_track_to_mpd_format_musicbrainz_trackid(self): 116 | track = self.track.replace( 117 | musicbrainz_id="715d581b-ef70-46d5-984b-e2c1d8feb8a0" 118 | ) 119 | result = translator.track_to_mpd_format(track, tagtype_list.TAGTYPE_LIST) 120 | assert ("MUSICBRAINZ_TRACKID", "715d581b-ef70-46d5-984b-e2c1d8feb8a0") in result 121 | 122 | def test_track_to_mpd_format_musicbrainz_albumid(self): 123 | album = self.track.album.replace( 124 | musicbrainz_id="715d581b-ef70-46d5-984b-e2c1d8feb8a0" 125 | ) 126 | track = self.track.replace(album=album) 127 | result = translator.track_to_mpd_format(track, tagtype_list.TAGTYPE_LIST) 128 | assert ("MUSICBRAINZ_ALBUMID", "715d581b-ef70-46d5-984b-e2c1d8feb8a0") in result 129 | 130 | def test_track_to_mpd_format_musicbrainz_albumartistid(self): 131 | artist = next(iter(self.track.artists)).replace( 132 | musicbrainz_id="715d581b-ef70-46d5-984b-e2c1d8feb8a0" 133 | ) 134 | album = self.track.album.replace(artists=[artist]) 135 | track = self.track.replace(album=album) 136 | result = translator.track_to_mpd_format(track, tagtype_list.TAGTYPE_LIST) 137 | assert ( 138 | "MUSICBRAINZ_ALBUMARTISTID", 139 | "715d581b-ef70-46d5-984b-e2c1d8feb8a0", 140 | ) in result 141 | 142 | def test_track_to_mpd_format_musicbrainz_artistid(self): 143 | artist = next(iter(self.track.artists)).replace( 144 | musicbrainz_id="715d581b-ef70-46d5-984b-e2c1d8feb8a0" 145 | ) 146 | track = self.track.replace(artists=[artist]) 147 | result = translator.track_to_mpd_format(track, tagtype_list.TAGTYPE_LIST) 148 | assert ( 149 | "MUSICBRAINZ_ARTISTID", 150 | "715d581b-ef70-46d5-984b-e2c1d8feb8a0", 151 | ) in result 152 | 153 | def test_concat_multi_values(self): 154 | artists = [Artist(name="ABBA"), Artist(name="Beatles")] 155 | translated = translator.concat_multi_values(artists, "name") 156 | assert translated == "ABBA;Beatles" 157 | 158 | def test_concat_multi_values_artist_with_no_name(self): 159 | artists = [Artist(name=None)] 160 | translated = translator.concat_multi_values(artists, "name") 161 | assert translated == "" 162 | 163 | def test_concat_multi_values_artist_with_no_musicbrainz_id(self): 164 | artists = [Artist(name="Jah Wobble")] 165 | translated = translator.concat_multi_values(artists, "musicbrainz_id") 166 | assert translated == "" 167 | 168 | def test_track_to_mpd_format_with_stream_title(self): 169 | result = translator.track_to_mpd_format( 170 | self.track, tagtype_list.TAGTYPE_LIST, stream_title="foo" 171 | ) 172 | assert ("Name", "a nàme") in result 173 | assert ("Title", "foo") in result 174 | 175 | def test_track_to_mpd_format_with_empty_stream_title(self): 176 | result = translator.track_to_mpd_format( 177 | self.track, tagtype_list.TAGTYPE_LIST, stream_title="" 178 | ) 179 | assert ("Name", "a nàme") in result 180 | assert ("Title", "") not in result 181 | 182 | def test_track_to_mpd_format_with_stream_and_no_track_name(self): 183 | track = self.track.replace(name=None) 184 | result = translator.track_to_mpd_format( 185 | track, tagtype_list.TAGTYPE_LIST, stream_title="foo" 186 | ) 187 | assert ("Name", "") not in result 188 | assert ("Title", "foo") in result 189 | 190 | def test_track_to_mpd_client_filtered(self): 191 | configured_tagtypes = [ 192 | "Artist", 193 | "Album", 194 | "Title", 195 | "Track", 196 | "Name", 197 | "Genre", 198 | ] 199 | result = translator.track_to_mpd_format( 200 | TlTrack(122, self.track), configured_tagtypes, position=9 201 | ) 202 | assert ("file", "à uri") in result 203 | assert ("Time", 137) in result 204 | assert ("Artist", "an artist") in result 205 | assert ("Artist", "yet another artist") in result 206 | assert ("Title", "a nàme") in result 207 | assert ("Album", "an album") in result 208 | assert ("Genre", "a genre") in result 209 | assert ("Track", "7/13") in result 210 | assert ("Pos", 9) in result 211 | assert ("Id", 122) in result 212 | assert len(result) == 10 213 | 214 | 215 | class PlaylistMpdFormatTest(unittest.TestCase): 216 | def test_mpd_format(self): 217 | playlist = Playlist( 218 | tracks=[ 219 | Track(uri="foo", track_no=1), 220 | Track(uri="bàr", track_no=2), 221 | Track(uri="baz", track_no=3), 222 | ] 223 | ) 224 | 225 | result = translator.playlist_to_mpd_format(playlist, tagtype_list.TAGTYPE_LIST) 226 | 227 | assert result == [ 228 | ("file", "foo"), 229 | ("Time", 0), 230 | ("Track", 1), 231 | ("file", "bàr"), 232 | ("Time", 0), 233 | ("Track", 2), 234 | ("file", "baz"), 235 | ("Time", 0), 236 | ("Track", 3), 237 | ] 238 | 239 | def test_mpd_format_with_range(self): 240 | playlist = Playlist( 241 | tracks=[ 242 | Track(uri="foo", track_no=1), 243 | Track(uri="bàr", track_no=2), 244 | Track(uri="baz", track_no=3), 245 | ] 246 | ) 247 | 248 | result = translator.playlist_to_mpd_format( 249 | playlist, tagtype_list.TAGTYPE_LIST, start=1, end=2 250 | ) 251 | 252 | assert result == [("file", "bàr"), ("Time", 0), ("Track", 2)] 253 | --------------------------------------------------------------------------------