├── .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 | [](https://pypi.org/p/mopidy-mpd)
4 | [](https://github.com/mopidy/mopidy-mpd/actions/workflows/ci.yml)
5 | [](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 |
--------------------------------------------------------------------------------