././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1607133604.9850466 Mopidy-MPD-3.1.0/0000755000175100001710000000000000000000000012732 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1607133604.9770465 Mopidy-MPD-3.1.0/.circleci/0000755000175100001710000000000000000000000014565 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607133598.0 Mopidy-MPD-3.1.0/.circleci/config.yml0000644000175100001710000000222200000000000016553 0ustar00runnerdockerversion: 2.1 orbs: codecov: codecov/codecov@1.0.5 workflows: version: 2 test: jobs: - py39 - py38 - py37 - black - check-manifest - flake8 jobs: py39: &test-template docker: - image: mopidy/ci-python:3.9 steps: - checkout - restore_cache: name: Restoring tox cache key: tox-v1-{{ .Environment.CIRCLE_JOB }}-{{ checksum "setup.cfg" }} - run: name: Run tests command: | tox -e $CIRCLE_JOB -- \ --junit-xml=test-results/pytest/results.xml \ --cov-report=xml - save_cache: name: Saving tox cache key: tox-v1-{{ .Environment.CIRCLE_JOB }}-{{ checksum "setup.cfg" }} paths: - ./.tox - ~/.cache/pip - codecov/upload: file: coverage.xml - store_test_results: path: test-results py38: <<: *test-template docker: - image: mopidy/ci-python:3.8 py37: <<: *test-template docker: - image: mopidy/ci-python:3.7 black: *test-template check-manifest: *test-template flake8: *test-template ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1607133604.9730465 Mopidy-MPD-3.1.0/.github/0000755000175100001710000000000000000000000014272 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1607133604.9770465 Mopidy-MPD-3.1.0/.github/workflows/0000755000175100001710000000000000000000000016327 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607133598.0 Mopidy-MPD-3.1.0/.github/workflows/release.yml0000644000175100001710000000075700000000000020503 0ustar00runnerdockername: Release on: release: types: [published] jobs: release: runs-on: ubuntu-20.04 steps: - uses: actions/checkout@v2 - uses: actions/setup-python@v2 with: python-version: '3.9' - name: "Install dependencies" run: python3 -m pip install wheel - name: "Build package" run: python3 setup.py sdist bdist_wheel - uses: pypa/gh-action-pypi-publish@v1.4.1 with: user: __token__ password: ${{ secrets.PYPI_TOKEN }} ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607133598.0 Mopidy-MPD-3.1.0/LICENSE0000644000175100001710000002613600000000000013747 0ustar00runnerdocker Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607133598.0 Mopidy-MPD-3.1.0/MANIFEST.in0000644000175100001710000000041200000000000014465 0ustar00runnerdockerinclude *.py include *.rst include .mailmap include LICENSE include MANIFEST.in include pyproject.toml include tox.ini recursive-include .circleci * recursive-include .github * include mopidy_*/ext.conf recursive-include tests *.py recursive-include tests/data * ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1607133604.9770465 Mopidy-MPD-3.1.0/Mopidy_MPD.egg-info/0000755000175100001710000000000000000000000016365 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607133604.0 Mopidy-MPD-3.1.0/Mopidy_MPD.egg-info/PKG-INFO0000644000175100001710000002033200000000000017462 0ustar00runnerdockerMetadata-Version: 2.1 Name: Mopidy-MPD Version: 3.1.0 Summary: Mopidy extension for controlling Mopidy from MPD clients Home-page: https://github.com/mopidy/mopidy-mpd Author: Stein Magnus Jodal Author-email: stein.magnus@jodal.no License: Apache License, Version 2.0 Description: ********** Mopidy-MPD ********** .. image:: https://img.shields.io/pypi/v/Mopidy-MPD :target: https://pypi.org/project/Mopidy-MPD/ :alt: Latest PyPI version .. image:: https://img.shields.io/circleci/build/gh/mopidy/mopidy-mpd :target: https://circleci.com/gh/mopidy/mopidy-mpd :alt: CircleCI build status .. image:: https://img.shields.io/codecov/c/gh/mopidy/mopidy-mpd :target: https://codecov.io/gh/mopidy/mopidy-mpd :alt: Test coverage `Mopidy`_ extension for controlling Mopidy from MPD clients. MPD stands for Music Player Daemon, which is also the name of the `original MPD server project `_. Mopidy does not depend on the original MPD server, but implements the MPD protocol itself, and is thus compatible with most clients built for the original MPD server .. _Mopidy: https://mopidy.com/ Maintainer wanted ================= Mopidy-MPD is currently kept on life support by the Mopidy core developers. It is in need of a more dedicated maintainer. If you want to be the maintainer of Mopidy-Local, please: 1. Make 2-3 good pull requests improving any part of the project. 2. Read and get familiar with all of the project's open issues. 3. Send a pull request removing this section and adding yourself as the "Current maintainer" in the "Credits" section below. In the pull request description, please refer to the previous pull requests and state that you've familiarized yourself with the open issues. As a maintainer, you'll be given push access to the repo and the authority to make releases to PyPI when you see fit. Installation ============ Install by running:: sudo python3 -m pip install Mopidy-MPD See https://mopidy.com/ext/mpd/ for alternative installation methods. Configuration ============= Before starting Mopidy, you must add configuration for Mopidy-MPD to your Mopidy configuration file:: [mpd] hostname = :: .. warning:: As a simple security measure, the MPD server is by default only available from localhost. To make it available from other computers, change the ``mpd/hostname`` config value. Before you do so, note that the MPD server does not support any form of encryption and only a single clear text password (see ``mpd/password``) for weak authentication. Anyone able to access the MPD server can control music playback on your computer. Thus, you probably only want to make the MPD server available from your local network. You have been warned. The following configuration values are available: - ``mpd/enabled``: If the MPD extension should be enabled or not. - ``mpd/hostname``: Which address the MPD server should bind to. This can be a network address or the path toa Unix socket: - ``127.0.0.1``: Listens only on the IPv4 loopback interface (default). - ``::1``: Listens only on the IPv6 loopback interface. - ``0.0.0.0``: Listens on all IPv4 interfaces. - ``::``: Listens on all interfaces, both IPv4 and IPv6. - ``unix:/path/to/unix/socket.sock``: Listen on the Unix socket at the specified path. Must be prefixed with ``unix:``. - ``mpd/port``: Which TCP port the MPD server should listen to. Default: 6600. - ``mpd/password``: The password required for connecting to the MPD server. If blank, no password is required. Default: blank. - ``mpd/max_connections``: The maximum number of concurrent connections the MPD server will accept. Default: 20. - ``mpd/connection_timeout``: Number of seconds an MPD client can stay inactive before the connection is closed by the server. Default: 60. - ``mpd/zeroconf``: Name of the MPD service when published through Zeroconf. The variables ``$hostname`` and ``$port`` can be used in the name. Set to an empty string to disable Zeroconf for MPD. Default: ``Mopidy MPD server on $hostname`` - ``mpd/command_blacklist``: List of MPD commands which are disabled by the server. By default this blacklists ``listall`` and ``listallinfo``. These commands don't fit well with many of Mopidy's backends and are better left disabled unless you know what youare doing. - ``mpd/default_playlist_scheme``: The URI scheme used if the server cannot find a backend appropriate for creating a playlist from the given tracks. Default: ``m3u`` Limitations =========== This is a non-exhaustive list of MPD features that Mopidy doesn't support. - Only a single password is supported. It gives all-or-nothing access. - Toggling of audio outputs is not supported. - Channels for client-to-client communication are not supported. - Stickers are not supported. - Crossfade is not supported. - Replay gain is not supported. - ``stats`` does not provide any statistics. - ``decoders`` does not provide information about available decoders. - ``tagtypes`` is not supported. - Live update of the music database is not supported. Clients ======= Over the years, a huge number of MPD clients have been built for every thinkable platform. As always, the quality and state of maintenance varies between clients, so you might have to try a couple before you find one you like for your purpose. In general, they should all work with Mopidy-MPD. The `Wikipedia article on MPD `_ has a short list of well-known clients. In the MPD wiki there is a `more complete list `_ of the available MPD clients. Both lists are grouped by user interface, e.g. terminal, graphical, or web-based. Project resources ================= - `Source code `_ - `Issue tracker `_ - `Changelog `_ Credits ======= - Original authors: `Stein Magnus Jodal `__ and `Thomas Adamcik `__ for the Mopidy-MPD extension in Mopidy core. - Current maintainer: None. Maintainer wanted, see section above. - `Contributors `_ Platform: UNKNOWN Classifier: Environment :: No Input/Output (Daemon) Classifier: Intended Audience :: End Users/Desktop Classifier: License :: OSI Approved :: Apache Software License Classifier: Operating System :: OS Independent Classifier: Programming Language :: Python :: 3 Classifier: Programming Language :: Python :: 3.7 Classifier: Programming Language :: Python :: 3.8 Classifier: Programming Language :: Python :: 3.9 Classifier: Topic :: Multimedia :: Sound/Audio :: Players Requires-Python: >=3.7 Provides-Extra: lint Provides-Extra: release Provides-Extra: test Provides-Extra: dev ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607133604.0 Mopidy-MPD-3.1.0/Mopidy_MPD.egg-info/SOURCES.txt0000644000175100001710000000410200000000000020246 0ustar00runnerdockerLICENSE MANIFEST.in README.rst pyproject.toml setup.cfg setup.py tox.ini .circleci/config.yml .github/workflows/release.yml Mopidy_MPD.egg-info/PKG-INFO Mopidy_MPD.egg-info/SOURCES.txt Mopidy_MPD.egg-info/dependency_links.txt Mopidy_MPD.egg-info/entry_points.txt Mopidy_MPD.egg-info/not-zip-safe Mopidy_MPD.egg-info/requires.txt Mopidy_MPD.egg-info/top_level.txt mopidy_mpd/__init__.py mopidy_mpd/actor.py mopidy_mpd/dispatcher.py mopidy_mpd/exceptions.py mopidy_mpd/ext.conf mopidy_mpd/formatting.py mopidy_mpd/network.py mopidy_mpd/session.py mopidy_mpd/tokenize.py mopidy_mpd/translator.py mopidy_mpd/uri_mapper.py mopidy_mpd/protocol/__init__.py mopidy_mpd/protocol/audio_output.py mopidy_mpd/protocol/channels.py mopidy_mpd/protocol/command_list.py mopidy_mpd/protocol/connection.py mopidy_mpd/protocol/current_playlist.py mopidy_mpd/protocol/mount.py mopidy_mpd/protocol/music_db.py mopidy_mpd/protocol/playback.py mopidy_mpd/protocol/reflection.py mopidy_mpd/protocol/status.py mopidy_mpd/protocol/stickers.py mopidy_mpd/protocol/stored_playlists.py mopidy_mpd/protocol/tagtype_list.py tests/__init__.py tests/dummy_audio.py tests/dummy_backend.py tests/dummy_mixer.py tests/path_utils.py tests/test_actor.py tests/test_commands.py tests/test_dispatcher.py tests/test_exceptions.py tests/test_extension.py tests/test_path_utils.py tests/test_session.py tests/test_status.py tests/test_tokenizer.py tests/test_translator.py tests/network/__init__.py tests/network/test_connection.py tests/network/test_lineprotocol.py tests/network/test_server.py tests/network/test_utils.py tests/protocol/__init__.py tests/protocol/test_audio_output.py tests/protocol/test_authentication.py tests/protocol/test_channels.py tests/protocol/test_command_list.py tests/protocol/test_connection.py tests/protocol/test_current_playlist.py tests/protocol/test_idle.py tests/protocol/test_mount.py tests/protocol/test_music_db.py tests/protocol/test_playback.py tests/protocol/test_reflection.py tests/protocol/test_regression.py tests/protocol/test_status.py tests/protocol/test_stickers.py tests/protocol/test_stored_playlists.py././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607133604.0 Mopidy-MPD-3.1.0/Mopidy_MPD.egg-info/dependency_links.txt0000644000175100001710000000000100000000000022433 0ustar00runnerdocker ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607133604.0 Mopidy-MPD-3.1.0/Mopidy_MPD.egg-info/entry_points.txt0000644000175100001710000000005100000000000021657 0ustar00runnerdocker[mopidy.ext] mpd = mopidy_mpd:Extension ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607133604.0 Mopidy-MPD-3.1.0/Mopidy_MPD.egg-info/not-zip-safe0000644000175100001710000000000100000000000020613 0ustar00runnerdocker ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607133604.0 Mopidy-MPD-3.1.0/Mopidy_MPD.egg-info/requires.txt0000644000175100001710000000044400000000000020767 0ustar00runnerdockerMopidy>=3.0.0 Pykka>=2.0.1 setuptools [dev] black check-manifest flake8 flake8-bugbear flake8-import-order isort[pyproject] twine wheel pytest pytest-cov [lint] black check-manifest flake8 flake8-bugbear flake8-import-order isort[pyproject] [release] twine wheel [test] pytest pytest-cov ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607133604.0 Mopidy-MPD-3.1.0/Mopidy_MPD.egg-info/top_level.txt0000644000175100001710000000001300000000000021111 0ustar00runnerdockermopidy_mpd ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1607133604.9850466 Mopidy-MPD-3.1.0/PKG-INFO0000644000175100001710000002033200000000000014027 0ustar00runnerdockerMetadata-Version: 2.1 Name: Mopidy-MPD Version: 3.1.0 Summary: Mopidy extension for controlling Mopidy from MPD clients Home-page: https://github.com/mopidy/mopidy-mpd Author: Stein Magnus Jodal Author-email: stein.magnus@jodal.no License: Apache License, Version 2.0 Description: ********** Mopidy-MPD ********** .. image:: https://img.shields.io/pypi/v/Mopidy-MPD :target: https://pypi.org/project/Mopidy-MPD/ :alt: Latest PyPI version .. image:: https://img.shields.io/circleci/build/gh/mopidy/mopidy-mpd :target: https://circleci.com/gh/mopidy/mopidy-mpd :alt: CircleCI build status .. image:: https://img.shields.io/codecov/c/gh/mopidy/mopidy-mpd :target: https://codecov.io/gh/mopidy/mopidy-mpd :alt: Test coverage `Mopidy`_ extension for controlling Mopidy from MPD clients. MPD stands for Music Player Daemon, which is also the name of the `original MPD server project `_. Mopidy does not depend on the original MPD server, but implements the MPD protocol itself, and is thus compatible with most clients built for the original MPD server .. _Mopidy: https://mopidy.com/ Maintainer wanted ================= Mopidy-MPD is currently kept on life support by the Mopidy core developers. It is in need of a more dedicated maintainer. If you want to be the maintainer of Mopidy-Local, please: 1. Make 2-3 good pull requests improving any part of the project. 2. Read and get familiar with all of the project's open issues. 3. Send a pull request removing this section and adding yourself as the "Current maintainer" in the "Credits" section below. In the pull request description, please refer to the previous pull requests and state that you've familiarized yourself with the open issues. As a maintainer, you'll be given push access to the repo and the authority to make releases to PyPI when you see fit. Installation ============ Install by running:: sudo python3 -m pip install Mopidy-MPD See https://mopidy.com/ext/mpd/ for alternative installation methods. Configuration ============= Before starting Mopidy, you must add configuration for Mopidy-MPD to your Mopidy configuration file:: [mpd] hostname = :: .. warning:: As a simple security measure, the MPD server is by default only available from localhost. To make it available from other computers, change the ``mpd/hostname`` config value. Before you do so, note that the MPD server does not support any form of encryption and only a single clear text password (see ``mpd/password``) for weak authentication. Anyone able to access the MPD server can control music playback on your computer. Thus, you probably only want to make the MPD server available from your local network. You have been warned. The following configuration values are available: - ``mpd/enabled``: If the MPD extension should be enabled or not. - ``mpd/hostname``: Which address the MPD server should bind to. This can be a network address or the path toa Unix socket: - ``127.0.0.1``: Listens only on the IPv4 loopback interface (default). - ``::1``: Listens only on the IPv6 loopback interface. - ``0.0.0.0``: Listens on all IPv4 interfaces. - ``::``: Listens on all interfaces, both IPv4 and IPv6. - ``unix:/path/to/unix/socket.sock``: Listen on the Unix socket at the specified path. Must be prefixed with ``unix:``. - ``mpd/port``: Which TCP port the MPD server should listen to. Default: 6600. - ``mpd/password``: The password required for connecting to the MPD server. If blank, no password is required. Default: blank. - ``mpd/max_connections``: The maximum number of concurrent connections the MPD server will accept. Default: 20. - ``mpd/connection_timeout``: Number of seconds an MPD client can stay inactive before the connection is closed by the server. Default: 60. - ``mpd/zeroconf``: Name of the MPD service when published through Zeroconf. The variables ``$hostname`` and ``$port`` can be used in the name. Set to an empty string to disable Zeroconf for MPD. Default: ``Mopidy MPD server on $hostname`` - ``mpd/command_blacklist``: List of MPD commands which are disabled by the server. By default this blacklists ``listall`` and ``listallinfo``. These commands don't fit well with many of Mopidy's backends and are better left disabled unless you know what youare doing. - ``mpd/default_playlist_scheme``: The URI scheme used if the server cannot find a backend appropriate for creating a playlist from the given tracks. Default: ``m3u`` Limitations =========== This is a non-exhaustive list of MPD features that Mopidy doesn't support. - Only a single password is supported. It gives all-or-nothing access. - Toggling of audio outputs is not supported. - Channels for client-to-client communication are not supported. - Stickers are not supported. - Crossfade is not supported. - Replay gain is not supported. - ``stats`` does not provide any statistics. - ``decoders`` does not provide information about available decoders. - ``tagtypes`` is not supported. - Live update of the music database is not supported. Clients ======= Over the years, a huge number of MPD clients have been built for every thinkable platform. As always, the quality and state of maintenance varies between clients, so you might have to try a couple before you find one you like for your purpose. In general, they should all work with Mopidy-MPD. The `Wikipedia article on MPD `_ has a short list of well-known clients. In the MPD wiki there is a `more complete list `_ of the available MPD clients. Both lists are grouped by user interface, e.g. terminal, graphical, or web-based. Project resources ================= - `Source code `_ - `Issue tracker `_ - `Changelog `_ Credits ======= - Original authors: `Stein Magnus Jodal `__ and `Thomas Adamcik `__ for the Mopidy-MPD extension in Mopidy core. - Current maintainer: None. Maintainer wanted, see section above. - `Contributors `_ Platform: UNKNOWN Classifier: Environment :: No Input/Output (Daemon) Classifier: Intended Audience :: End Users/Desktop Classifier: License :: OSI Approved :: Apache Software License Classifier: Operating System :: OS Independent Classifier: Programming Language :: Python :: 3 Classifier: Programming Language :: Python :: 3.7 Classifier: Programming Language :: Python :: 3.8 Classifier: Programming Language :: Python :: 3.9 Classifier: Topic :: Multimedia :: Sound/Audio :: Players Requires-Python: >=3.7 Provides-Extra: lint Provides-Extra: release Provides-Extra: test Provides-Extra: dev ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607133598.0 Mopidy-MPD-3.1.0/README.rst0000644000175100001710000001372400000000000014430 0ustar00runnerdocker********** Mopidy-MPD ********** .. image:: https://img.shields.io/pypi/v/Mopidy-MPD :target: https://pypi.org/project/Mopidy-MPD/ :alt: Latest PyPI version .. image:: https://img.shields.io/circleci/build/gh/mopidy/mopidy-mpd :target: https://circleci.com/gh/mopidy/mopidy-mpd :alt: CircleCI build status .. image:: https://img.shields.io/codecov/c/gh/mopidy/mopidy-mpd :target: https://codecov.io/gh/mopidy/mopidy-mpd :alt: Test coverage `Mopidy`_ extension for controlling Mopidy from MPD clients. MPD stands for Music Player Daemon, which is also the name of the `original MPD server project `_. Mopidy does not depend on the original MPD server, but implements the MPD protocol itself, and is thus compatible with most clients built for the original MPD server .. _Mopidy: https://mopidy.com/ Maintainer wanted ================= Mopidy-MPD is currently kept on life support by the Mopidy core developers. It is in need of a more dedicated maintainer. If you want to be the maintainer of Mopidy-Local, please: 1. Make 2-3 good pull requests improving any part of the project. 2. Read and get familiar with all of the project's open issues. 3. Send a pull request removing this section and adding yourself as the "Current maintainer" in the "Credits" section below. In the pull request description, please refer to the previous pull requests and state that you've familiarized yourself with the open issues. As a maintainer, you'll be given push access to the repo and the authority to make releases to PyPI when you see fit. Installation ============ Install by running:: sudo python3 -m pip install Mopidy-MPD See https://mopidy.com/ext/mpd/ for alternative installation methods. Configuration ============= Before starting Mopidy, you must add configuration for Mopidy-MPD to your Mopidy configuration file:: [mpd] hostname = :: .. warning:: As a simple security measure, the MPD server is by default only available from localhost. To make it available from other computers, change the ``mpd/hostname`` config value. Before you do so, note that the MPD server does not support any form of encryption and only a single clear text password (see ``mpd/password``) for weak authentication. Anyone able to access the MPD server can control music playback on your computer. Thus, you probably only want to make the MPD server available from your local network. You have been warned. The following configuration values are available: - ``mpd/enabled``: If the MPD extension should be enabled or not. - ``mpd/hostname``: Which address the MPD server should bind to. This can be a network address or the path toa Unix socket: - ``127.0.0.1``: Listens only on the IPv4 loopback interface (default). - ``::1``: Listens only on the IPv6 loopback interface. - ``0.0.0.0``: Listens on all IPv4 interfaces. - ``::``: Listens on all interfaces, both IPv4 and IPv6. - ``unix:/path/to/unix/socket.sock``: Listen on the Unix socket at the specified path. Must be prefixed with ``unix:``. - ``mpd/port``: Which TCP port the MPD server should listen to. Default: 6600. - ``mpd/password``: The password required for connecting to the MPD server. If blank, no password is required. Default: blank. - ``mpd/max_connections``: The maximum number of concurrent connections the MPD server will accept. Default: 20. - ``mpd/connection_timeout``: Number of seconds an MPD client can stay inactive before the connection is closed by the server. Default: 60. - ``mpd/zeroconf``: Name of the MPD service when published through Zeroconf. The variables ``$hostname`` and ``$port`` can be used in the name. Set to an empty string to disable Zeroconf for MPD. Default: ``Mopidy MPD server on $hostname`` - ``mpd/command_blacklist``: List of MPD commands which are disabled by the server. By default this blacklists ``listall`` and ``listallinfo``. These commands don't fit well with many of Mopidy's backends and are better left disabled unless you know what youare doing. - ``mpd/default_playlist_scheme``: The URI scheme used if the server cannot find a backend appropriate for creating a playlist from the given tracks. Default: ``m3u`` Limitations =========== This is a non-exhaustive list of MPD features that Mopidy doesn't support. - Only a single password is supported. It gives all-or-nothing access. - Toggling of audio outputs is not supported. - Channels for client-to-client communication are not supported. - Stickers are not supported. - Crossfade is not supported. - Replay gain is not supported. - ``stats`` does not provide any statistics. - ``decoders`` does not provide information about available decoders. - ``tagtypes`` is not supported. - Live update of the music database is not supported. Clients ======= Over the years, a huge number of MPD clients have been built for every thinkable platform. As always, the quality and state of maintenance varies between clients, so you might have to try a couple before you find one you like for your purpose. In general, they should all work with Mopidy-MPD. The `Wikipedia article on MPD `_ has a short list of well-known clients. In the MPD wiki there is a `more complete list `_ of the available MPD clients. Both lists are grouped by user interface, e.g. terminal, graphical, or web-based. Project resources ================= - `Source code `_ - `Issue tracker `_ - `Changelog `_ Credits ======= - Original authors: `Stein Magnus Jodal `__ and `Thomas Adamcik `__ for the Mopidy-MPD extension in Mopidy core. - Current maintainer: None. Maintainer wanted, see section above. - `Contributors `_ ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1607133604.9810467 Mopidy-MPD-3.1.0/mopidy_mpd/0000755000175100001710000000000000000000000015073 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607133598.0 Mopidy-MPD-3.1.0/mopidy_mpd/__init__.py0000644000175100001710000000202000000000000017176 0ustar00runnerdockerimport pathlib import pkg_resources from mopidy import config, ext __version__ = pkg_resources.get_distribution("Mopidy-MPD").version class Extension(ext.Extension): dist_name = "Mopidy-MPD" ext_name = "mpd" version = __version__ def get_default_config(self): return config.read(pathlib.Path(__file__).parent / "ext.conf") def get_config_schema(self): schema = super().get_config_schema() schema["hostname"] = config.Hostname() schema["port"] = config.Port(optional=True) schema["password"] = config.Secret(optional=True) schema["max_connections"] = config.Integer(minimum=1) schema["connection_timeout"] = config.Integer(minimum=1) schema["zeroconf"] = config.String(optional=True) schema["command_blacklist"] = config.List(optional=True) schema["default_playlist_scheme"] = config.String() return schema def setup(self, registry): from .actor import MpdFrontend registry.add("frontend", MpdFrontend) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607133598.0 Mopidy-MPD-3.1.0/mopidy_mpd/actor.py0000644000175100001710000000563200000000000016563 0ustar00runnerdockerimport logging import pykka from mopidy import exceptions, listener, zeroconf from mopidy.core import CoreListener from mopidy_mpd import network, session, uri_mapper logger = logging.getLogger(__name__) _CORE_EVENTS_TO_IDLE_SUBSYSTEMS = { "track_playback_paused": None, "track_playback_resumed": None, "track_playback_started": None, "track_playback_ended": None, "playback_state_changed": "player", "tracklist_changed": "playlist", "playlists_loaded": "stored_playlist", "playlist_changed": "stored_playlist", "playlist_deleted": "stored_playlist", "options_changed": "options", "volume_changed": "mixer", "mute_changed": "output", "seeked": "player", "stream_title_changed": "playlist", } class MpdFrontend(pykka.ThreadingActor, CoreListener): def __init__(self, config, core): super().__init__() self.hostname = network.format_hostname(config["mpd"]["hostname"]) self.port = config["mpd"]["port"] self.uri_map = uri_mapper.MpdUriMapper(core) self.zeroconf_name = config["mpd"]["zeroconf"] self.zeroconf_service = None self.server = self._setup_server(config, core) def _setup_server(self, config, core): try: server = network.Server( self.hostname, self.port, protocol=session.MpdSession, protocol_kwargs={ "config": config, "core": core, "uri_map": self.uri_map, }, max_connections=config["mpd"]["max_connections"], timeout=config["mpd"]["connection_timeout"], ) except OSError as exc: raise exceptions.FrontendError(f"MPD server startup failed: {exc}") logger.info( f"MPD server running at {network.format_address(server.address)}" ) return server def on_start(self): if self.zeroconf_name and not network.is_unix_socket( self.server.server_socket ): self.zeroconf_service = zeroconf.Zeroconf( name=self.zeroconf_name, stype="_mpd._tcp", port=self.port ) self.zeroconf_service.publish() def on_stop(self): if self.zeroconf_service: self.zeroconf_service.unpublish() session_actors = pykka.ActorRegistry.get_by_class(session.MpdSession) for session_actor in session_actors: session_actor.stop() self.server.stop() def on_event(self, event, **kwargs): if event not in _CORE_EVENTS_TO_IDLE_SUBSYSTEMS: logger.warning( "Got unexpected event: %s(%s)", event, ", ".join(kwargs) ) else: self.send_idle(_CORE_EVENTS_TO_IDLE_SUBSYSTEMS[event]) def send_idle(self, subsystem): if subsystem: listener.send(session.MpdSession, subsystem) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607133598.0 Mopidy-MPD-3.1.0/mopidy_mpd/dispatcher.py0000644000175100001710000002576500000000000017612 0ustar00runnerdockerimport logging import re import pykka from mopidy_mpd import exceptions, protocol, tokenize logger = logging.getLogger(__name__) protocol.load_protocol_modules() class MpdDispatcher: """ The MPD session feeds the MPD dispatcher with requests. The dispatcher finds the correct handler, processes the request and sends the response back to the MPD session. """ _noidle = re.compile(r"^noidle$") def __init__(self, session=None, config=None, core=None, uri_map=None): self.config = config self.authenticated = False self.command_list_receiving = False self.command_list_ok = False self.command_list = [] self.command_list_index = None self.context = MpdContext( self, session=session, config=config, core=core, uri_map=uri_map ) def handle_request(self, request, current_command_list_index=None): """Dispatch incoming requests to the correct handler.""" self.command_list_index = current_command_list_index response = [] filter_chain = [ self._catch_mpd_ack_errors_filter, self._authenticate_filter, self._command_list_filter, self._idle_filter, self._add_ok_filter, self._call_handler_filter, ] return self._call_next_filter(request, response, filter_chain) def handle_idle(self, subsystem): # TODO: validate against mopidy_mpd/protocol/status.SUBSYSTEMS self.context.events.add(subsystem) subsystems = self.context.subscriptions.intersection( self.context.events ) if not subsystems: return response = [] for subsystem in subsystems: response.append(f"changed: {subsystem}") response.append("OK") self.context.subscriptions = set() self.context.events = set() self.context.session.send_lines(response) def _call_next_filter(self, request, response, filter_chain): if filter_chain: next_filter = filter_chain.pop(0) return next_filter(request, response, filter_chain) else: return response # Filter: catch MPD ACK errors def _catch_mpd_ack_errors_filter(self, request, response, filter_chain): try: return self._call_next_filter(request, response, filter_chain) except exceptions.MpdAckError as mpd_ack_error: if self.command_list_index is not None: mpd_ack_error.index = self.command_list_index return [mpd_ack_error.get_mpd_ack()] # Filter: authenticate def _authenticate_filter(self, request, response, filter_chain): if self.authenticated: return self._call_next_filter(request, response, filter_chain) elif self.config["mpd"]["password"] is None: self.authenticated = True return self._call_next_filter(request, response, filter_chain) else: command_name = request.split(" ")[0] command = protocol.commands.handlers.get(command_name) if command and not command.auth_required: return self._call_next_filter(request, response, filter_chain) else: raise exceptions.MpdPermissionError(command=command_name) # Filter: command list def _command_list_filter(self, request, response, filter_chain): if self._is_receiving_command_list(request): self.command_list.append(request) return [] else: response = self._call_next_filter(request, response, filter_chain) if self._is_receiving_command_list( request ) or self._is_processing_command_list(request): if response and response[-1] == "OK": response = response[:-1] return response def _is_receiving_command_list(self, request): return self.command_list_receiving and request != "command_list_end" def _is_processing_command_list(self, request): return ( self.command_list_index is not None and request != "command_list_end" ) # Filter: idle def _idle_filter(self, request, response, filter_chain): if self._is_currently_idle() and not self._noidle.match(request): logger.debug( "Client sent us %s, only %s is allowed while in " "the idle state", repr(request), repr("noidle"), ) self.context.session.close() return [] if not self._is_currently_idle() and self._noidle.match(request): return [] # noidle was called before idle response = self._call_next_filter(request, response, filter_chain) if self._is_currently_idle(): return [] else: return response def _is_currently_idle(self): return bool(self.context.subscriptions) # Filter: add OK def _add_ok_filter(self, request, response, filter_chain): response = self._call_next_filter(request, response, filter_chain) if not self._has_error(response): response.append("OK") return response def _has_error(self, response): return response and response[-1].startswith("ACK") # Filter: call handler def _call_handler_filter(self, request, response, filter_chain): try: response = self._format_response(self._call_handler(request)) return self._call_next_filter(request, response, filter_chain) except pykka.ActorDeadError as e: logger.warning("Tried to communicate with dead actor.") raise exceptions.MpdSystemError(e) def _call_handler(self, request): tokens = tokenize.split(request) # TODO: check that blacklist items are valid commands? blacklist = self.config["mpd"].get("command_blacklist", []) if tokens and tokens[0] in blacklist: logger.warning("MPD client used blacklisted command: %s", tokens[0]) raise exceptions.MpdDisabled(command=tokens[0]) try: return protocol.commands.call(tokens, context=self.context) except exceptions.MpdAckError as exc: if exc.command is None: exc.command = tokens[0] raise def _format_response(self, response): formatted_response = [] for element in self._listify_result(response): formatted_response.extend(self._format_lines(element)) return formatted_response def _listify_result(self, result): if result is None: return [] if isinstance(result, set): return self._flatten(list(result)) if not isinstance(result, list): return [result] return self._flatten(result) def _flatten(self, the_list): result = [] for element in the_list: if isinstance(element, list): result.extend(self._flatten(element)) else: result.append(element) return result def _format_lines(self, line): if isinstance(line, dict): return [f"{key}: {value}" for (key, value) in line.items()] if isinstance(line, tuple): (key, value) = line return [f"{key}: {value}"] return [line] class MpdContext: """ This object is passed as the first argument to all MPD command handlers to give the command handlers access to important parts of Mopidy. """ #: The current :class:`MpdDispatcher`. dispatcher = None #: The current :class:`mopidy_mpd.MpdSession`. session = None #: The MPD password password = None #: The Mopidy core API. An instance of :class:`mopidy.core.Core`. core = None #: The active subsystems that have pending events. events = None #: The subsytems that we want to be notified about in idle mode. subscriptions = None _uri_map = None def __init__( self, dispatcher, session=None, config=None, core=None, uri_map=None ): self.dispatcher = dispatcher self.session = session if config is not None: self.password = config["mpd"]["password"] self.core = core self.events = set() self.subscriptions = set() self._uri_map = uri_map def lookup_playlist_uri_from_name(self, name): """ Helper function to retrieve a playlist from its unique MPD name. """ return self._uri_map.playlist_uri_from_name(name) def lookup_playlist_name_from_uri(self, uri): """ Helper function to retrieve the unique MPD playlist name from its uri. """ return self._uri_map.playlist_name_from_uri(uri) def browse(self, path, recursive=True, lookup=True): """ Browse the contents of a given directory path. Returns a sequence of two-tuples ``(path, data)``. If ``recursive`` is true, it returns results for all entries in the given path. If ``lookup`` is true and the ``path`` is to a track, the returned ``data`` is a future which will contain the results from looking up the URI with :meth:`mopidy.core.LibraryController.lookup`. If ``lookup`` is false and the ``path`` is to a track, the returned ``data`` will be a :class:`mopidy.models.Ref` for the track. For all entries that are not tracks, the returned ``data`` will be :class:`None`. """ path_parts = re.findall(r"[^/]+", path or "") root_path = "/".join([""] + path_parts) uri = self._uri_map.uri_from_name(root_path) if uri is None: for part in path_parts: for ref in self.core.library.browse(uri).get(): if ref.type != ref.TRACK and ref.name == part: uri = ref.uri break else: raise exceptions.MpdNoExistError("Not found") root_path = self._uri_map.insert(root_path, uri) if recursive: yield (root_path, None) path_and_futures = [(root_path, self.core.library.browse(uri))] while path_and_futures: base_path, future = path_and_futures.pop() for ref in future.get(): if ref.name is None or ref.uri is None: continue path = "/".join([base_path, ref.name.replace("/", "")]) path = self._uri_map.insert(path, ref.uri) if ref.type == ref.TRACK: if lookup: # TODO: can we lookup all the refs at once now? yield (path, self.core.library.lookup(uris=[ref.uri])) else: yield (path, ref) else: yield (path, None) if recursive: path_and_futures.append( (path, self.core.library.browse(ref.uri)) ) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607133598.0 Mopidy-MPD-3.1.0/mopidy_mpd/exceptions.py0000644000175100001710000000756000000000000017636 0ustar00runnerdockerfrom mopidy.exceptions import MopidyException class MpdAckError(MopidyException): """See fields on this class for available MPD error codes""" ACK_ERROR_NOT_LIST = 1 ACK_ERROR_ARG = 2 ACK_ERROR_PASSWORD = 3 ACK_ERROR_PERMISSION = 4 ACK_ERROR_UNKNOWN = 5 ACK_ERROR_NO_EXIST = 50 ACK_ERROR_PLAYLIST_MAX = 51 ACK_ERROR_SYSTEM = 52 ACK_ERROR_PLAYLIST_LOAD = 53 ACK_ERROR_UPDATE_ALREADY = 54 ACK_ERROR_PLAYER_SYNC = 55 ACK_ERROR_EXIST = 56 error_code = 0 def __init__(self, message="", index=0, command=None): super().__init__(message, index, command) self.message = message self.index = index self.command = command def get_mpd_ack(self): """ MPD error code format:: ACK [%(error_code)i@%(index)i] {%(command)s} description """ return ( f"ACK [{self.__class__.error_code:d}@{self.index:d}] " f"{{{self.command}}} {self.message}" ) class MpdArgError(MpdAckError): error_code = MpdAckError.ACK_ERROR_ARG class MpdPasswordError(MpdAckError): error_code = MpdAckError.ACK_ERROR_PASSWORD class MpdPermissionError(MpdAckError): error_code = MpdAckError.ACK_ERROR_PERMISSION def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) assert self.command is not None, "command must be given explicitly" self.message = f'you don\'t have permission for "{self.command}"' class MpdUnknownError(MpdAckError): error_code = MpdAckError.ACK_ERROR_UNKNOWN class MpdUnknownCommand(MpdUnknownError): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) assert self.command is not None, "command must be given explicitly" self.message = f'unknown command "{self.command}"' self.command = "" class MpdNoCommand(MpdUnknownCommand): def __init__(self, *args, **kwargs): kwargs["command"] = "" super().__init__(*args, **kwargs) self.message = "No command given" class MpdNoExistError(MpdAckError): error_code = MpdAckError.ACK_ERROR_NO_EXIST class MpdExistError(MpdAckError): error_code = MpdAckError.ACK_ERROR_EXIST class MpdSystemError(MpdAckError): error_code = MpdAckError.ACK_ERROR_SYSTEM class MpdInvalidPlaylistName(MpdAckError): error_code = MpdAckError.ACK_ERROR_ARG def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.message = ( "playlist name is invalid: playlist names may not " "contain slashes, newlines or carriage returns" ) class MpdNotImplemented(MpdAckError): error_code = 0 def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.message = "Not implemented" class MpdInvalidTrackForPlaylist(MpdAckError): # NOTE: This is a custom error for Mopidy that does not exist in MPD. error_code = 0 def __init__(self, playlist_scheme, track_scheme, *args, **kwargs): super().__init__(*args, **kwargs) self.message = ( f'Playlist with scheme "{playlist_scheme}" ' f'can\'t store track scheme "{track_scheme}"' ) class MpdFailedToSavePlaylist(MpdAckError): # NOTE: This is a custom error for Mopidy that does not exist in MPD. error_code = 0 def __init__(self, backend_scheme, *args, **kwargs): super().__init__(*args, **kwargs) self.message = ( f'Backend with scheme "{backend_scheme}" failed to save playlist' ) class MpdDisabled(MpdAckError): # NOTE: This is a custom error for Mopidy that does not exist in MPD. error_code = 0 def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) assert self.command is not None, "command must be given explicitly" self.message = f'"{self.command}" has been disabled in the server' ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607133598.0 Mopidy-MPD-3.1.0/mopidy_mpd/ext.conf0000644000175100001710000000033600000000000016544 0ustar00runnerdocker[mpd] enabled = true hostname = 127.0.0.1 port = 6600 password = max_connections = 20 connection_timeout = 60 zeroconf = Mopidy MPD server on $hostname command_blacklist = listall,listallinfo default_playlist_scheme = m3u ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607133598.0 Mopidy-MPD-3.1.0/mopidy_mpd/formatting.py0000644000175100001710000000053000000000000017615 0ustar00runnerdockerdef indent(string, places=4, linebreak="\n", singles=False): lines = string.split(linebreak) if not singles and len(lines) == 1: return string for i, line in enumerate(lines): lines[i] = " " * places + line result = linebreak.join(lines) if not singles: result = linebreak + result return result ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607133598.0 Mopidy-MPD-3.1.0/mopidy_mpd/network.py0000644000175100001710000003507500000000000017150 0ustar00runnerdockerimport errno import logging import os import re import socket import sys import threading import pykka from gi.repository import GLib logger = logging.getLogger(__name__) def get_unix_socket_path(socket_path): match = re.search("^unix:(.*)", socket_path) if not match: return None return match.group(1) def is_unix_socket(sock): """Check if the provided socket is a Unix domain socket""" if hasattr(socket, "AF_UNIX"): return sock.family == socket.AF_UNIX return False def get_socket_address(host, port): unix_socket_path = get_unix_socket_path(host) if unix_socket_path is not None: return (unix_socket_path, None) else: return (host, port) class ShouldRetrySocketCall(Exception): """Indicate that attempted socket call should be retried""" def try_ipv6_socket(): """Determine if system really supports IPv6""" if not socket.has_ipv6: return False try: socket.socket(socket.AF_INET6).close() return True except OSError as exc: logger.debug( f"Platform supports IPv6, but socket creation failed, " f"disabling: {exc}" ) return False #: Boolean value that indicates if creating an IPv6 socket will succeed. has_ipv6 = try_ipv6_socket() def create_tcp_socket(): """Create a TCP socket with or without IPv6 depending on system support""" if has_ipv6: sock = socket.socket(socket.AF_INET6, socket.SOCK_STREAM) # Explicitly configure socket to work for both IPv4 and IPv6 if hasattr(socket, "IPPROTO_IPV6"): sock.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0) elif sys.platform == "win32": # also match 64bit windows. # Python 2.7 on windows does not have the IPPROTO_IPV6 constant # Use values extracted from Windows Vista/7/8's header sock.setsockopt(41, 27, 0) else: sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) return sock def create_unix_socket(): """Create a Unix domain socket""" return socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) def format_address(address): """Format socket address for display.""" host, port = address[:2] if port is not None: return f"[{host}]:{port}" else: return f"[{host}]" def format_hostname(hostname): """Format hostname for display.""" if has_ipv6 and re.match(r"\d+.\d+.\d+.\d+", hostname) is not None: hostname = f"::ffff:{hostname}" return hostname class Server: """Setup listener and register it with GLib's event loop.""" def __init__( self, host, port, protocol, protocol_kwargs=None, max_connections=5, timeout=30, ): self.protocol = protocol self.protocol_kwargs = protocol_kwargs or {} self.max_connections = max_connections self.timeout = timeout self.server_socket = self.create_server_socket(host, port) self.address = get_socket_address(host, port) self.watcher = self.register_server_socket(self.server_socket.fileno()) def create_server_socket(self, host, port): socket_path = get_unix_socket_path(host) if socket_path is not None: # host is a path so use unix socket sock = create_unix_socket() sock.bind(socket_path) else: # ensure the port is supplied if not isinstance(port, int): raise TypeError(f"Expected an integer, not {port!r}") sock = create_tcp_socket() sock.bind((host, port)) sock.setblocking(False) sock.listen(1) return sock def stop(self): GLib.source_remove(self.watcher) if is_unix_socket(self.server_socket): unix_socket_path = self.server_socket.getsockname() else: unix_socket_path = None self.server_socket.shutdown(socket.SHUT_RDWR) self.server_socket.close() # clean up the socket file if unix_socket_path is not None: os.unlink(unix_socket_path) def register_server_socket(self, fileno): return GLib.io_add_watch(fileno, GLib.IO_IN, self.handle_connection) def handle_connection(self, fd, flags): try: sock, addr = self.accept_connection() except ShouldRetrySocketCall: return True if self.maximum_connections_exceeded(): self.reject_connection(sock, addr) else: self.init_connection(sock, addr) return True def accept_connection(self): try: sock, addr = self.server_socket.accept() if is_unix_socket(sock): addr = (sock.getsockname(), None) return sock, addr except OSError as e: if e.errno in (errno.EAGAIN, errno.EINTR): raise ShouldRetrySocketCall raise def maximum_connections_exceeded(self): return ( self.max_connections is not None and self.number_of_connections() >= self.max_connections ) def number_of_connections(self): return len(pykka.ActorRegistry.get_by_class(self.protocol)) def reject_connection(self, sock, addr): # FIXME provide more context in logging? logger.warning("Rejected connection from %s", format_address(addr)) try: sock.close() except OSError: pass def init_connection(self, sock, addr): Connection( self.protocol, self.protocol_kwargs, sock, addr, self.timeout ) class Connection: # NOTE: the callback code is _not_ run in the actor's thread, but in the # same one as the event loop. If code in the callbacks blocks, the rest of # GLib code will likely be blocked as well... # # Also note that source_remove() return values are ignored on purpose, a # false return value would only tell us that what we thought was registered # is already gone, there is really nothing more we can do. def __init__(self, protocol, protocol_kwargs, sock, addr, timeout): sock.setblocking(False) self.host, self.port = addr[:2] # IPv6 has larger addr self._sock = sock self.protocol = protocol self.protocol_kwargs = protocol_kwargs self.timeout = timeout self.send_lock = threading.Lock() self.send_buffer = b"" self.stopping = False self.recv_id = None self.send_id = None self.timeout_id = None self.actor_ref = self.protocol.start(self, **self.protocol_kwargs) self.enable_recv() self.enable_timeout() def stop(self, reason, level=logging.DEBUG): if self.stopping: logger.log(level, f"Already stopping: {reason}") return else: self.stopping = True logger.log(level, reason) try: self.actor_ref.stop(block=False) except pykka.ActorDeadError: pass self.disable_timeout() self.disable_recv() self.disable_send() try: self._sock.close() except OSError: pass def queue_send(self, data): """Try to send data to client exactly as is and queue rest.""" self.send_lock.acquire(True) self.send_buffer = self.send(self.send_buffer + data) self.send_lock.release() if self.send_buffer: self.enable_send() def send(self, data): """Send data to client, return any unsent data.""" try: sent = self._sock.send(data) return data[sent:] except OSError as exc: if exc.errno in (errno.EWOULDBLOCK, errno.EINTR): return data self.stop(f"Unexpected client error: {exc}") return b"" def enable_timeout(self): """Reactivate timeout mechanism.""" if self.timeout is None or self.timeout <= 0: return self.disable_timeout() self.timeout_id = GLib.timeout_add_seconds( self.timeout, self.timeout_callback ) def disable_timeout(self): """Deactivate timeout mechanism.""" if self.timeout_id is None: return GLib.source_remove(self.timeout_id) self.timeout_id = None def enable_recv(self): if self.recv_id is not None: return try: self.recv_id = GLib.io_add_watch( self._sock.fileno(), GLib.IO_IN | GLib.IO_ERR | GLib.IO_HUP, self.recv_callback, ) except OSError as exc: self.stop(f"Problem with connection: {exc}") def disable_recv(self): if self.recv_id is None: return GLib.source_remove(self.recv_id) self.recv_id = None def enable_send(self): if self.send_id is not None: return try: self.send_id = GLib.io_add_watch( self._sock.fileno(), GLib.IO_OUT | GLib.IO_ERR | GLib.IO_HUP, self.send_callback, ) except OSError as exc: self.stop(f"Problem with connection: {exc}") def disable_send(self): if self.send_id is None: return GLib.source_remove(self.send_id) self.send_id = None def recv_callback(self, fd, flags): if flags & (GLib.IO_ERR | GLib.IO_HUP): self.stop(f"Bad client flags: {flags}") return True try: data = self._sock.recv(4096) except OSError as exc: if exc.errno not in (errno.EWOULDBLOCK, errno.EINTR): self.stop(f"Unexpected client error: {exc}") return True if not data: self.disable_recv() self.actor_ref.tell({"close": True}) return True try: self.actor_ref.tell({"received": data}) except pykka.ActorDeadError: self.stop("Actor is dead.") return True def send_callback(self, fd, flags): if flags & (GLib.IO_ERR | GLib.IO_HUP): self.stop(f"Bad client flags: {flags}") return True # If with can't get the lock, simply try again next time socket is # ready for sending. if not self.send_lock.acquire(False): return True try: self.send_buffer = self.send(self.send_buffer) if not self.send_buffer: self.disable_send() finally: self.send_lock.release() return True def timeout_callback(self): self.stop(f"Client inactive for {self.timeout:d}s; closing connection") return False def __str__(self): return format_address((self.host, self.port)) class LineProtocol(pykka.ThreadingActor): """ Base class for handling line based protocols. Takes care of receiving new data from server's client code, decoding and then splitting data along line boundaries. """ #: Line terminator to use for outputed lines. terminator = b"\n" #: Regex to use for spliting lines, will be set compiled version of its #: own value, or to ``terminator``s value if it is not set itself. delimiter = None #: What encoding to expect incoming data to be in, can be :class:`None`. encoding = "utf-8" def __init__(self, connection): super().__init__() self.connection = connection self.prevent_timeout = False self.recv_buffer = b"" if self.delimiter: self.delimiter = re.compile(self.delimiter) else: self.delimiter = re.compile(self.terminator) @property def host(self): return self.connection.host @property def port(self): return self.connection.port def on_line_received(self, line): """ Called whenever a new line is found. Should be implemented by subclasses. """ raise NotImplementedError def on_receive(self, message): """Handle messages with new data from server.""" if "close" in message: self.connection.stop("Client most likely disconnected.") return if "received" not in message: return self.connection.disable_timeout() self.recv_buffer += message["received"] for line in self.parse_lines(): line = self.decode(line) if line is not None: self.on_line_received(line) if not self.prevent_timeout: self.connection.enable_timeout() def on_failure(self, exception_type, exception_value, traceback): """Clean up connection resouces when actor fails.""" self.connection.stop("Actor failed.") def on_stop(self): """Clean up connection resouces when actor stops.""" self.connection.stop("Actor is shutting down.") def parse_lines(self): """Consume new data and yield any lines found.""" while re.search(self.terminator, self.recv_buffer): line, self.recv_buffer = self.delimiter.split(self.recv_buffer, 1) yield line def encode(self, line): """ Handle encoding of line. Can be overridden by subclasses to change encoding behaviour. """ try: return line.encode(self.encoding) except UnicodeError: logger.warning( "Stopping actor due to encode problem, data " "supplied by client was not valid %s", self.encoding, ) self.stop() def decode(self, line): """ Handle decoding of line. Can be overridden by subclasses to change decoding behaviour. """ try: return line.decode(self.encoding) except UnicodeError: logger.warning( "Stopping actor due to decode problem, data " "supplied by client was not valid %s", self.encoding, ) self.stop() def join_lines(self, lines): if not lines: return "" line_terminator = self.decode(self.terminator) return line_terminator.join(lines) + line_terminator def send_lines(self, lines): """ Send array of lines to client via connection. Join lines using the terminator that is set for this class, encode it and send it to the client. """ if not lines: return data = self.join_lines(lines) self.connection.queue_send(self.encode(data)) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1607133604.9810467 Mopidy-MPD-3.1.0/mopidy_mpd/protocol/0000755000175100001710000000000000000000000016734 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607133598.0 Mopidy-MPD-3.1.0/mopidy_mpd/protocol/__init__.py0000644000175100001710000001563700000000000021061 0ustar00runnerdocker""" This is Mopidy's MPD protocol implementation. This is partly based upon the `MPD protocol documentation `_, which is a useful resource, but it is rather incomplete with regards to data formats, both for requests and responses. Thus, we have had to talk a great deal with the the original `MPD server `_ using telnet to get the details we need to implement our own MPD server which is compatible with the numerous existing `MPD clients `_. """ import inspect from mopidy_mpd import exceptions #: The MPD protocol uses UTF-8 for encoding all data. ENCODING = "UTF-8" #: The MPD protocol uses ``\n`` as line terminator. LINE_TERMINATOR = b"\n" #: The MPD protocol version is 0.19.0. VERSION = "0.19.0" def load_protocol_modules(): """ The protocol modules must be imported to get them registered in :attr:`commands`. """ from . import ( # noqa audio_output, channels, command_list, connection, current_playlist, mount, music_db, playback, reflection, status, stickers, stored_playlists, ) def INT(value): # noqa: N802 r"""Converts a value that matches [+-]?\d+ into an integer.""" if value is None: raise ValueError("None is not a valid integer") # TODO: check for whitespace via value != value.strip()? return int(value) def UINT(value): # noqa: N802 r"""Converts a value that matches \d+ into an integer.""" if value is None: raise ValueError("None is not a valid integer") if not value.isdigit(): raise ValueError("Only positive numbers are allowed") return int(value) def FLOAT(value): # noqa: N802 r"""Converts a value that matches [+-]\d+(.\d+)? into a float.""" if value is None: raise ValueError("None is not a valid float") return float(value) def UFLOAT(value): # noqa: N802 r"""Converts a value that matches \d+(.\d+)? into a float.""" if value is None: raise ValueError("None is not a valid float") value = float(value) if value < 0: raise ValueError("Only positive numbers are allowed") return value def BOOL(value): # noqa: N802 """Convert the values 0 and 1 into booleans.""" if value in ("1", "0"): return bool(int(value)) raise ValueError(f"{value!r} is not 0 or 1") def RANGE(value): # noqa: N802 """Convert a single integer or range spec into a slice ``n`` should become ``slice(n, n+1)`` ``n:`` should become ``slice(n, None)`` ``n:m`` should become ``slice(n, m)`` and ``m > n`` must hold """ if ":" in value: start, stop = value.split(":", 1) start = UINT(start) if stop.strip(): stop = UINT(stop) if start >= stop: raise ValueError("End must be larger than start") else: stop = None else: start = UINT(value) stop = start + 1 return slice(start, stop) class Commands: """Collection of MPD commands to expose to users. Normally used through the global instance which command handlers have been installed into. """ def __init__(self): self.handlers = {} # TODO: consider removing auth_required and list_command in favour of # additional command instances to register in? def add(self, name, auth_required=True, list_command=True, **validators): """Create a decorator that registers a handler and validation rules. Additional keyword arguments are treated as converters/validators to apply to tokens converting them to proper Python types. Requirements for valid handlers: - must accept a context argument as the first arg. - may not use variable keyword arguments, ``**kwargs``. - may use variable arguments ``*args`` *or* a mix of required and optional arguments. Decorator returns the unwrapped function so that tests etc can use the functions with values with correct python types instead of strings. :param string name: Name of the command being registered. :param bool auth_required: If authorization is required. :param bool list_command: If command should be listed in reflection. """ def wrapper(func): if name in self.handlers: raise ValueError(f"{name} already registered") spec = inspect.getfullargspec(func) defaults = dict( zip(spec.args[-len(spec.defaults or []) :], spec.defaults or []) ) if not spec.args and not spec.varargs: raise TypeError("Handler must accept at least one argument.") if len(spec.args) > 1 and spec.varargs: raise TypeError( "*args may not be combined with regular arguments" ) if not set(validators.keys()).issubset(spec.args): raise TypeError("Validator for non-existent arg passed") if spec.varkw or spec.kwonlyargs: raise TypeError("Keyword arguments are not permitted") def validate(*args, **kwargs): if spec.varargs: return func(*args, **kwargs) try: ba = inspect.signature(func).bind(*args, **kwargs) ba.apply_defaults() callargs = ba.arguments except TypeError: raise exceptions.MpdArgError( f'wrong number of arguments for "{name}"' ) for key, value in callargs.items(): default = defaults.get(key, object()) if key in validators and value != default: try: callargs[key] = validators[key](value) except ValueError: raise exceptions.MpdArgError("incorrect arguments") return func(**callargs) validate.auth_required = auth_required validate.list_command = list_command self.handlers[name] = validate return func return wrapper def call(self, tokens, context=None): """Find and run the handler registered for the given command. If the handler was registered with any converters/validators they will be run before calling the real handler. :param list tokens: List of tokens to process :param context: MPD context. :type context: :class:`~mopidy_mpd.dispatcher.MpdContext` """ if not tokens: raise exceptions.MpdNoCommand() if tokens[0] not in self.handlers: raise exceptions.MpdUnknownCommand(command=tokens[0]) return self.handlers[tokens[0]](context, *tokens[1:]) #: Global instance to install commands into commands = Commands() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607133598.0 Mopidy-MPD-3.1.0/mopidy_mpd/protocol/audio_output.py0000644000175100001710000000357200000000000022036 0ustar00runnerdockerfrom mopidy_mpd import exceptions, protocol @protocol.commands.add("disableoutput", outputid=protocol.UINT) def disableoutput(context, outputid): """ *musicpd.org, audio output section:* ``disableoutput {ID}`` Turns an output off. """ if outputid == 0: success = context.core.mixer.set_mute(False).get() if not success: raise exceptions.MpdSystemError("problems disabling output") else: raise exceptions.MpdNoExistError("No such audio output") @protocol.commands.add("enableoutput", outputid=protocol.UINT) def enableoutput(context, outputid): """ *musicpd.org, audio output section:* ``enableoutput {ID}`` Turns an output on. """ if outputid == 0: success = context.core.mixer.set_mute(True).get() if not success: raise exceptions.MpdSystemError("problems enabling output") else: raise exceptions.MpdNoExistError("No such audio output") @protocol.commands.add("toggleoutput", outputid=protocol.UINT) def toggleoutput(context, outputid): """ *musicpd.org, audio output section:* ``toggleoutput {ID}`` Turns an output on or off, depending on the current state. """ if outputid == 0: mute_status = context.core.mixer.get_mute().get() success = context.core.mixer.set_mute(not mute_status) if not success: raise exceptions.MpdSystemError("problems toggling output") else: raise exceptions.MpdNoExistError("No such audio output") @protocol.commands.add("outputs") def outputs(context): """ *musicpd.org, audio output section:* ``outputs`` Shows information about all outputs. """ muted = 1 if context.core.mixer.get_mute().get() else 0 return [ ("outputid", 0), ("outputname", "Mute"), ("outputenabled", muted), ] ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607133598.0 Mopidy-MPD-3.1.0/mopidy_mpd/protocol/channels.py0000644000175100001710000000327500000000000021110 0ustar00runnerdockerfrom mopidy_mpd import exceptions, protocol @protocol.commands.add("subscribe") def subscribe(context, channel): """ *musicpd.org, client to client section:* ``subscribe {NAME}`` Subscribe to a channel. The channel is created if it does not exist already. The name may consist of alphanumeric ASCII characters plus underscore, dash, dot and colon. """ # TODO: match channel against [A-Za-z0-9:._-]+ raise exceptions.MpdNotImplemented # TODO @protocol.commands.add("unsubscribe") def unsubscribe(context, channel): """ *musicpd.org, client to client section:* ``unsubscribe {NAME}`` Unsubscribe from a channel. """ # TODO: match channel against [A-Za-z0-9:._-]+ raise exceptions.MpdNotImplemented # TODO @protocol.commands.add("channels") def channels(context): """ *musicpd.org, client to client section:* ``channels`` Obtain a list of all channels. The response is a list of "channel:" lines. """ raise exceptions.MpdNotImplemented # TODO @protocol.commands.add("readmessages") def readmessages(context): """ *musicpd.org, client to client section:* ``readmessages`` Reads messages for this client. The response is a list of "channel:" and "message:" lines. """ raise exceptions.MpdNotImplemented # TODO @protocol.commands.add("sendmessage") def sendmessage(context, channel, text): """ *musicpd.org, client to client section:* ``sendmessage {CHANNEL} {TEXT}`` Send a message to the specified channel. """ # TODO: match channel against [A-Za-z0-9:._-]+ raise exceptions.MpdNotImplemented # TODO ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607133598.0 Mopidy-MPD-3.1.0/mopidy_mpd/protocol/command_list.py0000644000175100001710000000461000000000000021760 0ustar00runnerdockerfrom mopidy_mpd import exceptions, protocol @protocol.commands.add("command_list_begin", list_command=False) def command_list_begin(context): """ *musicpd.org, command list section:* To facilitate faster adding of files etc. you can pass a list of commands all at once using a command list. The command list begins with ``command_list_begin`` or ``command_list_ok_begin`` and ends with ``command_list_end``. It does not execute any commands until the list has ended. The return value is whatever the return for a list of commands is. On success for all commands, ``OK`` is returned. If a command fails, no more commands are executed and the appropriate ``ACK`` error is returned. If ``command_list_ok_begin`` is used, ``list_OK`` is returned for each successful command executed in the command list. """ context.dispatcher.command_list_receiving = True context.dispatcher.command_list_ok = False context.dispatcher.command_list = [] @protocol.commands.add("command_list_end", list_command=False) def command_list_end(context): """See :meth:`command_list_begin()`.""" # TODO: batch consecutive add commands if not context.dispatcher.command_list_receiving: raise exceptions.MpdUnknownCommand(command="command_list_end") context.dispatcher.command_list_receiving = False (command_list, context.dispatcher.command_list) = ( context.dispatcher.command_list, [], ) (command_list_ok, context.dispatcher.command_list_ok) = ( context.dispatcher.command_list_ok, False, ) command_list_response = [] for index, command in enumerate(command_list): response = context.dispatcher.handle_request( command, current_command_list_index=index ) command_list_response.extend(response) if command_list_response and command_list_response[-1].startswith( "ACK" ): return command_list_response if command_list_ok: command_list_response.append("list_OK") return command_list_response @protocol.commands.add("command_list_ok_begin", list_command=False) def command_list_ok_begin(context): """See :meth:`command_list_begin()`.""" context.dispatcher.command_list_receiving = True context.dispatcher.command_list_ok = True context.dispatcher.command_list = [] ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607133598.0 Mopidy-MPD-3.1.0/mopidy_mpd/protocol/connection.py0000644000175100001710000000215000000000000021443 0ustar00runnerdockerfrom mopidy_mpd import exceptions, protocol @protocol.commands.add("close", auth_required=False) def close(context): """ *musicpd.org, connection section:* ``close`` Closes the connection to MPD. """ context.session.close() @protocol.commands.add("kill", list_command=False) def kill(context): """ *musicpd.org, connection section:* ``kill`` Kills MPD. """ raise exceptions.MpdPermissionError(command="kill") @protocol.commands.add("password", auth_required=False) def password(context, password): """ *musicpd.org, connection section:* ``password {PASSWORD}`` This is used for authentication with the server. ``PASSWORD`` is simply the plaintext password. """ if password == context.password: context.dispatcher.authenticated = True else: raise exceptions.MpdPasswordError("incorrect password") @protocol.commands.add("ping", auth_required=False) def ping(context): """ *musicpd.org, connection section:* ``ping`` Does nothing but return ``OK``. """ pass ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607133598.0 Mopidy-MPD-3.1.0/mopidy_mpd/protocol/current_playlist.py0000644000175100001710000003320000000000000022707 0ustar00runnerdockerimport urllib from mopidy_mpd import exceptions, protocol, translator @protocol.commands.add("add") def add(context, uri): """ *musicpd.org, current playlist section:* ``add {URI}`` Adds the file ``URI`` to the playlist (directories add recursively). ``URI`` can also be a single file. *Clarifications:* - ``add ""`` should add all tracks in the library to the current playlist. """ if not uri.strip("/"): return # If we have an URI just try and add it directly without bothering with # jumping through browse... if urllib.parse.urlparse(uri).scheme != "": if context.core.tracklist.add(uris=[uri]).get(): return try: uris = [] for _path, ref in context.browse(uri, lookup=False): if ref: uris.append(ref.uri) except exceptions.MpdNoExistError as exc: exc.message = ( # noqa B306: Our own exception "directory or file not found" ) raise if not uris: raise exceptions.MpdNoExistError("directory or file not found") context.core.tracklist.add(uris=uris).get() @protocol.commands.add("addid", songpos=protocol.UINT) def addid(context, uri, songpos=None): """ *musicpd.org, current playlist section:* ``addid {URI} [POSITION]`` Adds a song to the playlist (non-recursive) and returns the song id. ``URI`` is always a single file or URL. For example:: addid "foo.mp3" Id: 999 OK *Clarifications:* - ``addid ""`` should return an error. """ if not uri: raise exceptions.MpdNoExistError("No such song") length = context.core.tracklist.get_length() if songpos is not None and songpos > length.get(): raise exceptions.MpdArgError("Bad song index") tl_tracks = context.core.tracklist.add( uris=[uri], at_position=songpos ).get() if not tl_tracks: raise exceptions.MpdNoExistError("No such song") return ("Id", tl_tracks[0].tlid) @protocol.commands.add("delete", songrange=protocol.RANGE) def delete(context, songrange): """ *musicpd.org, current playlist section:* ``delete [{POS} | {START:END}]`` Deletes a song from the playlist. """ start = songrange.start end = songrange.stop if end is None: end = context.core.tracklist.get_length().get() tl_tracks = context.core.tracklist.slice(start, end).get() if not tl_tracks: raise exceptions.MpdArgError("Bad song index", command="delete") for (tlid, _) in tl_tracks: context.core.tracklist.remove({"tlid": [tlid]}) @protocol.commands.add("deleteid", tlid=protocol.UINT) def deleteid(context, tlid): """ *musicpd.org, current playlist section:* ``deleteid {SONGID}`` Deletes the song ``SONGID`` from the playlist """ tl_tracks = context.core.tracklist.remove({"tlid": [tlid]}).get() if not tl_tracks: raise exceptions.MpdNoExistError("No such song") @protocol.commands.add("clear") def clear(context): """ *musicpd.org, current playlist section:* ``clear`` Clears the current playlist. """ context.core.tracklist.clear() @protocol.commands.add("move", songrange=protocol.RANGE, to=protocol.UINT) def move_range(context, songrange, to): """ *musicpd.org, current playlist section:* ``move [{FROM} | {START:END}] {TO}`` Moves the song at ``FROM`` or range of songs at ``START:END`` to ``TO`` in the playlist. """ start = songrange.start end = songrange.stop if end is None: end = context.core.tracklist.get_length().get() context.core.tracklist.move(start, end, to) @protocol.commands.add("moveid", tlid=protocol.UINT, to=protocol.UINT) def moveid(context, tlid, to): """ *musicpd.org, current playlist section:* ``moveid {FROM} {TO}`` Moves the song with ``FROM`` (songid) to ``TO`` (playlist index) in the playlist. If ``TO`` is negative, it is relative to the current song in the playlist (if there is one). """ tl_tracks = context.core.tracklist.filter({"tlid": [tlid]}).get() if not tl_tracks: raise exceptions.MpdNoExistError("No such song") position = context.core.tracklist.index(tl_tracks[0]).get() context.core.tracklist.move(position, position + 1, to) @protocol.commands.add("playlist") def playlist(context): """ *musicpd.org, current playlist section:* ``playlist`` Displays the current playlist. .. note:: Do not use this, instead use ``playlistinfo``. """ return playlistinfo(context) @protocol.commands.add("playlistfind") def playlistfind(context, tag, needle): """ *musicpd.org, current playlist section:* ``playlistfind {TAG} {NEEDLE}`` Finds songs in the current playlist with strict matching. """ if tag == "filename": tl_tracks = context.core.tracklist.filter({"uri": [needle]}).get() if not tl_tracks: return None position = context.core.tracklist.index(tl_tracks[0]).get() return translator.track_to_mpd_format(tl_tracks[0], position=position) raise exceptions.MpdNotImplemented # TODO @protocol.commands.add("playlistid", tlid=protocol.UINT) def playlistid(context, tlid=None): """ *musicpd.org, current playlist section:* ``playlistid {SONGID}`` Displays a list of songs in the playlist. ``SONGID`` is optional and specifies a single song to display info for. """ if tlid is not None: tl_tracks = context.core.tracklist.filter({"tlid": [tlid]}).get() if not tl_tracks: raise exceptions.MpdNoExistError("No such song") position = context.core.tracklist.index(tl_tracks[0]).get() return translator.track_to_mpd_format(tl_tracks[0], position=position) else: return translator.tracks_to_mpd_format( context.core.tracklist.get_tl_tracks().get() ) @protocol.commands.add("playlistinfo") def playlistinfo(context, parameter=None): """ *musicpd.org, current playlist section:* ``playlistinfo [[SONGPOS] | [START:END]]`` Displays a list of all songs in the playlist, or if the optional argument is given, displays information only for the song ``SONGPOS`` or the range of songs ``START:END``. *ncmpc and mpc:* - uses negative indexes, like ``playlistinfo "-1"``, to request the entire playlist """ if parameter is None or parameter == "-1": start, end = 0, None else: tracklist_slice = protocol.RANGE(parameter) start, end = tracklist_slice.start, tracklist_slice.stop tl_tracks = context.core.tracklist.get_tl_tracks().get() if start and start > len(tl_tracks): raise exceptions.MpdArgError("Bad song index") if end and end > len(tl_tracks): end = None return translator.tracks_to_mpd_format(tl_tracks, start, end) @protocol.commands.add("playlistsearch") def playlistsearch(context, tag, needle): """ *musicpd.org, current playlist section:* ``playlistsearch {TAG} {NEEDLE}`` Searches case-sensitively for partial matches in the current playlist. *GMPC:* - uses ``filename`` and ``any`` as tags """ raise exceptions.MpdNotImplemented # TODO @protocol.commands.add("plchanges", version=protocol.INT) def plchanges(context, version): """ *musicpd.org, current playlist section:* ``plchanges {VERSION}`` Displays changed songs currently in the playlist since ``VERSION``. To detect songs that were deleted at the end of the playlist, use ``playlistlength`` returned by status command. *MPDroid:* - Calls ``plchanges "-1"`` two times per second to get the entire playlist. """ # XXX Naive implementation that returns all tracks as changed tracklist_version = context.core.tracklist.get_version().get() if version < tracklist_version: return translator.tracks_to_mpd_format( context.core.tracklist.get_tl_tracks().get() ) elif version == tracklist_version: # A version match could indicate this is just a metadata update, so # check for a stream ref and let the client know about the change. stream_title = context.core.playback.get_stream_title().get() if stream_title is None: return None tl_track = context.core.playback.get_current_tl_track().get() position = context.core.tracklist.index(tl_track).get() return translator.track_to_mpd_format( tl_track, position=position, stream_title=stream_title ) @protocol.commands.add("plchangesposid", version=protocol.INT) def plchangesposid(context, version): """ *musicpd.org, current playlist section:* ``plchangesposid {VERSION}`` Displays changed songs currently in the playlist since ``VERSION``. This function only returns the position and the id of the changed song, not the complete metadata. This is more bandwidth efficient. To detect songs that were deleted at the end of the playlist, use ``playlistlength`` returned by status command. """ # XXX Naive implementation that returns all tracks as changed if int(version) != context.core.tracklist.get_version().get(): result = [] for (position, (tlid, _)) in enumerate( context.core.tracklist.get_tl_tracks().get() ): result.append(("cpos", position)) result.append(("Id", tlid)) return result @protocol.commands.add("prio", priority=protocol.UINT, position=protocol.RANGE) def prio(context, priority, position): """ *musicpd.org, current playlist section:* ``prio {PRIORITY} {START:END...}`` Set the priority of the specified songs. A higher priority means that it will be played first when "random" mode is enabled. A priority is an integer between 0 and 255. The default priority of new songs is 0. """ raise exceptions.MpdNotImplemented # TODO @protocol.commands.add("prioid") def prioid(context, *args): """ *musicpd.org, current playlist section:* ``prioid {PRIORITY} {ID...}`` Same as prio, but address the songs with their id. """ raise exceptions.MpdNotImplemented # TODO @protocol.commands.add("rangeid", tlid=protocol.UINT, songrange=protocol.RANGE) def rangeid(context, tlid, songrange): """ *musicpd.org, current playlist section:* ``rangeid {ID} {START:END}`` Specifies the portion of the song that shall be played. START and END are offsets in seconds (fractional seconds allowed); both are optional. Omitting both (i.e. sending just ":") means "remove the range, play everything". A song that is currently playing cannot be manipulated this way. .. versionadded:: 0.19 New in MPD protocol version 0.19 """ raise exceptions.MpdNotImplemented # TODO @protocol.commands.add("shuffle", songrange=protocol.RANGE) def shuffle(context, songrange=None): """ *musicpd.org, current playlist section:* ``shuffle [START:END]`` Shuffles the current playlist. ``START:END`` is optional and specifies a range of songs. """ if songrange is None: start, end = None, None else: start, end = songrange.start, songrange.stop context.core.tracklist.shuffle(start, end) @protocol.commands.add("swap", songpos1=protocol.UINT, songpos2=protocol.UINT) def swap(context, songpos1, songpos2): """ *musicpd.org, current playlist section:* ``swap {SONG1} {SONG2}`` Swaps the positions of ``SONG1`` and ``SONG2``. """ if songpos2 < songpos1: songpos1, songpos2 = songpos2, songpos1 context.core.tracklist.move(songpos1, songpos1 + 1, songpos2) context.core.tracklist.move(songpos2 - 1, songpos2, songpos1) @protocol.commands.add("swapid", tlid1=protocol.UINT, tlid2=protocol.UINT) def swapid(context, tlid1, tlid2): """ *musicpd.org, current playlist section:* ``swapid {SONG1} {SONG2}`` Swaps the positions of ``SONG1`` and ``SONG2`` (both song ids). """ tl_tracks1 = context.core.tracklist.filter({"tlid": [tlid1]}).get() tl_tracks2 = context.core.tracklist.filter({"tlid": [tlid2]}).get() if not tl_tracks1 or not tl_tracks2: raise exceptions.MpdNoExistError("No such song") position1 = context.core.tracklist.index(tl_tracks1[0]).get() position2 = context.core.tracklist.index(tl_tracks2[0]).get() swap(context, position1, position2) @protocol.commands.add("addtagid", tlid=protocol.UINT) def addtagid(context, tlid, tag, value): """ *musicpd.org, current playlist section:* ``addtagid {SONGID} {TAG} {VALUE}`` Adds a tag to the specified song. Editing song tags is only possible for remote songs. This change is volatile: it may be overwritten by tags received from the server, and the data is gone when the song gets removed from the queue. .. versionadded:: 0.19 New in MPD protocol version 0.19 """ raise exceptions.MpdNotImplemented # TODO @protocol.commands.add("cleartagid", tlid=protocol.UINT) def cleartagid(context, tlid, tag): """ *musicpd.org, current playlist section:* ``cleartagid {SONGID} [TAG]`` Removes tags from the specified song. If TAG is not specified, then all tag values will be removed. Editing song tags is only possible for remote songs. .. versionadded:: 0.19 New in MPD protocol version 0.19 """ raise exceptions.MpdNotImplemented # TODO ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607133598.0 Mopidy-MPD-3.1.0/mopidy_mpd/protocol/mount.py0000644000175100001710000000357700000000000020464 0ustar00runnerdockerfrom mopidy_mpd import exceptions, protocol @protocol.commands.add("mount") def mount(context, path, uri): """ *musicpd.org, mounts and neighbors section:* ``mount {PATH} {URI}`` Mount the specified remote storage URI at the given path. Example:: mount foo nfs://192.168.1.4/export/mp3 .. versionadded:: 0.19 New in MPD protocol version 0.19 """ raise exceptions.MpdNotImplemented # TODO @protocol.commands.add("unmount") def unmount(context, path): """ *musicpd.org, mounts and neighbors section:* ``unmount {PATH}`` Unmounts the specified path. Example:: unmount foo .. versionadded:: 0.19 New in MPD protocol version 0.19 """ raise exceptions.MpdNotImplemented # TODO @protocol.commands.add("listmounts") def listmounts(context): """ *musicpd.org, mounts and neighbors section:* ``listmounts`` Queries a list of all mounts. By default, this contains just the configured music_directory. Example:: listmounts mount: storage: /home/foo/music mount: foo storage: nfs://192.168.1.4/export/mp3 OK .. versionadded:: 0.19 New in MPD protocol version 0.19 """ raise exceptions.MpdNotImplemented # TODO @protocol.commands.add("listneighbors") def listneighbors(context): """ *musicpd.org, mounts and neighbors section:* ``listneighbors`` Queries a list of "neighbors" (e.g. accessible file servers on the local net). Items on that list may be used with the mount command. Example:: listneighbors neighbor: smb://FOO name: FOO (Samba 4.1.11-Debian) OK .. versionadded:: 0.19 New in MPD protocol version 0.19 """ raise exceptions.MpdNotImplemented # TODO ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607133598.0 Mopidy-MPD-3.1.0/mopidy_mpd/protocol/music_db.py0000644000175100001710000004014300000000000021075 0ustar00runnerdockerimport functools import itertools from mopidy.models import Track from mopidy_mpd import exceptions, protocol, translator _SEARCH_MAPPING = { "album": "album", "albumartist": "albumartist", "any": "any", "artist": "artist", "comment": "comment", "composer": "composer", "date": "date", "file": "uri", "filename": "uri", "genre": "genre", "performer": "performer", "title": "track_name", "track": "track_no", } _LIST_MAPPING = { "title": "track", "album": "album", "albumartist": "albumartist", "artist": "artist", "composer": "composer", "date": "date", "genre": "genre", "performer": "performer", } _LIST_NAME_MAPPING = { "track": "Title", "album": "Album", "albumartist": "AlbumArtist", "artist": "Artist", "composer": "Composer", "date": "Date", "genre": "Genre", "performer": "Performer", } def _query_from_mpd_search_parameters(parameters, mapping): query = {} parameters = list(parameters) while parameters: # TODO: does it matter that this is now case insensitive field = mapping.get(parameters.pop(0).lower()) if not field: raise exceptions.MpdArgError("incorrect arguments") if not parameters: raise ValueError value = parameters.pop(0) if value.strip(): query.setdefault(field, []).append(value) return query def _get_field(field, search_results): return list(itertools.chain(*[getattr(r, field) for r in search_results])) _get_albums = functools.partial(_get_field, "albums") _get_artists = functools.partial(_get_field, "artists") _get_tracks = functools.partial(_get_field, "tracks") def _album_as_track(album): return Track( uri=album.uri, name="Album: " + album.name, artists=album.artists, album=album, date=album.date, ) def _artist_as_track(artist): return Track( uri=artist.uri, name="Artist: " + artist.name, artists=[artist] ) @protocol.commands.add("count") def count(context, *args): """ *musicpd.org, music database section:* ``count {TAG} {NEEDLE}`` Counts the number of songs and their total playtime in the db matching ``TAG`` exactly. *GMPC:* - use multiple tag-needle pairs to make more specific searches. """ try: query = _query_from_mpd_search_parameters(args, _SEARCH_MAPPING) except ValueError: raise exceptions.MpdArgError("incorrect arguments") results = context.core.library.search(query=query, exact=True).get() result_tracks = _get_tracks(results) total_length = sum(t.length for t in result_tracks if t.length) return [ ("songs", len(result_tracks)), ("playtime", int(total_length / 1000)), ] @protocol.commands.add("find") def find(context, *args): """ *musicpd.org, music database section:* ``find {TYPE} {WHAT}`` Finds songs in the db that are exactly ``WHAT``. ``TYPE`` can be any tag supported by MPD, or one of the two special parameters - ``file`` to search by full path (relative to database root), and ``any`` to match against all available tags. ``WHAT`` is what to find. *GMPC:* - also uses ``find album "[ALBUM]" artist "[ARTIST]"`` to list album tracks. *ncmpc:* - capitalizes the type argument. *ncmpcpp:* - also uses the search type "date". - uses "file" instead of "filename". """ try: query = _query_from_mpd_search_parameters(args, _SEARCH_MAPPING) except ValueError: return results = context.core.library.search(query=query, exact=True).get() result_tracks = [] if ( "artist" not in query and "albumartist" not in query and "composer" not in query and "performer" not in query ): result_tracks += [_artist_as_track(a) for a in _get_artists(results)] if "album" not in query: result_tracks += [_album_as_track(a) for a in _get_albums(results)] result_tracks += _get_tracks(results) return translator.tracks_to_mpd_format(result_tracks) @protocol.commands.add("findadd") def findadd(context, *args): """ *musicpd.org, music database section:* ``findadd {TYPE} {WHAT}`` Finds songs in the db that are exactly ``WHAT`` and adds them to current playlist. Parameters have the same meaning as for ``find``. """ try: query = _query_from_mpd_search_parameters(args, _SEARCH_MAPPING) except ValueError: return results = context.core.library.search(query=query, exact=True).get() context.core.tracklist.add( uris=[track.uri for track in _get_tracks(results)] ).get() @protocol.commands.add("list") def list_(context, *args): """ *musicpd.org, music database section:* ``list {TYPE} [ARTIST]`` Lists all tags of the specified type. ``TYPE`` should be ``album``, ``artist``, ``albumartist``, ``date``, or ``genre``. ``ARTIST`` is an optional parameter when type is ``album``, ``date``, or ``genre``. This filters the result list by an artist. *Clarifications:* The musicpd.org documentation for ``list`` is far from complete. The command also supports the following variant: ``list {TYPE} {QUERY}`` Where ``QUERY`` applies to all ``TYPE``. ``QUERY`` is one or more pairs of a field name and a value. If the ``QUERY`` consists of more than one pair, the pairs are AND-ed together to find the result. Examples of valid queries and what they should return: ``list "artist" "artist" "ABBA"`` List artists where the artist name is "ABBA". Response:: Artist: ABBA OK ``list "album" "artist" "ABBA"`` Lists albums where the artist name is "ABBA". Response:: Album: More ABBA Gold: More ABBA Hits Album: Absolute More Christmas Album: Gold: Greatest Hits OK ``list "artist" "album" "Gold: Greatest Hits"`` Lists artists where the album name is "Gold: Greatest Hits". Response:: Artist: ABBA OK ``list "artist" "artist" "ABBA" "artist" "TLC"`` Lists artists where the artist name is "ABBA" *and* "TLC". Should never match anything. Response:: OK ``list "date" "artist" "ABBA"`` Lists dates where artist name is "ABBA". Response:: Date: Date: 1992 Date: 1993 OK ``list "date" "artist" "ABBA" "album" "Gold: Greatest Hits"`` Lists dates where artist name is "ABBA" and album name is "Gold: Greatest Hits". Response:: Date: 1992 OK ``list "genre" "artist" "The Rolling Stones"`` Lists genres where artist name is "The Rolling Stones". Response:: Genre: Genre: Rock OK *ncmpc:* - capitalizes the field argument. """ params = list(args) if not params: raise exceptions.MpdArgError('too few arguments for "list"') field_arg = params.pop(0).lower() field = _LIST_MAPPING.get(field_arg) if field is None: raise exceptions.MpdArgError(f"Unknown tag type: {field_arg}") query = None if len(params) == 1: if field != "album": raise exceptions.MpdArgError('should be "Album" for 3 arguments') if params[0].strip(): query = {"artist": params} else: try: query = _query_from_mpd_search_parameters(params, _SEARCH_MAPPING) except exceptions.MpdArgError as exc: exc.message = "Unknown filter type" # noqa B306: Our own exception raise except ValueError: return name = _LIST_NAME_MAPPING[field] result = context.core.library.get_distinct(field, query) return [(name, value) for value in result.get()] @protocol.commands.add("listall") def listall(context, uri=None): """ *musicpd.org, music database section:* ``listall [URI]`` Lists all songs and directories in ``URI``. Do not use this command. Do not manage a client-side copy of MPD's database. That is fragile and adds huge overhead. It will break with large databases. Instead, query MPD whenever you need something. .. warning:: This command is disabled by default in Mopidy installs. """ result = [] for path, track_ref in context.browse(uri, lookup=False): if not track_ref: result.append(("directory", path.lstrip("/"))) else: result.append(("file", track_ref.uri)) if not result: raise exceptions.MpdNoExistError("Not found") return result @protocol.commands.add("listallinfo") def listallinfo(context, uri=None): """ *musicpd.org, music database section:* ``listallinfo [URI]`` Same as ``listall``, except it also returns metadata info in the same format as ``lsinfo``. Do not use this command. Do not manage a client-side copy of MPD's database. That is fragile and adds huge overhead. It will break with large databases. Instead, query MPD whenever you need something. .. warning:: This command is disabled by default in Mopidy installs. """ result = [] for path, lookup_future in context.browse(uri): if not lookup_future: result.append(("directory", path.lstrip("/"))) else: for tracks in lookup_future.get().values(): for track in tracks: result.extend(translator.track_to_mpd_format(track)) return result @protocol.commands.add("listfiles") def listfiles(context, uri=None): """ *musicpd.org, music database section:* ``listfiles [URI]`` Lists the contents of the directory URI, including files are not recognized by MPD. URI can be a path relative to the music directory or an URI understood by one of the storage plugins. The response contains at least one line for each directory entry with the prefix "file: " or "directory: ", and may be followed by file attributes such as "Last-Modified" and "size". For example, "smb://SERVER" returns a list of all shares on the given SMB/CIFS server; "nfs://servername/path" obtains a directory listing from the NFS server. .. versionadded:: 0.19 New in MPD protocol version 0.19 """ raise exceptions.MpdNotImplemented # TODO @protocol.commands.add("lsinfo") def lsinfo(context, uri=None): """ *musicpd.org, music database section:* ``lsinfo [URI]`` Lists the contents of the directory ``URI``. When listing the root directory, this currently returns the list of stored playlists. This behavior is deprecated; use ``listplaylists`` instead. MPD returns the same result, including both playlists and the files and directories located at the root level, for both ``lsinfo``, ``lsinfo ""``, and ``lsinfo "/"``. """ result = [] for path, lookup_future in context.browse(uri, recursive=False): if not lookup_future: result.append(("directory", path.lstrip("/"))) else: for tracks in lookup_future.get().values(): if tracks: result.extend(translator.track_to_mpd_format(tracks[0])) if uri in (None, "", "/"): result.extend(protocol.stored_playlists.listplaylists(context)) return result @protocol.commands.add("rescan") def rescan(context, uri=None): """ *musicpd.org, music database section:* ``rescan [URI]`` Same as ``update``, but also rescans unmodified files. """ return {"updating_db": 0} # TODO @protocol.commands.add("search") def search(context, *args): """ *musicpd.org, music database section:* ``search {TYPE} {WHAT} [...]`` Searches for any song that contains ``WHAT``. Parameters have the same meaning as for ``find``, except that search is not case sensitive. *GMPC:* - uses the undocumented field ``any``. - searches for multiple words like this:: search any "foo" any "bar" any "baz" *ncmpc:* - capitalizes the field argument. *ncmpcpp:* - also uses the search type "date". - uses "file" instead of "filename". """ try: query = _query_from_mpd_search_parameters(args, _SEARCH_MAPPING) except ValueError: return results = context.core.library.search(query).get() artists = [_artist_as_track(a) for a in _get_artists(results)] albums = [_album_as_track(a) for a in _get_albums(results)] tracks = _get_tracks(results) return translator.tracks_to_mpd_format(artists + albums + tracks) @protocol.commands.add("searchadd") def searchadd(context, *args): """ *musicpd.org, music database section:* ``searchadd {TYPE} {WHAT} [...]`` Searches for any song that contains ``WHAT`` in tag ``TYPE`` and adds them to current playlist. Parameters have the same meaning as for ``find``, except that search is not case sensitive. """ try: query = _query_from_mpd_search_parameters(args, _SEARCH_MAPPING) except ValueError: return results = context.core.library.search(query).get() context.core.tracklist.add( uris=[track.uri for track in _get_tracks(results)] ).get() @protocol.commands.add("searchaddpl") def searchaddpl(context, *args): """ *musicpd.org, music database section:* ``searchaddpl {NAME} {TYPE} {WHAT} [...]`` Searches for any song that contains ``WHAT`` in tag ``TYPE`` and adds them to the playlist named ``NAME``. If a playlist by that name doesn't exist it is created. Parameters have the same meaning as for ``find``, except that search is not case sensitive. """ parameters = list(args) if not parameters: raise exceptions.MpdArgError("incorrect arguments") playlist_name = parameters.pop(0) try: query = _query_from_mpd_search_parameters(parameters, _SEARCH_MAPPING) except ValueError: return results = context.core.library.search(query).get() uri = context.lookup_playlist_uri_from_name(playlist_name) playlist = uri is not None and context.core.playlists.lookup(uri).get() if not playlist: playlist = context.core.playlists.create(playlist_name).get() tracks = list(playlist.tracks) + _get_tracks(results) playlist = playlist.replace(tracks=tracks) context.core.playlists.save(playlist) @protocol.commands.add("update") def update(context, uri=None): """ *musicpd.org, music database section:* ``update [URI]`` Updates the music database: find new files, remove deleted files, update modified files. ``URI`` is a particular directory or song/file to update. If you do not specify it, everything is updated. Prints ``updating_db: JOBID`` where ``JOBID`` is a positive number identifying the update job. You can read the current job id in the ``status`` response. """ return {"updating_db": 0} # TODO # TODO: add at least reflection tests before adding NotImplemented version # @protocol.commands.add('readcomments') def readcomments(context, uri): """ *musicpd.org, music database section:* ``readcomments [URI]`` Read "comments" (i.e. key-value pairs) from the file specified by "URI". This "URI" can be a path relative to the music directory or a URL in the form "file:///foo/bar.ogg". This command may be used to list metadata of remote files (e.g. URI beginning with "http://" or "smb://"). The response consists of lines in the form "KEY: VALUE". Comments with suspicious characters (e.g. newlines) are ignored silently. The meaning of these depends on the codec, and not all decoder plugins support it. For example, on Ogg files, this lists the Vorbis comments. """ pass ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607133598.0 Mopidy-MPD-3.1.0/mopidy_mpd/protocol/playback.py0000644000175100001710000003445000000000000021102 0ustar00runnerdockerfrom mopidy.core import PlaybackState from mopidy_mpd import exceptions, protocol @protocol.commands.add("consume", state=protocol.BOOL) def consume(context, state): """ *musicpd.org, playback section:* ``consume {STATE}`` Sets consume state to ``STATE``, ``STATE`` should be 0 or 1. When consume is activated, each song played is removed from playlist. """ context.core.tracklist.set_consume(state) @protocol.commands.add("crossfade", seconds=protocol.UINT) def crossfade(context, seconds): """ *musicpd.org, playback section:* ``crossfade {SECONDS}`` Sets crossfading between songs. """ raise exceptions.MpdNotImplemented # TODO @protocol.commands.add("mixrampdb") def mixrampdb(context, decibels): """ *musicpd.org, playback section:* ``mixrampdb {deciBels}`` Sets the threshold at which songs will be overlapped. Like crossfading but doesn't fade the track volume, just overlaps. The songs need to have MixRamp tags added by an external tool. 0dB is the normalized maximum volume so use negative values, I prefer -17dB. In the absence of mixramp tags crossfading will be used. See https://sourceforge.net/projects/mixramp/ """ raise exceptions.MpdNotImplemented # TODO @protocol.commands.add("mixrampdelay", seconds=protocol.UINT) def mixrampdelay(context, seconds): """ *musicpd.org, playback section:* ``mixrampdelay {SECONDS}`` Additional time subtracted from the overlap calculated by mixrampdb. A value of "nan" disables MixRamp overlapping and falls back to crossfading. """ raise exceptions.MpdNotImplemented # TODO @protocol.commands.add("next") def next_(context): """ *musicpd.org, playback section:* ``next`` Plays next song in the playlist. *MPD's behaviour when affected by repeat/random/single/consume:* Given a playlist of three tracks numbered 1, 2, 3, and a currently playing track ``c``. ``next_track`` is defined at the track that will be played upon calls to ``next``. Tests performed on MPD 0.15.4-1ubuntu3. ====== ====== ====== ======= ===== ===== ===== ===== Inputs next_track ------------------------------- ------------------- ----- repeat random single consume c = 1 c = 2 c = 3 Notes ====== ====== ====== ======= ===== ===== ===== ===== T T T T 2 3 EOPL T T T . Rand Rand Rand [1] T T . T Rand Rand Rand [4] T T . . Rand Rand Rand [4] T . T T 2 3 EOPL T . T . 2 3 1 T . . T 3 3 EOPL T . . . 2 3 1 . T T T Rand Rand Rand [3] . T T . Rand Rand Rand [3] . T . T Rand Rand Rand [2] . T . . Rand Rand Rand [2] . . T T 2 3 EOPL . . T . 2 3 EOPL . . . T 2 3 EOPL . . . . 2 3 EOPL ====== ====== ====== ======= ===== ===== ===== ===== - When end of playlist (EOPL) is reached, the current track is unset. - [1] When *random* and *single* is combined, ``next`` selects a track randomly at each invocation, and not just the next track in an internal prerandomized playlist. - [2] When *random* is active, ``next`` will skip through all tracks in the playlist in random order, and finally EOPL is reached. - [3] *single* has no effect in combination with *random* alone, or *random* and *consume*. - [4] When *random* and *repeat* is active, EOPL is never reached, but the playlist is played again, in the same random order as the first time. """ return context.core.playback.next().get() @protocol.commands.add("pause", state=protocol.BOOL) def pause(context, state=None): """ *musicpd.org, playback section:* ``pause {PAUSE}`` Toggles pause/resumes playing, ``PAUSE`` is 0 or 1. *MPDroid:* - Calls ``pause`` without any arguments to toogle pause. """ if state is None: # Deprecated: Calling `pause` without any arguments playback_state = context.core.playback.get_state().get() if playback_state == PlaybackState.PLAYING: context.core.playback.pause().get() elif playback_state == PlaybackState.PAUSED: context.core.playback.resume().get() elif state: context.core.playback.pause().get() else: context.core.playback.resume().get() @protocol.commands.add("play", songpos=protocol.INT) def play(context, songpos=None): """ *musicpd.org, playback section:* ``play [SONGPOS]`` Begins playing the playlist at song number ``SONGPOS``. The original MPD server resumes from the paused state on ``play`` without arguments. *Clarifications:* - ``play "-1"`` when playing is ignored. - ``play "-1"`` when paused resumes playback. - ``play "-1"`` when stopped with a current track starts playback at the current track. - ``play "-1"`` when stopped without a current track, e.g. after playlist replacement, starts playback at the first track. *BitMPC:* - issues ``play 6`` without quotes around the argument. """ if songpos is None: return context.core.playback.play().get() elif songpos == -1: return _play_minus_one(context) try: tl_track = context.core.tracklist.slice(songpos, songpos + 1).get()[0] return context.core.playback.play(tl_track).get() except IndexError: raise exceptions.MpdArgError("Bad song index") def _play_minus_one(context): playback_state = context.core.playback.get_state().get() if playback_state == PlaybackState.PLAYING: return # Nothing to do elif playback_state == PlaybackState.PAUSED: return context.core.playback.resume().get() current_tl_track = context.core.playback.get_current_tl_track().get() if current_tl_track is not None: return context.core.playback.play(current_tl_track).get() tl_tracks = context.core.tracklist.slice(0, 1).get() if tl_tracks: return context.core.playback.play(tl_tracks[0]).get() return # Fail silently @protocol.commands.add("playid", tlid=protocol.INT) def playid(context, tlid): """ *musicpd.org, playback section:* ``playid [SONGID]`` Begins playing the playlist at song ``SONGID``. *Clarifications:* - ``playid "-1"`` when playing is ignored. - ``playid "-1"`` when paused resumes playback. - ``playid "-1"`` when stopped with a current track starts playback at the current track. - ``playid "-1"`` when stopped without a current track, e.g. after playlist replacement, starts playback at the first track. """ if tlid == -1: return _play_minus_one(context) tl_tracks = context.core.tracklist.filter({"tlid": [tlid]}).get() if not tl_tracks: raise exceptions.MpdNoExistError("No such song") return context.core.playback.play(tl_tracks[0]).get() @protocol.commands.add("previous") def previous(context): """ *musicpd.org, playback section:* ``previous`` Plays previous song in the playlist. *MPD's behaviour when affected by repeat/random/single/consume:* Given a playlist of three tracks numbered 1, 2, 3, and a currently playing track ``c``. ``previous_track`` is defined at the track that will be played upon ``previous`` calls. Tests performed on MPD 0.15.4-1ubuntu3. ====== ====== ====== ======= ===== ===== ===== Inputs previous_track ------------------------------- ------------------- repeat random single consume c = 1 c = 2 c = 3 ====== ====== ====== ======= ===== ===== ===== T T T T Rand? Rand? Rand? T T T . 3 1 2 T T . T Rand? Rand? Rand? T T . . 3 1 2 T . T T 3 1 2 T . T . 3 1 2 T . . T 3 1 2 T . . . 3 1 2 . T T T c c c . T T . c c c . T . T c c c . T . . c c c . . T T 1 1 2 . . T . 1 1 2 . . . T 1 1 2 . . . . 1 1 2 ====== ====== ====== ======= ===== ===== ===== - If :attr:`time_position` of the current track is 15s or more, ``previous`` should do a seek to time position 0. """ return context.core.playback.previous().get() @protocol.commands.add("random", state=protocol.BOOL) def random(context, state): """ *musicpd.org, playback section:* ``random {STATE}`` Sets random state to ``STATE``, ``STATE`` should be 0 or 1. """ context.core.tracklist.set_random(state) @protocol.commands.add("repeat", state=protocol.BOOL) def repeat(context, state): """ *musicpd.org, playback section:* ``repeat {STATE}`` Sets repeat state to ``STATE``, ``STATE`` should be 0 or 1. """ context.core.tracklist.set_repeat(state) @protocol.commands.add("replay_gain_mode") def replay_gain_mode(context, mode): """ *musicpd.org, playback section:* ``replay_gain_mode {MODE}`` Sets the replay gain mode. One of ``off``, ``track``, ``album``. Changing the mode during playback may take several seconds, because the new settings does not affect the buffered data. This command triggers the options idle event. """ raise exceptions.MpdNotImplemented # TODO @protocol.commands.add("replay_gain_status") def replay_gain_status(context): """ *musicpd.org, playback section:* ``replay_gain_status`` Prints replay gain options. Currently, only the variable ``replay_gain_mode`` is returned. """ return "replay_gain_mode: off" # TODO @protocol.commands.add("seek", songpos=protocol.UINT, seconds=protocol.UFLOAT) def seek(context, songpos, seconds): """ *musicpd.org, playback section:* ``seek {SONGPOS} {TIME}`` Seeks to the position ``TIME`` (in seconds) of entry ``SONGPOS`` in the playlist. *Droid MPD:* - issues ``seek 1 120`` without quotes around the arguments. """ tl_track = context.core.playback.get_current_tl_track().get() if context.core.tracklist.index(tl_track).get() != songpos: play(context, songpos) context.core.playback.seek(int(seconds * 1000)).get() @protocol.commands.add("seekid", tlid=protocol.UINT, seconds=protocol.UFLOAT) def seekid(context, tlid, seconds): """ *musicpd.org, playback section:* ``seekid {SONGID} {TIME}`` Seeks to the position ``TIME`` (in seconds) of song ``SONGID``. """ tl_track = context.core.playback.get_current_tl_track().get() if not tl_track or tl_track.tlid != tlid: playid(context, tlid) context.core.playback.seek(int(seconds * 1000)).get() @protocol.commands.add("seekcur") def seekcur(context, time): """ *musicpd.org, playback section:* ``seekcur {TIME}`` Seeks to the position ``TIME`` within the current song. If prefixed by '+' or '-', then the time is relative to the current playing position. """ if time.startswith(("+", "-")): position = context.core.playback.get_time_position().get() position += int(protocol.FLOAT(time) * 1000) context.core.playback.seek(position).get() else: position = int(protocol.UFLOAT(time) * 1000) context.core.playback.seek(position).get() @protocol.commands.add("setvol", volume=protocol.INT) def setvol(context, volume): """ *musicpd.org, playback section:* ``setvol {VOL}`` Sets volume to ``VOL``, the range of volume is 0-100. *Droid MPD:* - issues ``setvol 50`` without quotes around the argument. """ # NOTE: we use INT as clients can pass in +N etc. value = min(max(0, volume), 100) success = context.core.mixer.set_volume(value).get() if not success: raise exceptions.MpdSystemError("problems setting volume") @protocol.commands.add("single", state=protocol.BOOL) def single(context, state): """ *musicpd.org, playback section:* ``single {STATE}`` Sets single state to ``STATE``, ``STATE`` should be 0 or 1. When single is activated, playback is stopped after current song, or song is repeated if the ``repeat`` mode is enabled. """ context.core.tracklist.set_single(state) @protocol.commands.add("stop") def stop(context): """ *musicpd.org, playback section:* ``stop`` Stops playing. """ context.core.playback.stop() @protocol.commands.add("volume", change=protocol.INT) def volume(context, change): """ *musicpd.org, playback section:* ``volume {CHANGE}`` Changes volume by amount ``CHANGE``. Note: ``volume`` is deprecated, use ``setvol`` instead. """ if change < -100 or change > 100: raise exceptions.MpdArgError("Invalid volume value") old_volume = context.core.mixer.get_volume().get() if old_volume is None: raise exceptions.MpdSystemError("problems setting volume") new_volume = min(max(0, old_volume + change), 100) success = context.core.mixer.set_volume(new_volume).get() if not success: raise exceptions.MpdSystemError("problems setting volume") ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607133598.0 Mopidy-MPD-3.1.0/mopidy_mpd/protocol/reflection.py0000644000175100001710000000564600000000000021453 0ustar00runnerdockerfrom mopidy_mpd import exceptions, protocol from mopidy_mpd.protocol import tagtype_list @protocol.commands.add("config", list_command=False) def config(context): """ *musicpd.org, reflection section:* ``config`` Dumps configuration values that may be interesting for the client. This command is only permitted to "local" clients (connected via UNIX domain socket). """ raise exceptions.MpdPermissionError(command="config") @protocol.commands.add("commands", auth_required=False) def commands(context): """ *musicpd.org, reflection section:* ``commands`` Shows which commands the current user has access to. """ command_names = set() for name, handler in protocol.commands.handlers.items(): if not handler.list_command: continue if context.dispatcher.authenticated or not handler.auth_required: command_names.add(name) return [("command", command_name) for command_name in sorted(command_names)] @protocol.commands.add("decoders") def decoders(context): """ *musicpd.org, reflection section:* ``decoders`` Print a list of decoder plugins, followed by their supported suffixes and MIME types. Example response:: plugin: mad suffix: mp3 suffix: mp2 mime_type: audio/mpeg plugin: mpcdec suffix: mpc *Clarifications:* - ncmpcpp asks for decoders the first time you open the browse view. By returning nothing and OK instead of an not implemented error, we avoid "Not implemented" showing up in the ncmpcpp interface, and we get the list of playlists without having to enter the browse interface twice. """ return # TODO @protocol.commands.add("notcommands", auth_required=False) def notcommands(context): """ *musicpd.org, reflection section:* ``notcommands`` Shows which commands the current user does not have access to. """ command_names = {"config", "kill"} # No permission to use for name, handler in protocol.commands.handlers.items(): if not handler.list_command: continue if not context.dispatcher.authenticated and handler.auth_required: command_names.add(name) return [("command", command_name) for command_name in sorted(command_names)] @protocol.commands.add("tagtypes") def tagtypes(context): """ *musicpd.org, reflection section:* ``tagtypes`` Shows a list of available song metadata. """ return [("tagtype", tagtype) for tagtype in tagtype_list.TAGTYPE_LIST] @protocol.commands.add("urlhandlers") def urlhandlers(context): """ *musicpd.org, reflection section:* ``urlhandlers`` Gets a list of available URL handlers. """ return [ ("handler", uri_scheme) for uri_scheme in context.core.get_uri_schemes().get() ] ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607133598.0 Mopidy-MPD-3.1.0/mopidy_mpd/protocol/status.py0000644000175100001710000002377700000000000020651 0ustar00runnerdockerimport pykka from mopidy.core import PlaybackState from mopidy_mpd import exceptions, protocol, translator #: Subsystems that can be registered with idle command. SUBSYSTEMS = [ "database", "mixer", "options", "output", "player", "playlist", "stored_playlist", "update", ] @protocol.commands.add("clearerror") def clearerror(context): """ *musicpd.org, status section:* ``clearerror`` Clears the current error message in status (this is also accomplished by any command that starts playback). """ raise exceptions.MpdNotImplemented # TODO @protocol.commands.add("currentsong") def currentsong(context): """ *musicpd.org, status section:* ``currentsong`` Displays the song info of the current song (same song that is identified in status). """ tl_track = context.core.playback.get_current_tl_track().get() stream_title = context.core.playback.get_stream_title().get() if tl_track is not None: position = context.core.tracklist.index(tl_track).get() return translator.track_to_mpd_format( tl_track, position=position, stream_title=stream_title ) @protocol.commands.add("idle") def idle(context, *subsystems): """ *musicpd.org, status section:* ``idle [SUBSYSTEMS...]`` Waits until there is a noteworthy change in one or more of MPD's subsystems. As soon as there is one, it lists all changed systems in a line in the format ``changed: SUBSYSTEM``, where ``SUBSYSTEM`` is one of the following: - ``database``: the song database has been modified after update. - ``update``: a database update has started or finished. If the database was modified during the update, the database event is also emitted. - ``stored_playlist``: a stored playlist has been modified, renamed, created or deleted - ``playlist``: the current playlist has been modified - ``player``: the player has been started, stopped or seeked - ``mixer``: the volume has been changed - ``output``: an audio output has been enabled or disabled - ``options``: options like repeat, random, crossfade, replay gain While a client is waiting for idle results, the server disables timeouts, allowing a client to wait for events as long as MPD runs. The idle command can be canceled by sending the command ``noidle`` (no other commands are allowed). MPD will then leave idle mode and print results immediately; might be empty at this time. If the optional ``SUBSYSTEMS`` argument is used, MPD will only send notifications when something changed in one of the specified subsystems. """ # TODO: test against valid subsystems if not subsystems: subsystems = SUBSYSTEMS for subsystem in subsystems: context.subscriptions.add(subsystem) active = context.subscriptions.intersection(context.events) if not active: context.session.prevent_timeout = True return response = [] context.events = set() context.subscriptions = set() for subsystem in active: response.append(f"changed: {subsystem}") return response @protocol.commands.add("noidle", list_command=False) def noidle(context): """See :meth:`_status_idle`.""" if not context.subscriptions: return context.subscriptions = set() context.events = set() context.session.prevent_timeout = False @protocol.commands.add("stats") def stats(context): """ *musicpd.org, status section:* ``stats`` Displays statistics. - ``artists``: number of artists - ``songs``: number of albums - ``uptime``: daemon uptime in seconds - ``db_playtime``: sum of all song times in the db - ``db_update``: last db update in UNIX time - ``playtime``: time length of music played """ return { "artists": 0, # TODO "albums": 0, # TODO "songs": 0, # TODO "uptime": 0, # TODO "db_playtime": 0, # TODO "db_update": 0, # TODO "playtime": 0, # TODO } @protocol.commands.add("status") def status(context): """ *musicpd.org, status section:* ``status`` Reports the current status of the player and the volume level. - ``volume``: 0-100 or -1 - ``repeat``: 0 or 1 - ``single``: 0 or 1 - ``consume``: 0 or 1 - ``playlist``: 31-bit unsigned integer, the playlist version number - ``playlistlength``: integer, the length of the playlist - ``state``: play, stop, or pause - ``song``: playlist song number of the current song stopped on or playing - ``songid``: playlist songid of the current song stopped on or playing - ``nextsong``: playlist song number of the next song to be played - ``nextsongid``: playlist songid of the next song to be played - ``time``: total time elapsed (of current playing/paused song) - ``elapsed``: Total time elapsed within the current song, but with higher resolution. - ``bitrate``: instantaneous bitrate in kbps - ``xfade``: crossfade in seconds - ``audio``: sampleRate``:bits``:channels - ``updatings_db``: job id - ``error``: if there is an error, returns message here *Clarifications based on experience implementing* - ``volume``: can also be -1 if no output is set. - ``elapsed``: Higher resolution means time in seconds with three decimal places for millisecond precision. """ tl_track = context.core.playback.get_current_tl_track() next_tlid = context.core.tracklist.get_next_tlid() futures = { "tracklist.length": context.core.tracklist.get_length(), "tracklist.version": context.core.tracklist.get_version(), "mixer.volume": context.core.mixer.get_volume(), "tracklist.consume": context.core.tracklist.get_consume(), "tracklist.random": context.core.tracklist.get_random(), "tracklist.repeat": context.core.tracklist.get_repeat(), "tracklist.single": context.core.tracklist.get_single(), "playback.state": context.core.playback.get_state(), "playback.current_tl_track": tl_track, "tracklist.index": context.core.tracklist.index(tl_track.get()), "tracklist.next_tlid": next_tlid, "tracklist.next_index": context.core.tracklist.index( tlid=next_tlid.get() ), "playback.time_position": context.core.playback.get_time_position(), } pykka.get_all(futures.values()) result = [ ("volume", _status_volume(futures)), ("repeat", _status_repeat(futures)), ("random", _status_random(futures)), ("single", _status_single(futures)), ("consume", _status_consume(futures)), ("playlist", _status_playlist_version(futures)), ("playlistlength", _status_playlist_length(futures)), ("xfade", _status_xfade(futures)), ("state", _status_state(futures)), ] if futures["playback.current_tl_track"].get() is not None: result.append(("song", _status_songpos(futures))) result.append(("songid", _status_songid(futures))) if futures["tracklist.next_tlid"].get() is not None: result.append(("nextsong", _status_nextsongpos(futures))) result.append(("nextsongid", _status_nextsongid(futures))) if futures["playback.state"].get() in ( PlaybackState.PLAYING, PlaybackState.PAUSED, ): result.append(("time", _status_time(futures))) result.append(("elapsed", _status_time_elapsed(futures))) result.append(("bitrate", _status_bitrate(futures))) return result def _status_bitrate(futures): current_tl_track = futures["playback.current_tl_track"].get() if current_tl_track is None: return 0 if current_tl_track.track.bitrate is None: return 0 return current_tl_track.track.bitrate def _status_consume(futures): if futures["tracklist.consume"].get(): return 1 else: return 0 def _status_playlist_length(futures): return futures["tracklist.length"].get() def _status_playlist_version(futures): return futures["tracklist.version"].get() def _status_random(futures): return int(futures["tracklist.random"].get()) def _status_repeat(futures): return int(futures["tracklist.repeat"].get()) def _status_single(futures): return int(futures["tracklist.single"].get()) def _status_songid(futures): current_tl_track = futures["playback.current_tl_track"].get() if current_tl_track is not None: return current_tl_track.tlid else: return _status_songpos(futures) def _status_songpos(futures): return futures["tracklist.index"].get() def _status_nextsongid(futures): return futures["tracklist.next_tlid"].get() def _status_nextsongpos(futures): return futures["tracklist.next_index"].get() def _status_state(futures): state = futures["playback.state"].get() if state == PlaybackState.PLAYING: return "play" elif state == PlaybackState.STOPPED: return "stop" elif state == PlaybackState.PAUSED: return "pause" def _status_time(futures): position = futures["playback.time_position"].get() // 1000 total = _status_time_total(futures) // 1000 return f"{position:d}:{total:d}" def _status_time_elapsed(futures): elapsed = futures["playback.time_position"].get() / 1000.0 return f"{elapsed:.3f}" def _status_time_total(futures): current_tl_track = futures["playback.current_tl_track"].get() if current_tl_track is None: return 0 elif current_tl_track.track.length is None: return 0 else: return current_tl_track.track.length def _status_volume(futures): volume = futures["mixer.volume"].get() if volume is not None: return volume else: return -1 def _status_xfade(futures): return 0 # Not supported ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607133598.0 Mopidy-MPD-3.1.0/mopidy_mpd/protocol/stickers.py0000644000175100001710000000226600000000000021143 0ustar00runnerdockerfrom mopidy_mpd import exceptions, protocol @protocol.commands.add("sticker", list_command=False) def sticker(context, action, field, uri, name=None, value=None): """ *musicpd.org, sticker section:* ``sticker list {TYPE} {URI}`` Lists the stickers for the specified object. ``sticker find {TYPE} {URI} {NAME}`` Searches the sticker database for stickers with the specified name, below the specified directory (``URI``). For each matching song, it prints the ``URI`` and that one sticker's value. ``sticker get {TYPE} {URI} {NAME}`` Reads a sticker value for the specified object. ``sticker set {TYPE} {URI} {NAME} {VALUE}`` Adds a sticker value to the specified object. If a sticker item with that name already exists, it is replaced. ``sticker delete {TYPE} {URI} [NAME]`` Deletes a sticker value from the specified object. If you do not specify a sticker name, all sticker values are deleted. """ # TODO: check that action in ('list', 'find', 'get', 'set', 'delete') # TODO: check name/value matches with action raise exceptions.MpdNotImplemented # TODO ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607133598.0 Mopidy-MPD-3.1.0/mopidy_mpd/protocol/stored_playlists.py0000644000175100001710000003046300000000000022720 0ustar00runnerdockerimport datetime import logging import re import urllib from mopidy_mpd import exceptions, protocol, translator logger = logging.getLogger(__name__) def _check_playlist_name(name): if re.search("[/\n\r]", name): raise exceptions.MpdInvalidPlaylistName() def _get_playlist(context, name, must_exist=True): playlist = None uri = context.lookup_playlist_uri_from_name(name) if uri: playlist = context.core.playlists.lookup(uri).get() if must_exist and playlist is None: raise exceptions.MpdNoExistError("No such playlist") return playlist @protocol.commands.add("listplaylist") def listplaylist(context, name): """ *musicpd.org, stored playlists section:* ``listplaylist {NAME}`` Lists the files in the playlist ``NAME.m3u``. Output format:: file: relative/path/to/file1.flac file: relative/path/to/file2.ogg file: relative/path/to/file3.mp3 """ playlist = _get_playlist(context, name) return [f"file: {track.uri}" for track in playlist.tracks] @protocol.commands.add("listplaylistinfo") def listplaylistinfo(context, name): """ *musicpd.org, stored playlists section:* ``listplaylistinfo {NAME}`` Lists songs in the playlist ``NAME.m3u``. Output format: Standard track listing, with fields: file, Time, Title, Date, Album, Artist, Track """ playlist = _get_playlist(context, name) track_uris = [track.uri for track in playlist.tracks] tracks_map = context.core.library.lookup(uris=track_uris).get() tracks = [] for uri in track_uris: tracks.extend(tracks_map[uri]) playlist = playlist.replace(tracks=tracks) return translator.playlist_to_mpd_format(playlist) @protocol.commands.add("listplaylists") def listplaylists(context): """ *musicpd.org, stored playlists section:* ``listplaylists`` Prints a list of the playlist directory. After each playlist name the server sends its last modification time as attribute ``Last-Modified`` in ISO 8601 format. To avoid problems due to clock differences between clients and the server, clients should not compare this value with their local clock. Output format:: playlist: a Last-Modified: 2010-02-06T02:10:25Z playlist: b Last-Modified: 2010-02-06T02:11:08Z *Clarifications:* - ncmpcpp 0.5.10 segfaults if we return 'playlist: ' on a line, so we must ignore playlists without names, which isn't very useful anyway. """ last_modified = _get_last_modified() result = [] for playlist_ref in context.core.playlists.as_list().get(): if not playlist_ref.name: continue name = context.lookup_playlist_name_from_uri(playlist_ref.uri) result.append(("playlist", name)) result.append(("Last-Modified", last_modified)) return result # TODO: move to translators? def _get_last_modified(last_modified=None): """Formats last modified timestamp of a playlist for MPD. Time in UTC with second precision, formatted in the ISO 8601 format, with the "Z" time zone marker for UTC. For example, "1970-01-01T00:00:00Z". """ if last_modified is None: # If unknown, assume the playlist is modified dt = datetime.datetime.utcnow() else: dt = datetime.datetime.utcfromtimestamp(last_modified / 1000.0) dt = dt.replace(microsecond=0) return f"{dt.isoformat()}Z" DEFAULT_PLAYLIST_SLICE = slice(0, None) @protocol.commands.add("load", playlist_slice=protocol.RANGE) def load(context, name, playlist_slice=DEFAULT_PLAYLIST_SLICE): """ *musicpd.org, stored playlists section:* ``load {NAME} [START:END]`` Loads the playlist into the current queue. Playlist plugins are supported. A range may be specified to load only a part of the playlist. *Clarifications:* - ``load`` appends the given playlist to the current playlist. - MPD 0.17.1 does not support open-ended ranges, i.e. without end specified, for the ``load`` command, even though MPD's general range docs allows open-ended ranges. - MPD 0.17.1 does not fail if the specified range is outside the playlist, in either or both ends. """ playlist = _get_playlist(context, name) track_uris = [track.uri for track in playlist.tracks[playlist_slice]] context.core.tracklist.add(uris=track_uris).get() @protocol.commands.add("playlistadd") def playlistadd(context, name, track_uri): """ *musicpd.org, stored playlists section:* ``playlistadd {NAME} {URI}`` Adds ``URI`` to the playlist ``NAME.m3u``. ``NAME.m3u`` will be created if it does not exist. """ _check_playlist_name(name) old_playlist = _get_playlist(context, name, must_exist=False) if not old_playlist: # Create new playlist with this single track lookup_res = context.core.library.lookup(uris=[track_uri]).get() tracks = [ track for uri_tracks in lookup_res.values() for track in uri_tracks ] _create_playlist(context, name, tracks) else: # Add track to existing playlist lookup_res = context.core.library.lookup(uris=[track_uri]).get() new_tracks = [ track for uri_tracks in lookup_res.values() for track in uri_tracks ] new_playlist = old_playlist.replace( tracks=list(old_playlist.tracks) + new_tracks ) saved_playlist = context.core.playlists.save(new_playlist).get() if saved_playlist is None: playlist_scheme = urllib.parse.urlparse(old_playlist.uri).scheme uri_scheme = urllib.parse.urlparse(track_uri).scheme raise exceptions.MpdInvalidTrackForPlaylist( playlist_scheme, uri_scheme ) def _create_playlist(context, name, tracks): """ Creates new playlist using backend appropriate for the given tracks """ uri_schemes = {urllib.parse.urlparse(t.uri).scheme for t in tracks} for scheme in uri_schemes: new_playlist = context.core.playlists.create(name, scheme).get() if new_playlist is None: logger.debug("Backend for scheme %s can't create playlists", scheme) continue # Backend can't create playlists at all new_playlist = new_playlist.replace(tracks=tracks) saved_playlist = context.core.playlists.save(new_playlist).get() if saved_playlist is not None: return # Created and saved else: continue # Failed to save using this backend # Can't use backend appropriate for passed URI schemes, use default one default_scheme = context.dispatcher.config["mpd"]["default_playlist_scheme"] new_playlist = context.core.playlists.create(name, default_scheme).get() if new_playlist is None: # If even MPD's default backend can't save playlist, everything is lost logger.warning("MPD's default backend can't create playlists") raise exceptions.MpdFailedToSavePlaylist(default_scheme) new_playlist = new_playlist.replace(tracks=tracks) saved_playlist = context.core.playlists.save(new_playlist).get() if saved_playlist is None: uri_scheme = urllib.parse.urlparse(new_playlist.uri).scheme raise exceptions.MpdFailedToSavePlaylist(uri_scheme) @protocol.commands.add("playlistclear") def playlistclear(context, name): """ *musicpd.org, stored playlists section:* ``playlistclear {NAME}`` Clears the playlist ``NAME.m3u``. The playlist will be created if it does not exist. """ _check_playlist_name(name) playlist = _get_playlist(context, name, must_exist=False) if not playlist: playlist = context.core.playlists.create(name).get() # Just replace tracks with empty list and save playlist = playlist.replace(tracks=[]) if context.core.playlists.save(playlist).get() is None: raise exceptions.MpdFailedToSavePlaylist( urllib.parse.urlparse(playlist.uri).scheme ) @protocol.commands.add("playlistdelete", songpos=protocol.UINT) def playlistdelete(context, name, songpos): """ *musicpd.org, stored playlists section:* ``playlistdelete {NAME} {SONGPOS}`` Deletes ``SONGPOS`` from the playlist ``NAME.m3u``. """ _check_playlist_name(name) playlist = _get_playlist(context, name) try: # Convert tracks to list and remove requested tracks = list(playlist.tracks) tracks.pop(songpos) except IndexError: raise exceptions.MpdArgError("Bad song index") # Replace tracks and save playlist playlist = playlist.replace(tracks=tracks) saved_playlist = context.core.playlists.save(playlist).get() if saved_playlist is None: raise exceptions.MpdFailedToSavePlaylist( urllib.parse.urlparse(playlist.uri).scheme ) @protocol.commands.add( "playlistmove", from_pos=protocol.UINT, to_pos=protocol.UINT ) def playlistmove(context, name, from_pos, to_pos): """ *musicpd.org, stored playlists section:* ``playlistmove {NAME} {SONGID} {SONGPOS}`` Moves ``SONGID`` in the playlist ``NAME.m3u`` to the position ``SONGPOS``. *Clarifications:* - The second argument is not a ``SONGID`` as used elsewhere in the protocol documentation, but just the ``SONGPOS`` to move *from*, i.e. ``playlistmove {NAME} {FROM_SONGPOS} {TO_SONGPOS}``. """ if from_pos == to_pos: return _check_playlist_name(name) playlist = _get_playlist(context, name) if from_pos == to_pos: return # Nothing to do try: # Convert tracks to list and perform move tracks = list(playlist.tracks) track = tracks.pop(from_pos) tracks.insert(to_pos, track) except IndexError: raise exceptions.MpdArgError("Bad song index") # Replace tracks and save playlist playlist = playlist.replace(tracks=tracks) saved_playlist = context.core.playlists.save(playlist).get() if saved_playlist is None: raise exceptions.MpdFailedToSavePlaylist( urllib.parse.urlparse(playlist.uri).scheme ) @protocol.commands.add("rename") def rename(context, old_name, new_name): """ *musicpd.org, stored playlists section:* ``rename {NAME} {NEW_NAME}`` Renames the playlist ``NAME.m3u`` to ``NEW_NAME.m3u``. """ _check_playlist_name(old_name) _check_playlist_name(new_name) old_playlist = _get_playlist(context, old_name) if _get_playlist(context, new_name, must_exist=False): raise exceptions.MpdExistError("Playlist already exists") # TODO: should we purge the mapping in an else? # Create copy of the playlist and remove original uri_scheme = urllib.parse.urlparse(old_playlist.uri).scheme new_playlist = context.core.playlists.create(new_name, uri_scheme).get() new_playlist = new_playlist.replace(tracks=old_playlist.tracks) saved_playlist = context.core.playlists.save(new_playlist).get() if saved_playlist is None: raise exceptions.MpdFailedToSavePlaylist(uri_scheme) context.core.playlists.delete(old_playlist.uri).get() @protocol.commands.add("rm") def rm(context, name): """ *musicpd.org, stored playlists section:* ``rm {NAME}`` Removes the playlist ``NAME.m3u`` from the playlist directory. """ _check_playlist_name(name) uri = context.lookup_playlist_uri_from_name(name) if not uri: raise exceptions.MpdNoExistError("No such playlist") context.core.playlists.delete(uri).get() @protocol.commands.add("save") def save(context, name): """ *musicpd.org, stored playlists section:* ``save {NAME}`` Saves the current playlist to ``NAME.m3u`` in the playlist directory. """ _check_playlist_name(name) tracks = context.core.tracklist.get_tracks().get() playlist = _get_playlist(context, name, must_exist=False) if not playlist: # Create new playlist _create_playlist(context, name, tracks) else: # Overwrite existing playlist new_playlist = playlist.replace(tracks=tracks) saved_playlist = context.core.playlists.save(new_playlist).get() if saved_playlist is None: raise exceptions.MpdFailedToSavePlaylist( urllib.parse.urlparse(playlist.uri).scheme ) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607133598.0 Mopidy-MPD-3.1.0/mopidy_mpd/protocol/tagtype_list.py0000644000175100001710000000055700000000000022025 0ustar00runnerdockerTAGTYPE_LIST = [ "Artist", "ArtistSort", "Album", "AlbumArtist", "AlbumArtistSort", "Title", "Track", "Name", "Genre", "Date", "Composer", "Performer", "Disc", "MUSICBRAINZ_ARTISTID", "MUSICBRAINZ_ALBUMID", "MUSICBRAINZ_ALBUMARTISTID", "MUSICBRAINZ_TRACKID", "X-AlbumUri", "X-AlbumImage", ] ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607133598.0 Mopidy-MPD-3.1.0/mopidy_mpd/session.py0000644000175100001710000000315500000000000017134 0ustar00runnerdockerimport logging from mopidy_mpd import dispatcher, formatting, network, protocol logger = logging.getLogger(__name__) class MpdSession(network.LineProtocol): """ The MPD client session. Keeps track of a single client session. Any requests from the client is passed on to the MPD request dispatcher. """ terminator = protocol.LINE_TERMINATOR encoding = protocol.ENCODING delimiter = br"\r?\n" def __init__(self, connection, config=None, core=None, uri_map=None): super().__init__(connection) self.dispatcher = dispatcher.MpdDispatcher( session=self, config=config, core=core, uri_map=uri_map ) def on_start(self): logger.info("New MPD connection from %s", self.connection) self.send_lines([f"OK MPD {protocol.VERSION}"]) def on_line_received(self, line): logger.debug("Request from %s: %s", self.connection, line) response = self.dispatcher.handle_request(line) if not response: return logger.debug( "Response to %s: %s", self.connection, formatting.indent(self.decode(self.terminator).join(response)), ) self.send_lines(response) def on_event(self, subsystem): self.dispatcher.handle_idle(subsystem) def decode(self, line): try: return super().decode(line) except ValueError: logger.warning( "Stopping actor due to unescaping error, data " "supplied by client was not valid." ) self.stop() def close(self): self.stop() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607133598.0 Mopidy-MPD-3.1.0/mopidy_mpd/tokenize.py0000644000175100001710000000624200000000000017301 0ustar00runnerdockerimport re from mopidy_mpd import exceptions WORD_RE = re.compile( r""" ^ (\s*) # Leading whitespace not allowed, capture it to report. ([a-z][a-z0-9_]*) # A command name (?:\s+|$) # trailing whitespace or EOS (.*) # Possibly a remainder to be parsed """, re.VERBOSE, ) # Quotes matching is an unrolled version of "(?:[^"\\]|\\.)*" PARAM_RE = re.compile( r""" ^ # Leading whitespace is not allowed (?: ([^%(unprintable)s"']+) # ord(char) < 0x20, not ", not ' | # or "([^"\\]*(?:\\.[^"\\]*)*)" # anything surrounded by quotes ) (?:\s+|$) # trailing whitespace or EOS (.*) # Possibly a remainder to be parsed """ % {"unprintable": "".join(map(chr, range(0x21)))}, re.VERBOSE, ) BAD_QUOTED_PARAM_RE = re.compile( r""" ^ "[^"\\]*(?:\\.[^"\\]*)* # start of a quoted value (?: # followed by: ("[^\s]) # non-escaped quote, followed by non-whitespace | # or ([^"]) # anything that is not a quote ) """, re.VERBOSE, ) UNESCAPE_RE = re.compile(r"\\(.)") # Backslash escapes any following char. def split(line): """Splits a line into tokens using same rules as MPD. - Lines may not start with whitespace - Tokens are split by arbitrary amount of spaces or tabs - First token must match `[a-z][a-z0-9_]*` - Remaining tokens can be unquoted or quoted tokens. - Unquoted tokens consist of all printable characters except double quotes, single quotes, spaces and tabs. - Quoted tokens are surrounded by a matching pair of double quotes. - The closing quote must be followed by space, tab or end of line. - Any value is allowed inside a quoted token. Including double quotes, assuming it is correctly escaped. - Backslash inside a quoted token is used to escape the following character. For examples see the tests for this function. """ if not line.strip(): raise exceptions.MpdNoCommand("No command given") match = WORD_RE.match(line) if not match: raise exceptions.MpdUnknownError("Invalid word character") whitespace, command, remainder = match.groups() if whitespace: raise exceptions.MpdUnknownError("Letter expected") result = [command] while remainder: match = PARAM_RE.match(remainder) if not match: msg = _determine_error_message(remainder) raise exceptions.MpdArgError(msg, command=command) unquoted, quoted, remainder = match.groups() result.append(unquoted or UNESCAPE_RE.sub(r"\g<1>", quoted)) return result def _determine_error_message(remainder): """Helper to emulate MPD errors.""" # Following checks are simply to match MPD error messages: match = BAD_QUOTED_PARAM_RE.match(remainder) if match: if match.group(1): return "Space expected after closing '\"'" else: return "Missing closing '\"'" return "Invalid unquoted character" ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607133598.0 Mopidy-MPD-3.1.0/mopidy_mpd/translator.py0000644000175100001710000001331600000000000017642 0ustar00runnerdockerimport datetime import logging import re from mopidy.models import TlTrack from mopidy_mpd.protocol import tagtype_list logger = logging.getLogger(__name__) # TODO: special handling of local:// uri scheme normalize_path_re = re.compile(r"[^/]+") def normalize_path(path, relative=False): parts = normalize_path_re.findall(path or "") if not relative: parts.insert(0, "") return "/".join(parts) def track_to_mpd_format(track, position=None, stream_title=None): """ Format track for output to MPD client. :param track: the track :type track: :class:`mopidy.models.Track` or :class:`mopidy.models.TlTrack` :param position: track's position in playlist :type position: integer :param stream_title: the current streams title :type position: string :rtype: list of two-tuples """ if isinstance(track, TlTrack): (tlid, track) = track else: (tlid, track) = (None, track) if not track.uri: logger.warning("Ignoring track without uri") return [] result = [ ("file", track.uri), ("Time", track.length and (track.length // 1000) or 0), ("Artist", concat_multi_values(track.artists, "name")), ("Album", track.album and track.album.name or ""), ] if stream_title is not None: result.append(("Title", stream_title)) if track.name: result.append(("Name", track.name)) else: result.append(("Title", track.name or "")) if track.date: result.append(("Date", track.date)) if track.album is not None and track.album.num_tracks is not None: result.append( ("Track", f"{track.track_no or 0}/{track.album.num_tracks}") ) else: result.append(("Track", track.track_no or 0)) if position is not None and tlid is not None: result.append(("Pos", position)) result.append(("Id", tlid)) if track.album is not None and track.album.musicbrainz_id is not None: result.append(("MUSICBRAINZ_ALBUMID", track.album.musicbrainz_id)) if track.album is not None and track.album.artists: result.append( ("AlbumArtist", concat_multi_values(track.album.artists, "name")) ) musicbrainz_ids = concat_multi_values( track.album.artists, "musicbrainz_id" ) if musicbrainz_ids: result.append(("MUSICBRAINZ_ALBUMARTISTID", musicbrainz_ids)) if track.artists: musicbrainz_ids = concat_multi_values(track.artists, "musicbrainz_id") if musicbrainz_ids: result.append(("MUSICBRAINZ_ARTISTID", musicbrainz_ids)) if track.composers: result.append( ("Composer", concat_multi_values(track.composers, "name")) ) if track.performers: result.append( ("Performer", concat_multi_values(track.performers, "name")) ) if track.genre: result.append(("Genre", track.genre)) if track.disc_no: result.append(("Disc", track.disc_no)) if track.last_modified: datestring = datetime.datetime.utcfromtimestamp( track.last_modified // 1000 ).isoformat() result.append(("Last-Modified", datestring + "Z")) if track.musicbrainz_id is not None: result.append(("MUSICBRAINZ_TRACKID", track.musicbrainz_id)) if track.album and track.album.uri: result.append(("X-AlbumUri", track.album.uri)) result = [element for element in result if _has_value(*element)] return result def _has_value(tagtype, value): """ Determine whether to add the tagtype to the output or not. :param tagtype: the MPD tagtype :type tagtype: string :param value: the tag value :rtype: bool """ if tagtype in tagtype_list.TAGTYPE_LIST: return bool(value) return True def concat_multi_values(models, attribute): """ Format Mopidy model values for output to MPD client. :param models: the models :type models: array of :class:`mopidy.models.Artist`, :class:`mopidy.models.Album` or :class:`mopidy.models.Track` :param attribute: the attribute to use :type attribute: string :rtype: string """ # Don't sort the values. MPD doesn't appear to (or if it does it's not # strict alphabetical). If we just use them in the order in which they come # in then the musicbrainz ids have a higher chance of staying in sync return ";".join( getattr(m, attribute) for m in models if getattr(m, attribute, None) is not None ) def tracks_to_mpd_format(tracks, start=0, end=None): """ Format list of tracks for output to MPD client. Optionally limit output to the slice ``[start:end]`` of the list. :param tracks: the tracks :type tracks: list of :class:`mopidy.models.Track` or :class:`mopidy.models.TlTrack` :param start: position of first track to include in output :type start: int (positive or negative) :param end: position after last track to include in output :type end: int (positive or negative) or :class:`None` for end of list :rtype: list of lists of two-tuples """ if end is None: end = len(tracks) tracks = tracks[start:end] positions = range(start, end) assert len(tracks) == len(positions) result = [] for track, position in zip(tracks, positions): formatted_track = track_to_mpd_format(track, position) if formatted_track: result.append(formatted_track) return result def playlist_to_mpd_format(playlist, *args, **kwargs): """ Format playlist for output to MPD client. Arguments as for :func:`tracks_to_mpd_format`, except the first one. """ return tracks_to_mpd_format(playlist.tracks, *args, **kwargs) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607133598.0 Mopidy-MPD-3.1.0/mopidy_mpd/uri_mapper.py0000644000175100001710000000501400000000000017610 0ustar00runnerdockerimport re # TOOD: refactor this into a generic mapper that does not know about browse # or playlists and then use one instance for each case? class MpdUriMapper: """ Maintains the mappings between uniquified MPD names and URIs. """ #: The Mopidy core API. An instance of :class:`mopidy.core.Core`. core = None _invalid_browse_chars = re.compile(r"[\n\r]") _invalid_playlist_chars = re.compile(r"[/]") def __init__(self, core=None): self.core = core self._uri_from_name = {} self._browse_name_from_uri = {} self._playlist_name_from_uri = {} def _create_unique_name(self, name, uri): stripped_name = self._invalid_browse_chars.sub(" ", name) name = stripped_name i = 2 while name in self._uri_from_name: if self._uri_from_name[name] == uri: return name name = f"{stripped_name} [{i:d}]" i += 1 return name def insert(self, name, uri, playlist=False): """ Create a unique and MPD compatible name that maps to the given URI. """ name = self._create_unique_name(name, uri) self._uri_from_name[name] = uri if playlist: self._playlist_name_from_uri[uri] = name else: self._browse_name_from_uri[uri] = name return name def uri_from_name(self, name): """ Return the uri for the given MPD name. """ return self._uri_from_name.get(name) def refresh_playlists_mapping(self): """ Maintain map between playlists and unique playlist names to be used by MPD. """ if self.core is None: return for playlist_ref in self.core.playlists.as_list().get(): if not playlist_ref.name: continue name = self._invalid_playlist_chars.sub("|", playlist_ref.name) self.insert(name, playlist_ref.uri, playlist=True) def playlist_uri_from_name(self, name): """ Helper function to retrieve a playlist URI from its unique MPD name. """ if name not in self._uri_from_name: self.refresh_playlists_mapping() return self._uri_from_name.get(name) def playlist_name_from_uri(self, uri): """ Helper function to retrieve the unique MPD playlist name from its URI. """ if uri not in self._playlist_name_from_uri: self.refresh_playlists_mapping() return self._playlist_name_from_uri[uri] ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607133598.0 Mopidy-MPD-3.1.0/pyproject.toml0000644000175100001710000000052600000000000015651 0ustar00runnerdocker[build-system] requires = ["setuptools >= 30.3.0", "wheel"] [tool.black] target-version = ["py37", "py38"] line-length = 80 [tool.isort] multi_line_output = 3 include_trailing_comma = true force_grid_wrap = 0 use_parentheses = true line_length = 88 known_tests = "tests" sections = "FUTURE,STDLIB,THIRDPARTY,FIRSTPARTY,TESTS,LOCALFOLDER" ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1607133604.9850466 Mopidy-MPD-3.1.0/setup.cfg0000644000175100001710000000260300000000000014554 0ustar00runnerdocker[metadata] name = Mopidy-MPD version = 3.1.0 url = https://github.com/mopidy/mopidy-mpd author = Stein Magnus Jodal author_email = stein.magnus@jodal.no license = Apache License, Version 2.0 license_file = LICENSE description = Mopidy extension for controlling Mopidy from MPD clients long_description = file: README.rst classifiers = Environment :: No Input/Output (Daemon) Intended Audience :: End Users/Desktop License :: OSI Approved :: Apache Software License Operating System :: OS Independent Programming Language :: Python :: 3 Programming Language :: Python :: 3.7 Programming Language :: Python :: 3.8 Programming Language :: Python :: 3.9 Topic :: Multimedia :: Sound/Audio :: Players [options] zip_safe = False include_package_data = True packages = find: python_requires = >= 3.7 install_requires = Mopidy >= 3.0.0 Pykka >= 2.0.1 setuptools [options.extras_require] lint = black check-manifest flake8 flake8-bugbear flake8-import-order isort[pyproject] release = twine wheel test = pytest pytest-cov dev = %(lint)s %(release)s %(test)s [options.packages.find] exclude = tests tests.* [options.entry_points] mopidy.ext = mpd = mopidy_mpd:Extension [flake8] application-import-names = mopidy_mpd, tests max-line-length = 80 exclude = .git, .tox, build select = C, E, F, W B B950 N ignore = E203 E501 W503 B305 [egg_info] tag_build = tag_date = 0 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607133598.0 Mopidy-MPD-3.1.0/setup.py0000644000175100001710000000004600000000000014444 0ustar00runnerdockerfrom setuptools import setup setup() ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1607133604.9850466 Mopidy-MPD-3.1.0/tests/0000755000175100001710000000000000000000000014074 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607133598.0 Mopidy-MPD-3.1.0/tests/__init__.py0000644000175100001710000000066500000000000016214 0ustar00runnerdockerclass IsA: def __init__(self, klass): self.klass = klass def __eq__(self, rhs): try: return isinstance(rhs, self.klass) except TypeError: return type(rhs) == type(self.klass) # noqa def __ne__(self, rhs): return not self.__eq__(rhs) def __repr__(self): return str(self.klass) any_int = IsA(int) any_str = IsA(str) any_unicode = any_str # TODO remove ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607133598.0 Mopidy-MPD-3.1.0/tests/dummy_audio.py0000644000175100001710000000747400000000000016776 0ustar00runnerdocker"""A dummy audio actor for use in tests. This class implements the audio API in the simplest way possible. It is used in tests of the core and backends. """ import pykka from mopidy import audio def create_proxy(config=None, mixer=None): return DummyAudio.start(config, mixer).proxy() # TODO: reset position on track change? class DummyAudio(pykka.ThreadingActor): def __init__(self, config=None, mixer=None): super().__init__() self.state = audio.PlaybackState.STOPPED self._volume = 0 self._position = 0 self._callback = None self._uri = None self._stream_changed = False self._live_stream = False self._tags = {} self._bad_uris = set() def set_uri(self, uri, live_stream=False, download=False): assert self._uri is None, "prepare change not called before set" self._position = 0 self._uri = uri self._stream_changed = True self._live_stream = live_stream self._tags = {} def set_appsrc(self, *args, **kwargs): pass def emit_data(self, buffer_): pass def get_position(self): return self._position def set_position(self, position): self._position = position audio.AudioListener.send("position_changed", position=position) return True def start_playback(self): return self._change_state(audio.PlaybackState.PLAYING) def pause_playback(self): return self._change_state(audio.PlaybackState.PAUSED) def prepare_change(self): self._uri = None return True def stop_playback(self): return self._change_state(audio.PlaybackState.STOPPED) def get_volume(self): return self._volume def set_volume(self, volume): self._volume = volume return True def set_metadata(self, track): pass def get_current_tags(self): return self._tags def set_about_to_finish_callback(self, callback): self._callback = callback def enable_sync_handler(self): pass def wait_for_state_change(self): pass def _change_state(self, new_state): if not self._uri: return False if new_state == audio.PlaybackState.STOPPED and self._uri: self._stream_changed = True self._uri = None if self._uri is not None: audio.AudioListener.send("position_changed", position=0) if self._stream_changed: self._stream_changed = False audio.AudioListener.send("stream_changed", uri=self._uri) old_state, self.state = self.state, new_state audio.AudioListener.send( "state_changed", old_state=old_state, new_state=new_state, target_state=None, ) if new_state == audio.PlaybackState.PLAYING: self._tags["audio-codec"] = ["fake info..."] audio.AudioListener.send("tags_changed", tags=["audio-codec"]) return self._uri not in self._bad_uris def trigger_fake_playback_failure(self, uri): self._bad_uris.add(uri) def trigger_fake_tags_changed(self, tags): self._tags.update(tags) audio.AudioListener.send("tags_changed", tags=self._tags.keys()) def get_about_to_finish_callback(self): # This needs to be called from outside the actor or we lock up. def wrapper(): if self._callback: self.prepare_change() self._callback() if not self._uri or not self._callback: self._tags = {} audio.AudioListener.send("reached_end_of_stream") else: audio.AudioListener.send("position_changed", position=0) audio.AudioListener.send("stream_changed", uri=self._uri) return wrapper ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607133598.0 Mopidy-MPD-3.1.0/tests/dummy_backend.py0000644000175100001710000001024200000000000017247 0ustar00runnerdocker"""A dummy backend for use in tests. This backend implements the backend API in the simplest way possible. It is used in tests of the frontends. """ import pykka from mopidy import backend from mopidy.models import Playlist, Ref, SearchResult def create_proxy(config=None, audio=None): return DummyBackend.start(config=config, audio=audio).proxy() class DummyBackend(pykka.ThreadingActor, backend.Backend): def __init__(self, config, audio): super().__init__() self.library = DummyLibraryProvider(backend=self) if audio: self.playback = backend.PlaybackProvider(audio=audio, backend=self) else: self.playback = DummyPlaybackProvider(audio=audio, backend=self) self.playlists = DummyPlaylistsProvider(backend=self) self.uri_schemes = ["dummy"] class DummyLibraryProvider(backend.LibraryProvider): root_directory = Ref.directory(uri="dummy:/", name="dummy") def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.dummy_library = [] self.dummy_get_distinct_result = {} self.dummy_browse_result = {} self.dummy_find_exact_result = SearchResult() self.dummy_search_result = SearchResult() def browse(self, path): return self.dummy_browse_result.get(path, []) def get_distinct(self, field, query=None): return self.dummy_get_distinct_result.get(field, set()) def lookup(self, uri): uri = Ref.track(uri=uri).uri return [t for t in self.dummy_library if uri == t.uri] def refresh(self, uri=None): pass def search(self, query=None, uris=None, exact=False): if exact: # TODO: remove uses of dummy_find_exact_result return self.dummy_find_exact_result return self.dummy_search_result class DummyPlaybackProvider(backend.PlaybackProvider): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._uri = None self._time_position = 0 def pause(self): return True def play(self): return self._uri and self._uri != "dummy:error" def change_track(self, track): """Pass a track with URI 'dummy:error' to force failure""" self._uri = track.uri self._time_position = 0 return True def prepare_change(self): pass def resume(self): return True def seek(self, time_position): self._time_position = time_position return True def stop(self): self._uri = None return True def get_time_position(self): return self._time_position class DummyPlaylistsProvider(backend.PlaylistsProvider): def __init__(self, backend): super().__init__(backend) self._playlists = [] self._allow_save = True def set_dummy_playlists(self, playlists): """For tests using the dummy provider through an actor proxy.""" self._playlists = playlists def set_allow_save(self, enabled): self._allow_save = enabled def as_list(self): return [ Ref.playlist(uri=pl.uri, name=pl.name) for pl in self._playlists ] def get_items(self, uri): playlist = self.lookup(uri) if playlist is None: return return [Ref.track(uri=t.uri, name=t.name) for t in playlist.tracks] def lookup(self, uri): uri = Ref.playlist(uri=uri).uri for playlist in self._playlists: if playlist.uri == uri: return playlist def refresh(self): pass def create(self, name): playlist = Playlist(name=name, uri=f"dummy:{name}") self._playlists.append(playlist) return playlist def delete(self, uri): playlist = self.lookup(uri) if playlist: self._playlists.remove(playlist) def save(self, playlist): if not self._allow_save: return None old_playlist = self.lookup(playlist.uri) if old_playlist is not None: index = self._playlists.index(old_playlist) self._playlists[index] = playlist else: self._playlists.append(playlist) return playlist ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607133598.0 Mopidy-MPD-3.1.0/tests/dummy_mixer.py0000644000175100001710000000121500000000000017004 0ustar00runnerdockerimport pykka from mopidy import mixer def create_proxy(config=None): return DummyMixer.start(config=None).proxy() class DummyMixer(pykka.ThreadingActor, mixer.Mixer): def __init__(self, config): super().__init__() self._volume = None self._mute = None def get_volume(self): return self._volume def set_volume(self, volume): self._volume = volume self.trigger_volume_changed(volume=volume) return True def get_mute(self): return self._mute def set_mute(self, mute): self._mute = mute self.trigger_mute_changed(mute=mute) return True ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1607133604.9850466 Mopidy-MPD-3.1.0/tests/network/0000755000175100001710000000000000000000000015565 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607133598.0 Mopidy-MPD-3.1.0/tests/network/__init__.py0000644000175100001710000000000000000000000017664 0ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607133598.0 Mopidy-MPD-3.1.0/tests/network/test_connection.py0000644000175100001710000005315600000000000021347 0ustar00runnerdockerimport errno import logging import socket import unittest from unittest.mock import Mock, call, patch, sentinel import pykka from gi.repository import GLib from mopidy_mpd import network from tests import any_int, any_unicode class ConnectionTest(unittest.TestCase): def setUp(self): # noqa: N802 self.mock = Mock(spec=network.Connection) def test_init_ensure_nonblocking_io(self): sock = Mock(spec=socket.SocketType) network.Connection.__init__( self.mock, Mock(), {}, sock, (sentinel.host, sentinel.port), sentinel.timeout, ) sock.setblocking.assert_called_once_with(False) def test_init_starts_actor(self): protocol = Mock(spec=network.LineProtocol) network.Connection.__init__( self.mock, protocol, {}, Mock(), (sentinel.host, sentinel.port), sentinel.timeout, ) protocol.start.assert_called_once_with(self.mock) def test_init_enables_recv_and_timeout(self): network.Connection.__init__( self.mock, Mock(), {}, Mock(), (sentinel.host, sentinel.port), sentinel.timeout, ) self.mock.enable_recv.assert_called_once_with() self.mock.enable_timeout.assert_called_once_with() def test_init_stores_values_in_attributes(self): addr = (sentinel.host, sentinel.port) protocol = Mock(spec=network.LineProtocol) protocol_kwargs = {} sock = Mock(spec=socket.SocketType) network.Connection.__init__( self.mock, protocol, protocol_kwargs, sock, addr, sentinel.timeout ) assert sock == self.mock._sock assert protocol == self.mock.protocol assert protocol_kwargs == self.mock.protocol_kwargs assert sentinel.timeout == self.mock.timeout assert sentinel.host == self.mock.host assert sentinel.port == self.mock.port def test_init_handles_ipv6_addr(self): addr = ( sentinel.host, sentinel.port, sentinel.flowinfo, sentinel.scopeid, ) protocol = Mock(spec=network.LineProtocol) protocol_kwargs = {} sock = Mock(spec=socket.SocketType) network.Connection.__init__( self.mock, protocol, protocol_kwargs, sock, addr, sentinel.timeout ) assert sentinel.host == self.mock.host assert sentinel.port == self.mock.port def test_stop_disables_recv_send_and_timeout(self): self.mock.stopping = False self.mock.actor_ref = Mock() self.mock._sock = Mock(spec=socket.SocketType) network.Connection.stop(self.mock, sentinel.reason) self.mock.disable_timeout.assert_called_once_with() self.mock.disable_recv.assert_called_once_with() self.mock.disable_send.assert_called_once_with() def test_stop_closes_socket(self): self.mock.stopping = False self.mock.actor_ref = Mock() self.mock._sock = Mock(spec=socket.SocketType) network.Connection.stop(self.mock, sentinel.reason) self.mock._sock.close.assert_called_once_with() def test_stop_closes_socket_error(self): self.mock.stopping = False self.mock.actor_ref = Mock() self.mock._sock = Mock(spec=socket.SocketType) self.mock._sock.close.side_effect = socket.error network.Connection.stop(self.mock, sentinel.reason) self.mock._sock.close.assert_called_once_with() def test_stop_stops_actor(self): self.mock.stopping = False self.mock.actor_ref = Mock() self.mock._sock = Mock(spec=socket.SocketType) network.Connection.stop(self.mock, sentinel.reason) self.mock.actor_ref.stop.assert_called_once_with(block=False) def test_stop_handles_actor_already_being_stopped(self): self.mock.stopping = False self.mock.actor_ref = Mock() self.mock.actor_ref.stop.side_effect = pykka.ActorDeadError() self.mock._sock = Mock(spec=socket.SocketType) network.Connection.stop(self.mock, sentinel.reason) self.mock.actor_ref.stop.assert_called_once_with(block=False) def test_stop_sets_stopping_to_true(self): self.mock.stopping = False self.mock.actor_ref = Mock() self.mock._sock = Mock(spec=socket.SocketType) network.Connection.stop(self.mock, sentinel.reason) assert self.mock.stopping is True def test_stop_does_not_proceed_when_already_stopping(self): self.mock.stopping = True self.mock.actor_ref = Mock() self.mock._sock = Mock(spec=socket.SocketType) network.Connection.stop(self.mock, sentinel.reason) assert 0 == self.mock.actor_ref.stop.call_count assert 0 == self.mock._sock.close.call_count @patch.object(network.logger, "log", new=Mock()) def test_stop_logs_reason(self): self.mock.stopping = False self.mock.actor_ref = Mock() self.mock._sock = Mock(spec=socket.SocketType) network.Connection.stop(self.mock, sentinel.reason) network.logger.log.assert_called_once_with( logging.DEBUG, sentinel.reason ) @patch.object(network.logger, "log", new=Mock()) def test_stop_logs_reason_with_level(self): self.mock.stopping = False self.mock.actor_ref = Mock() self.mock._sock = Mock(spec=socket.SocketType) network.Connection.stop( self.mock, sentinel.reason, level=sentinel.level ) network.logger.log.assert_called_once_with( sentinel.level, sentinel.reason ) @patch.object(network.logger, "log", new=Mock()) def test_stop_logs_that_it_is_calling_itself(self): self.mock.stopping = True self.mock.actor_ref = Mock() self.mock._sock = Mock(spec=socket.SocketType) network.Connection.stop(self.mock, sentinel.reason) network.logger.log(any_int, any_unicode) @patch.object(GLib, "io_add_watch", new=Mock()) def test_enable_recv_registers_with_glib(self): self.mock.recv_id = None self.mock._sock = Mock(spec=socket.SocketType) self.mock._sock.fileno.return_value = sentinel.fileno GLib.io_add_watch.return_value = sentinel.tag network.Connection.enable_recv(self.mock) GLib.io_add_watch.assert_called_once_with( sentinel.fileno, GLib.IO_IN | GLib.IO_ERR | GLib.IO_HUP, self.mock.recv_callback, ) assert sentinel.tag == self.mock.recv_id @patch.object(GLib, "io_add_watch", new=Mock()) def test_enable_recv_already_registered(self): self.mock._sock = Mock(spec=socket.SocketType) self.mock.recv_id = sentinel.tag network.Connection.enable_recv(self.mock) assert 0 == GLib.io_add_watch.call_count def test_enable_recv_does_not_change_tag(self): self.mock.recv_id = sentinel.tag self.mock._sock = Mock(spec=socket.SocketType) network.Connection.enable_recv(self.mock) assert sentinel.tag == self.mock.recv_id @patch.object(GLib, "source_remove", new=Mock()) def test_disable_recv_deregisters(self): self.mock.recv_id = sentinel.tag network.Connection.disable_recv(self.mock) GLib.source_remove.assert_called_once_with(sentinel.tag) assert self.mock.recv_id is None @patch.object(GLib, "source_remove", new=Mock()) def test_disable_recv_already_deregistered(self): self.mock.recv_id = None network.Connection.disable_recv(self.mock) assert 0 == GLib.source_remove.call_count assert self.mock.recv_id is None def test_enable_recv_on_closed_socket(self): self.mock.recv_id = None self.mock._sock = Mock(spec=socket.SocketType) self.mock._sock.fileno.side_effect = socket.error(errno.EBADF, "") network.Connection.enable_recv(self.mock) self.mock.stop.assert_called_once_with(any_unicode) assert self.mock.recv_id is None @patch.object(GLib, "io_add_watch", new=Mock()) def test_enable_send_registers_with_glib(self): self.mock.send_id = None self.mock._sock = Mock(spec=socket.SocketType) self.mock._sock.fileno.return_value = sentinel.fileno GLib.io_add_watch.return_value = sentinel.tag network.Connection.enable_send(self.mock) GLib.io_add_watch.assert_called_once_with( sentinel.fileno, GLib.IO_OUT | GLib.IO_ERR | GLib.IO_HUP, self.mock.send_callback, ) assert sentinel.tag == self.mock.send_id @patch.object(GLib, "io_add_watch", new=Mock()) def test_enable_send_already_registered(self): self.mock._sock = Mock(spec=socket.SocketType) self.mock.send_id = sentinel.tag network.Connection.enable_send(self.mock) assert 0 == GLib.io_add_watch.call_count def test_enable_send_does_not_change_tag(self): self.mock.send_id = sentinel.tag self.mock._sock = Mock(spec=socket.SocketType) network.Connection.enable_send(self.mock) assert sentinel.tag == self.mock.send_id @patch.object(GLib, "source_remove", new=Mock()) def test_disable_send_deregisters(self): self.mock.send_id = sentinel.tag network.Connection.disable_send(self.mock) GLib.source_remove.assert_called_once_with(sentinel.tag) assert self.mock.send_id is None @patch.object(GLib, "source_remove", new=Mock()) def test_disable_send_already_deregistered(self): self.mock.send_id = None network.Connection.disable_send(self.mock) assert 0 == GLib.source_remove.call_count assert self.mock.send_id is None def test_enable_send_on_closed_socket(self): self.mock.send_id = None self.mock._sock = Mock(spec=socket.SocketType) self.mock._sock.fileno.side_effect = socket.error(errno.EBADF, "") network.Connection.enable_send(self.mock) assert self.mock.send_id is None @patch.object(GLib, "timeout_add_seconds", new=Mock()) def test_enable_timeout_clears_existing_timeouts(self): self.mock.timeout = 10 network.Connection.enable_timeout(self.mock) self.mock.disable_timeout.assert_called_once_with() @patch.object(GLib, "timeout_add_seconds", new=Mock()) def test_enable_timeout_add_glib_timeout(self): self.mock.timeout = 10 GLib.timeout_add_seconds.return_value = sentinel.tag network.Connection.enable_timeout(self.mock) GLib.timeout_add_seconds.assert_called_once_with( 10, self.mock.timeout_callback ) assert sentinel.tag == self.mock.timeout_id @patch.object(GLib, "timeout_add_seconds", new=Mock()) def test_enable_timeout_does_not_add_timeout(self): self.mock.timeout = 0 network.Connection.enable_timeout(self.mock) assert 0 == GLib.timeout_add_seconds.call_count self.mock.timeout = -1 network.Connection.enable_timeout(self.mock) assert 0 == GLib.timeout_add_seconds.call_count self.mock.timeout = None network.Connection.enable_timeout(self.mock) assert 0 == GLib.timeout_add_seconds.call_count def test_enable_timeout_does_not_call_disable_for_invalid_timeout(self): self.mock.timeout = 0 network.Connection.enable_timeout(self.mock) assert 0 == self.mock.disable_timeout.call_count self.mock.timeout = -1 network.Connection.enable_timeout(self.mock) assert 0 == self.mock.disable_timeout.call_count self.mock.timeout = None network.Connection.enable_timeout(self.mock) assert 0 == self.mock.disable_timeout.call_count @patch.object(GLib, "source_remove", new=Mock()) def test_disable_timeout_deregisters(self): self.mock.timeout_id = sentinel.tag network.Connection.disable_timeout(self.mock) GLib.source_remove.assert_called_once_with(sentinel.tag) assert self.mock.timeout_id is None @patch.object(GLib, "source_remove", new=Mock()) def test_disable_timeout_already_deregistered(self): self.mock.timeout_id = None network.Connection.disable_timeout(self.mock) assert 0 == GLib.source_remove.call_count assert self.mock.timeout_id is None def test_queue_send_acquires_and_releases_lock(self): self.mock.send_lock = Mock() self.mock.send_buffer = b"" network.Connection.queue_send(self.mock, b"data") self.mock.send_lock.acquire.assert_called_once_with(True) self.mock.send_lock.release.assert_called_once_with() def test_queue_send_calls_send(self): self.mock.send_buffer = b"" self.mock.send_lock = Mock() self.mock.send.return_value = b"" network.Connection.queue_send(self.mock, b"data") self.mock.send.assert_called_once_with(b"data") assert 0 == self.mock.enable_send.call_count assert b"" == self.mock.send_buffer def test_queue_send_calls_enable_send_for_partial_send(self): self.mock.send_buffer = b"" self.mock.send_lock = Mock() self.mock.send.return_value = b"ta" network.Connection.queue_send(self.mock, b"data") self.mock.send.assert_called_once_with(b"data") self.mock.enable_send.assert_called_once_with() assert b"ta" == self.mock.send_buffer def test_queue_send_calls_send_with_existing_buffer(self): self.mock.send_buffer = b"foo" self.mock.send_lock = Mock() self.mock.send.return_value = b"" network.Connection.queue_send(self.mock, b"bar") self.mock.send.assert_called_once_with(b"foobar") assert 0 == self.mock.enable_send.call_count assert b"" == self.mock.send_buffer def test_recv_callback_respects_io_err(self): self.mock._sock = Mock(spec=socket.SocketType) self.mock.actor_ref = Mock() assert network.Connection.recv_callback( self.mock, sentinel.fd, (GLib.IO_IN | GLib.IO_ERR) ) self.mock.stop.assert_called_once_with(any_unicode) def test_recv_callback_respects_io_hup(self): self.mock._sock = Mock(spec=socket.SocketType) self.mock.actor_ref = Mock() assert network.Connection.recv_callback( self.mock, sentinel.fd, (GLib.IO_IN | GLib.IO_HUP) ) self.mock.stop.assert_called_once_with(any_unicode) def test_recv_callback_respects_io_hup_and_io_err(self): self.mock._sock = Mock(spec=socket.SocketType) self.mock.actor_ref = Mock() assert network.Connection.recv_callback( self.mock, sentinel.fd, ((GLib.IO_IN | GLib.IO_HUP) | GLib.IO_ERR) ) self.mock.stop.assert_called_once_with(any_unicode) def test_recv_callback_sends_data_to_actor(self): self.mock._sock = Mock(spec=socket.SocketType) self.mock._sock.recv.return_value = b"data" self.mock.actor_ref = Mock() assert network.Connection.recv_callback( self.mock, sentinel.fd, GLib.IO_IN ) self.mock.actor_ref.tell.assert_called_once_with({"received": b"data"}) def test_recv_callback_handles_dead_actors(self): self.mock._sock = Mock(spec=socket.SocketType) self.mock._sock.recv.return_value = b"data" self.mock.actor_ref = Mock() self.mock.actor_ref.tell.side_effect = pykka.ActorDeadError() assert network.Connection.recv_callback( self.mock, sentinel.fd, GLib.IO_IN ) self.mock.stop.assert_called_once_with(any_unicode) def test_recv_callback_gets_no_data(self): self.mock._sock = Mock(spec=socket.SocketType) self.mock._sock.recv.return_value = b"" self.mock.actor_ref = Mock() assert network.Connection.recv_callback( self.mock, sentinel.fd, GLib.IO_IN ) assert self.mock.mock_calls == [ call._sock.recv(any_int), call.disable_recv(), call.actor_ref.tell({"close": True}), ] def test_recv_callback_recoverable_error(self): self.mock._sock = Mock(spec=socket.SocketType) for error in (errno.EWOULDBLOCK, errno.EINTR): self.mock._sock.recv.side_effect = socket.error(error, "") assert network.Connection.recv_callback( self.mock, sentinel.fd, GLib.IO_IN ) assert 0 == self.mock.stop.call_count def test_recv_callback_unrecoverable_error(self): self.mock._sock = Mock(spec=socket.SocketType) self.mock._sock.recv.side_effect = socket.error assert network.Connection.recv_callback( self.mock, sentinel.fd, GLib.IO_IN ) self.mock.stop.assert_called_once_with(any_unicode) def test_send_callback_respects_io_err(self): self.mock._sock = Mock(spec=socket.SocketType) self.mock._sock.send.return_value = 1 self.mock.send_lock = Mock() self.mock.actor_ref = Mock() self.mock.send_buffer = b"" assert network.Connection.send_callback( self.mock, sentinel.fd, (GLib.IO_IN | GLib.IO_ERR) ) self.mock.stop.assert_called_once_with(any_unicode) def test_send_callback_respects_io_hup(self): self.mock._sock = Mock(spec=socket.SocketType) self.mock._sock.send.return_value = 1 self.mock.send_lock = Mock() self.mock.actor_ref = Mock() self.mock.send_buffer = b"" assert network.Connection.send_callback( self.mock, sentinel.fd, (GLib.IO_IN | GLib.IO_HUP) ) self.mock.stop.assert_called_once_with(any_unicode) def test_send_callback_respects_io_hup_and_io_err(self): self.mock._sock = Mock(spec=socket.SocketType) self.mock._sock.send.return_value = 1 self.mock.send_lock = Mock() self.mock.actor_ref = Mock() self.mock.send_buffer = b"" assert network.Connection.send_callback( self.mock, sentinel.fd, ((GLib.IO_IN | GLib.IO_HUP) | GLib.IO_ERR) ) self.mock.stop.assert_called_once_with(any_unicode) def test_send_callback_acquires_and_releases_lock(self): self.mock.send_lock = Mock() self.mock.send_lock.acquire.return_value = True self.mock.send_buffer = b"" self.mock._sock = Mock(spec=socket.SocketType) self.mock._sock.send.return_value = 0 assert network.Connection.send_callback( self.mock, sentinel.fd, GLib.IO_IN ) self.mock.send_lock.acquire.assert_called_once_with(False) self.mock.send_lock.release.assert_called_once_with() def test_send_callback_fails_to_acquire_lock(self): self.mock.send_lock = Mock() self.mock.send_lock.acquire.return_value = False self.mock.send_buffer = b"" self.mock._sock = Mock(spec=socket.SocketType) self.mock._sock.send.return_value = 0 assert network.Connection.send_callback( self.mock, sentinel.fd, GLib.IO_IN ) self.mock.send_lock.acquire.assert_called_once_with(False) assert 0 == self.mock._sock.send.call_count def test_send_callback_sends_all_data(self): self.mock.send_lock = Mock() self.mock.send_lock.acquire.return_value = True self.mock.send_buffer = b"data" self.mock.send.return_value = b"" assert network.Connection.send_callback( self.mock, sentinel.fd, GLib.IO_IN ) self.mock.disable_send.assert_called_once_with() self.mock.send.assert_called_once_with(b"data") assert b"" == self.mock.send_buffer def test_send_callback_sends_partial_data(self): self.mock.send_lock = Mock() self.mock.send_lock.acquire.return_value = True self.mock.send_buffer = b"data" self.mock.send.return_value = b"ta" assert network.Connection.send_callback( self.mock, sentinel.fd, GLib.IO_IN ) self.mock.send.assert_called_once_with(b"data") assert b"ta" == self.mock.send_buffer def test_send_recoverable_error(self): self.mock._sock = Mock(spec=socket.SocketType) for error in (errno.EWOULDBLOCK, errno.EINTR): self.mock._sock.send.side_effect = socket.error(error, "") network.Connection.send(self.mock, b"data") assert 0 == self.mock.stop.call_count def test_send_calls_socket_send(self): self.mock._sock = Mock(spec=socket.SocketType) self.mock._sock.send.return_value = 4 assert b"" == network.Connection.send(self.mock, b"data") self.mock._sock.send.assert_called_once_with(b"data") def test_send_calls_socket_send_partial_send(self): self.mock._sock = Mock(spec=socket.SocketType) self.mock._sock.send.return_value = 2 assert b"ta" == network.Connection.send(self.mock, b"data") self.mock._sock.send.assert_called_once_with(b"data") def test_send_unrecoverable_error(self): self.mock._sock = Mock(spec=socket.SocketType) self.mock._sock.send.side_effect = socket.error assert b"" == network.Connection.send(self.mock, b"data") self.mock.stop.assert_called_once_with(any_unicode) def test_timeout_callback(self): self.mock.timeout = 10 assert not network.Connection.timeout_callback(self.mock) self.mock.stop.assert_called_once_with(any_unicode) def test_str(self): self.mock.host = "foo" self.mock.port = 999 assert "[foo]:999" == network.Connection.__str__(self.mock) def test_str_without_port(self): self.mock.host = "foo" self.mock.port = None assert "[foo]" == network.Connection.__str__(self.mock) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607133598.0 Mopidy-MPD-3.1.0/tests/network/test_lineprotocol.py0000644000175100001710000002564700000000000021725 0ustar00runnerdockerimport re import unittest from unittest.mock import Mock, sentinel from mopidy_mpd import network from tests import any_unicode class LineProtocolTest(unittest.TestCase): def setUp(self): # noqa: N802 self.mock = Mock(spec=network.LineProtocol) self.mock.terminator = network.LineProtocol.terminator self.mock.encoding = network.LineProtocol.encoding self.mock.delimiter = network.LineProtocol.delimiter self.mock.prevent_timeout = False def prepare_on_receive_test(self, return_value=None): self.mock.connection = Mock(spec=network.Connection) self.mock.recv_buffer = b"" self.mock.parse_lines.return_value = return_value or [] def test_init_stores_values_in_attributes(self): delimiter = re.compile(network.LineProtocol.terminator) network.LineProtocol.__init__(self.mock, sentinel.connection) assert sentinel.connection == self.mock.connection assert b"" == self.mock.recv_buffer assert delimiter == self.mock.delimiter assert not self.mock.prevent_timeout def test_init_compiles_delimiter(self): self.mock.delimiter = "\r?\n" delimiter = re.compile("\r?\n") network.LineProtocol.__init__(self.mock, sentinel.connection) assert delimiter == self.mock.delimiter def test_on_receive_close_calls_stop(self): self.prepare_on_receive_test() network.LineProtocol.on_receive(self.mock, {"close": True}) self.mock.connection.stop.assert_called_once_with(any_unicode) def test_on_receive_no_new_lines_adds_to_recv_buffer(self): self.prepare_on_receive_test() network.LineProtocol.on_receive(self.mock, {"received": b"data"}) assert b"data" == self.mock.recv_buffer self.mock.parse_lines.assert_called_once_with() assert 0 == self.mock.on_line_received.call_count def test_on_receive_toggles_timeout(self): self.prepare_on_receive_test() network.LineProtocol.on_receive(self.mock, {"received": b"data"}) self.mock.connection.disable_timeout.assert_called_once_with() self.mock.connection.enable_timeout.assert_called_once_with() def test_on_receive_toggles_unless_prevent_timeout_is_set(self): self.prepare_on_receive_test() self.mock.prevent_timeout = True network.LineProtocol.on_receive(self.mock, {"received": b"data"}) self.mock.connection.disable_timeout.assert_called_once_with() assert 0 == self.mock.connection.enable_timeout.call_count def test_on_receive_no_new_lines_calls_parse_lines(self): self.prepare_on_receive_test() network.LineProtocol.on_receive(self.mock, {"received": b"data"}) self.mock.parse_lines.assert_called_once_with() assert 0 == self.mock.on_line_received.call_count def test_on_receive_with_new_line_calls_decode(self): self.prepare_on_receive_test([sentinel.line]) network.LineProtocol.on_receive(self.mock, {"received": b"data\n"}) self.mock.parse_lines.assert_called_once_with() self.mock.decode.assert_called_once_with(sentinel.line) def test_on_receive_with_new_line_calls_on_recieve(self): self.prepare_on_receive_test([sentinel.line]) self.mock.decode.return_value = sentinel.decoded network.LineProtocol.on_receive(self.mock, {"received": b"data\n"}) self.mock.on_line_received.assert_called_once_with(sentinel.decoded) def test_on_receive_with_new_line_with_failed_decode(self): self.prepare_on_receive_test([sentinel.line]) self.mock.decode.return_value = None network.LineProtocol.on_receive(self.mock, {"received": b"data\n"}) assert 0 == self.mock.on_line_received.call_count def test_on_receive_with_new_lines_calls_on_recieve(self): self.prepare_on_receive_test(["line1", "line2"]) self.mock.decode.return_value = sentinel.decoded network.LineProtocol.on_receive( self.mock, {"received": b"line1\nline2\n"} ) assert 2 == self.mock.on_line_received.call_count def test_on_failure_calls_stop(self): self.mock.connection = Mock(spec=network.Connection) network.LineProtocol.on_failure(self.mock, None, None, None) self.mock.connection.stop.assert_called_once_with("Actor failed.") def prepare_parse_lines_test(self, recv_data=""): self.mock.terminator = b"\n" self.mock.delimiter = re.compile(br"\n") self.mock.recv_buffer = recv_data.encode() def test_parse_lines_emtpy_buffer(self): self.prepare_parse_lines_test() lines = network.LineProtocol.parse_lines(self.mock) with self.assertRaises(StopIteration): next(lines) def test_parse_lines_no_terminator(self): self.prepare_parse_lines_test("data") lines = network.LineProtocol.parse_lines(self.mock) with self.assertRaises(StopIteration): next(lines) def test_parse_lines_terminator(self): self.prepare_parse_lines_test("data\n") lines = network.LineProtocol.parse_lines(self.mock) assert b"data" == next(lines) with self.assertRaises(StopIteration): next(lines) assert b"" == self.mock.recv_buffer def test_parse_lines_terminator_with_carriage_return(self): self.prepare_parse_lines_test("data\r\n") self.mock.delimiter = re.compile(br"\r?\n") lines = network.LineProtocol.parse_lines(self.mock) assert b"data" == next(lines) with self.assertRaises(StopIteration): next(lines) assert b"" == self.mock.recv_buffer def test_parse_lines_no_data_before_terminator(self): self.prepare_parse_lines_test("\n") lines = network.LineProtocol.parse_lines(self.mock) assert b"" == next(lines) with self.assertRaises(StopIteration): next(lines) assert b"" == self.mock.recv_buffer def test_parse_lines_extra_data_after_terminator(self): self.prepare_parse_lines_test("data1\ndata2") lines = network.LineProtocol.parse_lines(self.mock) assert b"data1" == next(lines) with self.assertRaises(StopIteration): next(lines) assert b"data2" == self.mock.recv_buffer def test_parse_lines_non_ascii(self): self.prepare_parse_lines_test("æøå\n") lines = network.LineProtocol.parse_lines(self.mock) assert "æøå".encode() == next(lines) with self.assertRaises(StopIteration): next(lines) assert b"" == self.mock.recv_buffer def test_parse_lines_multiple_lines(self): self.prepare_parse_lines_test("abc\ndef\nghi\njkl") lines = network.LineProtocol.parse_lines(self.mock) assert b"abc" == next(lines) assert b"def" == next(lines) assert b"ghi" == next(lines) with self.assertRaises(StopIteration): next(lines) assert b"jkl" == self.mock.recv_buffer def test_parse_lines_multiple_calls(self): self.prepare_parse_lines_test("data1") lines = network.LineProtocol.parse_lines(self.mock) with self.assertRaises(StopIteration): next(lines) assert b"data1" == self.mock.recv_buffer self.mock.recv_buffer += b"\ndata2" lines = network.LineProtocol.parse_lines(self.mock) assert b"data1" == next(lines) with self.assertRaises(StopIteration): next(lines) assert b"data2" == self.mock.recv_buffer def test_send_lines_called_with_no_lines(self): self.mock.connection = Mock(spec=network.Connection) network.LineProtocol.send_lines(self.mock, []) assert 0 == self.mock.encode.call_count assert 0 == self.mock.connection.queue_send.call_count def test_send_lines_calls_join_lines(self): self.mock.connection = Mock(spec=network.Connection) self.mock.join_lines.return_value = "lines" network.LineProtocol.send_lines(self.mock, sentinel.lines) self.mock.join_lines.assert_called_once_with(sentinel.lines) def test_send_line_encodes_joined_lines_with_final_terminator(self): self.mock.connection = Mock(spec=network.Connection) self.mock.join_lines.return_value = "lines\n" network.LineProtocol.send_lines(self.mock, sentinel.lines) self.mock.encode.assert_called_once_with("lines\n") def test_send_lines_sends_encoded_string(self): self.mock.connection = Mock(spec=network.Connection) self.mock.join_lines.return_value = "lines" self.mock.encode.return_value = sentinel.data network.LineProtocol.send_lines(self.mock, sentinel.lines) self.mock.connection.queue_send.assert_called_once_with(sentinel.data) def test_join_lines_returns_empty_string_for_no_lines(self): assert "" == network.LineProtocol.join_lines(self.mock, []) def test_join_lines_returns_joined_lines(self): self.mock.decode.return_value = "\n" assert "1\n2\n" == network.LineProtocol.join_lines( self.mock, ["1", "2"] ) def test_decode_calls_decode_on_string(self): string = Mock() network.LineProtocol.decode(self.mock, string) string.decode.assert_called_once_with(self.mock.encoding) def test_decode_plain_ascii(self): result = network.LineProtocol.decode(self.mock, b"abc") assert "abc" == result assert str == type(result) def test_decode_utf8(self): result = network.LineProtocol.decode(self.mock, "æøå".encode()) assert "æøå" == result assert str == type(result) def test_decode_invalid_data(self): string = Mock() string.decode.side_effect = UnicodeError network.LineProtocol.decode(self.mock, string) self.mock.stop.assert_called_once_with() def test_encode_calls_encode_on_string(self): string = Mock() network.LineProtocol.encode(self.mock, string) string.encode.assert_called_once_with(self.mock.encoding) def test_encode_plain_ascii(self): result = network.LineProtocol.encode(self.mock, "abc") assert b"abc" == result assert bytes == type(result) def test_encode_utf8(self): result = network.LineProtocol.encode(self.mock, "æøå") assert "æøå".encode() == result assert bytes == type(result) def test_encode_invalid_data(self): string = Mock() string.encode.side_effect = UnicodeError network.LineProtocol.encode(self.mock, string) self.mock.stop.assert_called_once_with() def test_host_property(self): mock = Mock(spec=network.Connection) mock.host = sentinel.host lineprotocol = network.LineProtocol(mock) assert sentinel.host == lineprotocol.host def test_port_property(self): mock = Mock(spec=network.Connection) mock.port = sentinel.port lineprotocol = network.LineProtocol(mock) assert sentinel.port == lineprotocol.port ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607133598.0 Mopidy-MPD-3.1.0/tests/network/test_server.py0000644000175100001710000003044500000000000020512 0ustar00runnerdockerimport errno import os import socket import unittest from unittest.mock import Mock, patch, sentinel from gi.repository import GLib from mopidy_mpd import network from tests import any_int class ServerTest(unittest.TestCase): def setUp(self): # noqa: N802 self.mock = Mock(spec=network.Server) @patch.object(network, "get_socket_address", new=Mock()) def test_init_calls_create_server_socket(self): network.Server.__init__( self.mock, sentinel.host, sentinel.port, sentinel.protocol ) self.mock.create_server_socket.assert_called_once_with( sentinel.host, sentinel.port ) self.mock.stop() @patch.object(network, "get_socket_address", new=Mock()) def test_init_calls_get_socket_address(self): network.Server.__init__( self.mock, sentinel.host, sentinel.port, sentinel.protocol ) self.mock.create_server_socket.return_value = None network.get_socket_address.assert_called_once_with( sentinel.host, sentinel.port ) self.mock.stop() @patch.object(network, "get_socket_address", new=Mock()) def test_init_calls_register_server(self): sock = Mock(spec=socket.socket) sock.fileno.return_value = sentinel.fileno self.mock.create_server_socket.return_value = sock network.Server.__init__( self.mock, sentinel.host, sentinel.port, sentinel.protocol ) self.mock.register_server_socket.assert_called_once_with( sentinel.fileno ) @patch.object(network, "get_socket_address", new=Mock()) def test_init_fails_on_fileno_call(self): sock = Mock(spec=socket.socket) sock.fileno.side_effect = socket.error self.mock.create_server_socket.return_value = sock with self.assertRaises(socket.error): network.Server.__init__( self.mock, sentinel.host, sentinel.port, sentinel.protocol ) def test_init_stores_values_in_attributes(self): # This need to be a mock and no a sentinel as fileno() is called on it sock = Mock(spec=socket.socket) self.mock.create_server_socket.return_value = sock network.Server.__init__( self.mock, str(sentinel.host), sentinel.port, sentinel.protocol, max_connections=sentinel.max_connections, timeout=sentinel.timeout, ) assert sentinel.protocol == self.mock.protocol assert sentinel.max_connections == self.mock.max_connections assert sentinel.timeout == self.mock.timeout assert sock == self.mock.server_socket assert (str(sentinel.host), sentinel.port) == self.mock.address def test_create_server_socket_no_port(self): with self.assertRaises(TypeError): network.Server.create_server_socket( self.mock, str(sentinel.host), None ) def test_create_server_socket_invalid_port(self): with self.assertRaises(TypeError): network.Server.create_server_socket( self.mock, str(sentinel.host), str(sentinel.port) ) @patch.object(network, "create_tcp_socket", spec=socket.socket) def test_create_server_socket_sets_up_listener(self, create_tcp_socket): sock = create_tcp_socket.return_value network.Server.create_server_socket(self.mock, str(sentinel.host), 1234) sock.setblocking.assert_called_once_with(False) sock.bind.assert_called_once_with((str(sentinel.host), 1234)) sock.listen.assert_called_once_with(any_int) create_tcp_socket.assert_called_once() @patch.object(network, "create_unix_socket", spec=socket.socket) def test_create_server_socket_sets_up_listener_unix( self, create_unix_socket ): sock = create_unix_socket.return_value network.Server.create_server_socket( self.mock, "unix:" + str(sentinel.host), sentinel.port ) sock.setblocking.assert_called_once_with(False) sock.bind.assert_called_once_with(str(sentinel.host)) sock.listen.assert_called_once_with(any_int) create_unix_socket.assert_called_once() @patch.object(network, "create_tcp_socket", new=Mock()) def test_create_server_socket_fails(self): network.create_tcp_socket.side_effect = socket.error with self.assertRaises(socket.error): network.Server.create_server_socket( self.mock, str(sentinel.host), 1234 ) @patch.object(network, "create_unix_socket", new=Mock()) def test_create_server_socket_fails_unix(self): network.create_unix_socket.side_effect = socket.error with self.assertRaises(socket.error): network.Server.create_server_socket( self.mock, "unix:" + str(sentinel.host), sentinel.port ) @patch.object(network, "create_tcp_socket", new=Mock()) def test_create_server_bind_fails(self): sock = network.create_tcp_socket.return_value sock.bind.side_effect = socket.error with self.assertRaises(socket.error): network.Server.create_server_socket( self.mock, str(sentinel.host), 1234 ) @patch.object(network, "create_unix_socket", new=Mock()) def test_create_server_bind_fails_unix(self): sock = network.create_unix_socket.return_value sock.bind.side_effect = socket.error with self.assertRaises(socket.error): network.Server.create_server_socket( self.mock, "unix:" + str(sentinel.host), sentinel.port ) @patch.object(network, "create_tcp_socket", new=Mock()) def test_create_server_listen_fails(self): sock = network.create_tcp_socket.return_value sock.listen.side_effect = socket.error with self.assertRaises(socket.error): network.Server.create_server_socket( self.mock, str(sentinel.host), 1234 ) @patch.object(network, "create_unix_socket", new=Mock()) def test_create_server_listen_fails_unix(self): sock = network.create_unix_socket.return_value sock.listen.side_effect = socket.error with self.assertRaises(socket.error): network.Server.create_server_socket( self.mock, "unix:" + str(sentinel.host), sentinel.port ) @patch.object(os, "unlink", new=Mock()) @patch.object(GLib, "source_remove", new=Mock()) def test_stop_server_cleans_unix_socket(self): self.mock.watcher = Mock() sock = Mock() sock.family = socket.AF_UNIX self.mock.server_socket = sock network.Server.stop(self.mock) os.unlink.assert_called_once_with(sock.getsockname()) @patch.object(GLib, "io_add_watch", new=Mock()) def test_register_server_socket_sets_up_io_watch(self): network.Server.register_server_socket(self.mock, sentinel.fileno) GLib.io_add_watch.assert_called_once_with( sentinel.fileno, GLib.IO_IN, self.mock.handle_connection ) def test_handle_connection(self): self.mock.accept_connection.return_value = ( sentinel.sock, sentinel.addr, ) self.mock.maximum_connections_exceeded.return_value = False assert network.Server.handle_connection( self.mock, sentinel.fileno, GLib.IO_IN ) self.mock.accept_connection.assert_called_once_with() self.mock.maximum_connections_exceeded.assert_called_once_with() self.mock.init_connection.assert_called_once_with( sentinel.sock, sentinel.addr ) assert 0 == self.mock.reject_connection.call_count def test_handle_connection_exceeded_connections(self): self.mock.accept_connection.return_value = ( sentinel.sock, sentinel.addr, ) self.mock.maximum_connections_exceeded.return_value = True assert network.Server.handle_connection( self.mock, sentinel.fileno, GLib.IO_IN ) self.mock.accept_connection.assert_called_once_with() self.mock.maximum_connections_exceeded.assert_called_once_with() self.mock.reject_connection.assert_called_once_with( sentinel.sock, sentinel.addr ) assert 0 == self.mock.init_connection.call_count def test_accept_connection(self): sock = Mock(spec=socket.socket) connected_sock = Mock(spec=socket.socket) sock.accept.return_value = (connected_sock, sentinel.addr) self.mock.server_socket = sock sock, addr = network.Server.accept_connection(self.mock) assert connected_sock == sock assert sentinel.addr == addr def test_accept_connection_unix(self): sock = Mock(spec=socket.socket) connected_sock = Mock(spec=socket.socket) connected_sock.family = socket.AF_UNIX connected_sock.getsockname.return_value = sentinel.sockname sock.accept.return_value = (connected_sock, sentinel.addr) self.mock.server_socket = sock sock, addr = network.Server.accept_connection(self.mock) assert connected_sock == sock assert (sentinel.sockname, None) == addr def test_accept_connection_recoverable_error(self): sock = Mock(spec=socket.socket) self.mock.server_socket = sock for error in (errno.EAGAIN, errno.EINTR): sock.accept.side_effect = socket.error(error, "") with self.assertRaises(network.ShouldRetrySocketCall): network.Server.accept_connection(self.mock) # FIXME decide if this should be allowed to propegate def test_accept_connection_unrecoverable_error(self): sock = Mock(spec=socket.socket) self.mock.server_socket = sock sock.accept.side_effect = socket.error with self.assertRaises(socket.error): network.Server.accept_connection(self.mock) def test_maximum_connections_exceeded(self): self.mock.max_connections = 10 self.mock.number_of_connections.return_value = 11 assert network.Server.maximum_connections_exceeded(self.mock) self.mock.number_of_connections.return_value = 10 assert network.Server.maximum_connections_exceeded(self.mock) self.mock.number_of_connections.return_value = 9 assert not network.Server.maximum_connections_exceeded(self.mock) @patch("pykka.ActorRegistry.get_by_class") def test_number_of_connections(self, get_by_class): self.mock.protocol = sentinel.protocol get_by_class.return_value = [1, 2, 3] assert 3 == network.Server.number_of_connections(self.mock) get_by_class.return_value = [] assert 0 == network.Server.number_of_connections(self.mock) @patch.object(network, "Connection", new=Mock()) def test_init_connection(self): self.mock.protocol = sentinel.protocol self.mock.protocol_kwargs = {} self.mock.timeout = sentinel.timeout network.Server.init_connection(self.mock, sentinel.sock, sentinel.addr) network.Connection.assert_called_once_with( sentinel.protocol, {}, sentinel.sock, sentinel.addr, sentinel.timeout, ) def test_reject_connection(self): sock = Mock(spec=socket.socket) network.Server.reject_connection( self.mock, sock, (sentinel.host, sentinel.port) ) sock.close.assert_called_once_with() @patch.object(network, "format_address", new=Mock()) @patch.object(network.logger, "warning", new=Mock()) def test_reject_connection_message(self): sock = Mock(spec=socket.socket) network.format_address.return_value = sentinel.formatted network.Server.reject_connection( self.mock, sock, (sentinel.host, sentinel.port) ) network.format_address.assert_called_once_with( (sentinel.host, sentinel.port) ) network.logger.warning.assert_called_once_with( "Rejected connection from %s", sentinel.formatted ) def test_reject_connection_error(self): sock = Mock(spec=socket.socket) sock.close.side_effect = socket.error network.Server.reject_connection( self.mock, sock, (sentinel.host, sentinel.port) ) sock.close.assert_called_once_with() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607133598.0 Mopidy-MPD-3.1.0/tests/network/test_utils.py0000644000175100001710000000562200000000000020343 0ustar00runnerdockerimport socket import unittest from unittest.mock import Mock, patch, sentinel from mopidy_mpd import network class FormatHostnameTest(unittest.TestCase): @patch("mopidy_mpd.network.has_ipv6", True) def test_format_hostname_prefixes_ipv4_addresses_when_ipv6_available(self): network.has_ipv6 = True assert network.format_hostname("0.0.0.0") == "::ffff:0.0.0.0" assert network.format_hostname("1.0.0.1") == "::ffff:1.0.0.1" @patch("mopidy_mpd.network.has_ipv6", False) def test_format_hostname_does_nothing_when_only_ipv4_available(self): network.has_ipv6 = False assert network.format_hostname("0.0.0.0") == "0.0.0.0" class FormatAddressTest(unittest.TestCase): def test_format_address_ipv4(self): address = (sentinel.host, sentinel.port) assert ( network.format_address(address) == f"[{sentinel.host}]:{sentinel.port}" ) def test_format_address_ipv6(self): address = (sentinel.host, sentinel.port, sentinel.flow, sentinel.scope) assert ( network.format_address(address) == f"[{sentinel.host}]:{sentinel.port}" ) def test_format_address_unix(self): address = (sentinel.path, None) assert network.format_address(address) == f"[{sentinel.path}]" class GetSocketAddress(unittest.TestCase): def test_get_socket_address(self): host = str(sentinel.host) port = sentinel.port assert network.get_socket_address(host, port) == (host, port) def test_get_socket_address_unix(self): host = str(sentinel.host) port = sentinel.port assert network.get_socket_address(("unix:" + host), port) == ( host, None, ) class TryIPv6SocketTest(unittest.TestCase): @patch("socket.has_ipv6", False) def test_system_that_claims_no_ipv6_support(self): assert not network.try_ipv6_socket() @patch("socket.has_ipv6", True) @patch("socket.socket") def test_system_with_broken_ipv6(self, socket_mock): socket_mock.side_effect = IOError() assert not network.try_ipv6_socket() @patch("socket.has_ipv6", True) @patch("socket.socket") def test_with_working_ipv6(self, socket_mock): socket_mock.return_value = Mock() assert network.try_ipv6_socket() class CreateSocketTest(unittest.TestCase): @patch("mopidy_mpd.network.has_ipv6", False) @patch("socket.socket") def test_ipv4_socket(self, socket_mock): network.create_tcp_socket() assert socket_mock.call_args[0] == (socket.AF_INET, socket.SOCK_STREAM) @patch("mopidy_mpd.network.has_ipv6", True) @patch("socket.socket") def test_ipv6_socket(self, socket_mock): network.create_tcp_socket() assert socket_mock.call_args[0] == (socket.AF_INET6, socket.SOCK_STREAM) @unittest.SkipTest def test_ipv6_only_is_set(self): pass ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607133598.0 Mopidy-MPD-3.1.0/tests/path_utils.py0000644000175100001710000000057700000000000016633 0ustar00runnerdockerimport os # FIXME replace with mock usage in tests. class Mtime: def __init__(self): self.fake = None def __call__(self, path): if self.fake is not None: return self.fake return int(os.stat(path).st_mtime) def set_fake_time(self, time): self.fake = time def undo_fake(self): self.fake = None mtime = Mtime() ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1607133604.9850466 Mopidy-MPD-3.1.0/tests/protocol/0000755000175100001710000000000000000000000015735 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607133598.0 Mopidy-MPD-3.1.0/tests/protocol/__init__.py0000644000175100001710000000540600000000000020053 0ustar00runnerdockerimport unittest from unittest import mock import pykka from mopidy import core from mopidy_mpd import session, uri_mapper from tests import dummy_audio, dummy_backend, dummy_mixer class MockConnection(mock.Mock): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.host = mock.sentinel.host self.port = mock.sentinel.port self.response = [] def queue_send(self, data): data = data.decode() lines = (line for line in data.split("\n") if line) self.response.extend(lines) class BaseTestCase(unittest.TestCase): enable_mixer = True def get_config(self): return { "core": {"max_tracklist_length": 10000}, "mpd": {"password": None, "default_playlist_scheme": "dummy"}, } def setUp(self): # noqa: N802 if self.enable_mixer: self.mixer = dummy_mixer.create_proxy() else: self.mixer = None self.audio = dummy_audio.create_proxy() self.backend = dummy_backend.create_proxy(audio=self.audio) self.core = core.Core.start( self.get_config(), audio=self.audio, mixer=self.mixer, backends=[self.backend], ).proxy() self.uri_map = uri_mapper.MpdUriMapper(self.core) self.connection = MockConnection() self.session = session.MpdSession( self.connection, config=self.get_config(), core=self.core, uri_map=self.uri_map, ) self.dispatcher = self.session.dispatcher self.context = self.dispatcher.context def tearDown(self): # noqa: N802 pykka.ActorRegistry.stop_all() def send_request(self, request): self.connection.response = [] request = f"{request}\n".encode() self.session.on_receive({"received": request}) return self.connection.response def assertNoResponse(self): # noqa: N802 assert [] == self.connection.response def assertInResponse(self, value): # noqa: N802 assert ( value in self.connection.response ), f"Did not find {value!r} in {self.connection.response!r}" def assertOnceInResponse(self, value): # noqa: N802 matched = len([r for r in self.connection.response if r == value]) assert ( 1 == matched ), f"Expected to find {value!r} once in {self.connection.response!r}" def assertNotInResponse(self, value): # noqa: N802 assert ( value not in self.connection.response ), f"Found {value!r} in {self.connection.response!r}" def assertEqualResponse(self, value): # noqa: N802 assert 1 == len(self.connection.response) assert value == self.connection.response[0] ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607133598.0 Mopidy-MPD-3.1.0/tests/protocol/test_audio_output.py0000644000175100001710000001053500000000000022073 0ustar00runnerdockerfrom tests import protocol class AudioOutputHandlerTest(protocol.BaseTestCase): def test_enableoutput(self): self.core.mixer.set_mute(False) self.send_request('enableoutput "0"') self.assertInResponse("OK") assert self.core.mixer.get_mute().get() is True def test_enableoutput_unknown_outputid(self): self.send_request('enableoutput "7"') self.assertInResponse("ACK [50@0] {enableoutput} No such audio output") def test_disableoutput(self): self.core.mixer.set_mute(True) self.send_request('disableoutput "0"') self.assertInResponse("OK") assert self.core.mixer.get_mute().get() is False def test_disableoutput_unknown_outputid(self): self.send_request('disableoutput "7"') self.assertInResponse("ACK [50@0] {disableoutput} No such audio output") def test_outputs_when_unmuted(self): self.core.mixer.set_mute(False) self.send_request("outputs") self.assertInResponse("outputid: 0") self.assertInResponse("outputname: Mute") self.assertInResponse("outputenabled: 0") self.assertInResponse("OK") def test_outputs_when_muted(self): self.core.mixer.set_mute(True) self.send_request("outputs") self.assertInResponse("outputid: 0") self.assertInResponse("outputname: Mute") self.assertInResponse("outputenabled: 1") self.assertInResponse("OK") def test_outputs_toggleoutput(self): self.core.mixer.set_mute(False) self.send_request('toggleoutput "0"') self.send_request("outputs") self.assertInResponse("outputid: 0") self.assertInResponse("outputname: Mute") self.assertInResponse("outputenabled: 1") self.assertInResponse("OK") self.send_request('toggleoutput "0"') self.send_request("outputs") self.assertInResponse("outputid: 0") self.assertInResponse("outputname: Mute") self.assertInResponse("outputenabled: 0") self.assertInResponse("OK") self.send_request('toggleoutput "0"') self.send_request("outputs") self.assertInResponse("outputid: 0") self.assertInResponse("outputname: Mute") self.assertInResponse("outputenabled: 1") self.assertInResponse("OK") def test_outputs_toggleoutput_unknown_outputid(self): self.send_request('toggleoutput "7"') self.assertInResponse("ACK [50@0] {toggleoutput} No such audio output") class AudioOutputHandlerNoneMixerTest(protocol.BaseTestCase): enable_mixer = False def test_enableoutput(self): assert self.core.mixer.get_mute().get() is None self.send_request('enableoutput "0"') self.assertInResponse( "ACK [52@0] {enableoutput} problems enabling output" ) assert self.core.mixer.get_mute().get() is None def test_disableoutput(self): assert self.core.mixer.get_mute().get() is None self.send_request('disableoutput "0"') self.assertInResponse( "ACK [52@0] {disableoutput} problems disabling output" ) assert self.core.mixer.get_mute().get() is None def test_outputs_when_unmuted(self): self.core.mixer.set_mute(False) self.send_request("outputs") self.assertInResponse("outputid: 0") self.assertInResponse("outputname: Mute") self.assertInResponse("outputenabled: 0") self.assertInResponse("OK") def test_outputs_when_muted(self): self.core.mixer.set_mute(True) self.send_request("outputs") self.assertInResponse("outputid: 0") self.assertInResponse("outputname: Mute") self.assertInResponse("outputenabled: 0") self.assertInResponse("OK") def test_outputs_toggleoutput(self): self.core.mixer.set_mute(False) self.send_request('toggleoutput "0"') self.send_request("outputs") self.assertInResponse("outputid: 0") self.assertInResponse("outputname: Mute") self.assertInResponse("outputenabled: 0") self.assertInResponse("OK") self.send_request('toggleoutput "0"') self.send_request("outputs") self.assertInResponse("outputid: 0") self.assertInResponse("outputname: Mute") self.assertInResponse("outputenabled: 0") self.assertInResponse("OK") ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607133598.0 Mopidy-MPD-3.1.0/tests/protocol/test_authentication.py0000644000175100001710000000455500000000000022376 0ustar00runnerdockerfrom tests import protocol class AuthenticationActiveTest(protocol.BaseTestCase): def get_config(self): config = super().get_config() config["mpd"]["password"] = "topsecret" return config def test_authentication_with_valid_password_is_accepted(self): self.send_request('password "topsecret"') assert self.dispatcher.authenticated self.assertInResponse("OK") def test_authentication_with_invalid_password_is_not_accepted(self): self.send_request('password "secret"') assert not self.dispatcher.authenticated self.assertEqualResponse("ACK [3@0] {password} incorrect password") def test_authentication_without_password_fails(self): self.send_request("password") assert not self.dispatcher.authenticated self.assertEqualResponse( 'ACK [2@0] {password} wrong number of arguments for "password"' ) def test_anything_when_not_authenticated_should_fail(self): self.send_request("any request at all") assert not self.dispatcher.authenticated self.assertEqualResponse( 'ACK [4@0] {any} you don\'t have permission for "any"' ) def test_close_is_allowed_without_authentication(self): self.send_request("close") assert not self.dispatcher.authenticated def test_commands_is_allowed_without_authentication(self): self.send_request("commands") assert not self.dispatcher.authenticated self.assertInResponse("OK") def test_notcommands_is_allowed_without_authentication(self): self.send_request("notcommands") assert not self.dispatcher.authenticated self.assertInResponse("OK") def test_ping_is_allowed_without_authentication(self): self.send_request("ping") assert not self.dispatcher.authenticated self.assertInResponse("OK") class AuthenticationInactiveTest(protocol.BaseTestCase): def test_authentication_with_anything_when_password_check_turned_off(self): self.send_request("any request at all") assert self.dispatcher.authenticated self.assertEqualResponse('ACK [5@0] {} unknown command "any"') def test_any_password_is_not_accepted_when_password_check_turned_off(self): self.send_request('password "secret"') self.assertEqualResponse("ACK [3@0] {password} incorrect password") ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607133598.0 Mopidy-MPD-3.1.0/tests/protocol/test_channels.py0000644000175100001710000000152400000000000021143 0ustar00runnerdockerfrom tests import protocol class ChannelsHandlerTest(protocol.BaseTestCase): def test_subscribe(self): self.send_request('subscribe "topic"') self.assertEqualResponse("ACK [0@0] {subscribe} Not implemented") def test_unsubscribe(self): self.send_request('unsubscribe "topic"') self.assertEqualResponse("ACK [0@0] {unsubscribe} Not implemented") def test_channels(self): self.send_request("channels") self.assertEqualResponse("ACK [0@0] {channels} Not implemented") def test_readmessages(self): self.send_request("readmessages") self.assertEqualResponse("ACK [0@0] {readmessages} Not implemented") def test_sendmessage(self): self.send_request('sendmessage "topic" "a message"') self.assertEqualResponse("ACK [0@0] {sendmessage} Not implemented") ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607133598.0 Mopidy-MPD-3.1.0/tests/protocol/test_command_list.py0000644000175100001710000000470300000000000022023 0ustar00runnerdockerfrom tests import protocol class CommandListsTest(protocol.BaseTestCase): def test_command_list_begin(self): response = self.send_request("command_list_begin") assert [] == response def test_command_list_end(self): self.send_request("command_list_begin") self.send_request("command_list_end") self.assertInResponse("OK") def test_command_list_end_without_start_first_is_an_unknown_command(self): self.send_request("command_list_end") self.assertEqualResponse( 'ACK [5@0] {} unknown command "command_list_end"' ) def test_command_list_with_ping(self): self.send_request("command_list_begin") assert self.dispatcher.command_list_receiving assert not self.dispatcher.command_list_ok assert [] == self.dispatcher.command_list self.send_request("ping") assert "ping" in self.dispatcher.command_list self.send_request("command_list_end") self.assertInResponse("OK") assert not self.dispatcher.command_list_receiving assert not self.dispatcher.command_list_ok assert [] == self.dispatcher.command_list def test_command_list_with_error_returns_ack_with_correct_index(self): self.send_request("command_list_begin") self.send_request("play") # Known command self.send_request("paly") # Unknown command self.send_request("command_list_end") self.assertEqualResponse('ACK [5@1] {} unknown command "paly"') def test_command_list_ok_begin(self): response = self.send_request("command_list_ok_begin") assert [] == response def test_command_list_ok_with_ping(self): self.send_request("command_list_ok_begin") assert self.dispatcher.command_list_receiving assert self.dispatcher.command_list_ok assert [] == self.dispatcher.command_list self.send_request("ping") assert "ping" in self.dispatcher.command_list self.send_request("command_list_end") self.assertInResponse("list_OK") self.assertInResponse("OK") assert not self.dispatcher.command_list_receiving assert not self.dispatcher.command_list_ok assert [] == self.dispatcher.command_list # FIXME this should also include the special handling of idle within a # command list. That is that once a idle/noidle command is found inside a # commad list, the rest of the list seems to be ignored. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607133598.0 Mopidy-MPD-3.1.0/tests/protocol/test_connection.py0000644000175100001710000000153600000000000021512 0ustar00runnerdockerfrom unittest.mock import patch from tests import protocol class ConnectionHandlerTest(protocol.BaseTestCase): def test_close_closes_the_client_connection(self): with patch.object(self.session, "close") as close_mock: self.send_request("close") close_mock.assert_called_once_with() self.assertEqualResponse("OK") def test_empty_request(self): self.send_request("") self.assertEqualResponse("ACK [5@0] {} No command given") self.send_request(" ") self.assertEqualResponse("ACK [5@0] {} No command given") def test_kill(self): self.send_request("kill") self.assertEqualResponse( 'ACK [4@0] {kill} you don\'t have permission for "kill"' ) def test_ping(self): self.send_request("ping") self.assertEqualResponse("OK") ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607133598.0 Mopidy-MPD-3.1.0/tests/protocol/test_current_playlist.py0000644000175100001710000004415100000000000022756 0ustar00runnerdockerfrom mopidy.models import Ref, Track from tests import protocol class AddCommandsTest(protocol.BaseTestCase): def setUp(self): # noqa: N802 super().setUp() self.tracks = [ Track(uri="dummy:/a", name="a"), Track(uri="dummy:/foo/b", name="b"), Track(uri="dummy:/foo/ǂ", name="ǂ"), ] self.refs = { "/a": Ref.track(uri="dummy:/a", name="a"), "/foo": Ref.directory(uri="dummy:/foo", name="foo"), "/foo/b": Ref.track(uri="dummy:/foo/b", name="b"), "/foo/ǂ": Ref.track(uri="dummy:/foo/ǂ", name="ǂ"), } self.backend.library.dummy_library = self.tracks def test_add(self): for track in [self.tracks[0], self.tracks[0], self.tracks[1]]: self.send_request(f'add "{track.uri}"') assert len(self.core.tracklist.get_tracks().get()) == 3 assert self.core.tracklist.get_tracks().get()[2] == self.tracks[1] self.assertEqualResponse("OK") def test_add_unicode(self): for track in [self.tracks[0], self.tracks[1], self.tracks[2]]: self.send_request(f'add "{track.uri}"') assert len(self.core.tracklist.get_tracks().get()) == 3 assert self.core.tracklist.get_tracks().get()[2] == self.tracks[2] self.assertEqualResponse("OK") def test_add_with_uri_not_found_in_library_should_ack(self): self.send_request('add "dummy://foo"') self.assertEqualResponse("ACK [50@0] {add} directory or file not found") def test_add_with_empty_uri_should_not_add_anything_and_ok(self): self.backend.library.dummy_browse_result = { "dummy:/": [self.refs["/a"]] } self.send_request('add ""') assert len(self.core.tracklist.get_tracks().get()) == 0 self.assertInResponse("OK") def test_add_with_library_should_recurse(self): self.backend.library.dummy_browse_result = { "dummy:/": [self.refs["/a"], self.refs["/foo"]], "dummy:/foo": [self.refs["/foo/b"], self.refs["/foo/ǂ"]], } self.send_request('add "/dummy"') assert self.core.tracklist.get_tracks().get() == self.tracks self.assertInResponse("OK") def test_add_root_should_not_add_anything_and_ok(self): self.backend.library.dummy_browse_result = { "dummy:/": [self.refs["/a"]] } self.send_request('add "/"') assert len(self.core.tracklist.get_tracks().get()) == 0 self.assertInResponse("OK") def test_addid_without_songpos(self): for track in [self.tracks[0], self.tracks[0], self.tracks[1]]: self.send_request(f'addid "{track.uri}"') tl_tracks = self.core.tracklist.get_tl_tracks().get() assert len(tl_tracks) == 3 assert tl_tracks[2].track == self.tracks[1] self.assertInResponse(f"Id: {tl_tracks[2].tlid:d}") self.assertInResponse("OK") def test_addid_with_songpos(self): for track in [self.tracks[0], self.tracks[0]]: self.send_request(f'add "{track.uri}"') self.send_request(f'addid "{self.tracks[1].uri}" "1"') tl_tracks = self.core.tracklist.get_tl_tracks().get() assert len(tl_tracks) == 3 assert tl_tracks[1].track == self.tracks[1] self.assertInResponse(f"Id: {tl_tracks[1].tlid:d}") self.assertInResponse("OK") def test_addid_with_songpos_out_of_bounds_should_ack(self): self.send_request(f'addid "{self.tracks[0].uri}" "3"') self.assertEqualResponse("ACK [2@0] {addid} Bad song index") def test_addid_with_empty_uri_acks(self): self.send_request('addid ""') self.assertEqualResponse("ACK [50@0] {addid} No such song") def test_addid_with_uri_not_found_in_library_should_ack(self): self.send_request('addid "dummy://foo"') self.assertEqualResponse("ACK [50@0] {addid} No such song") class BasePopulatedTracklistTestCase(protocol.BaseTestCase): def setUp(self): # noqa: N802 super().setUp() tracks = [Track(uri=f"dummy:/{x}", name=x) for x in "abcdeǂ"] self.backend.library.dummy_library = tracks self.core.tracklist.add(uris=[t.uri for t in tracks]) class DeleteCommandsTest(BasePopulatedTracklistTestCase): def test_clear(self): self.send_request("clear") assert len(self.core.tracklist.get_tracks().get()) == 0 assert self.core.playback.get_current_track().get() is None self.assertInResponse("OK") def test_delete_songpos(self): tl_tracks = self.core.tracklist.get_tl_tracks().get() self.send_request(f'delete "{tl_tracks[1].tlid}"') assert len(self.core.tracklist.get_tracks().get()) == 5 self.assertInResponse("OK") def test_delete_songpos_out_of_bounds(self): self.send_request('delete "8"') assert len(self.core.tracklist.get_tracks().get()) == 6 self.assertEqualResponse("ACK [2@0] {delete} Bad song index") def test_delete_open_range(self): self.send_request('delete "1:"') assert len(self.core.tracklist.get_tracks().get()) == 1 self.assertInResponse("OK") # TODO: check how this should work. # def test_delete_open_upper_range(self): # self.send_request('delete ":8"') # self.assertEqual(len(self.core.tracklist.get_tracks().get()), 0) # self.assertInResponse('OK') def test_delete_closed_range(self): self.send_request('delete "1:3"') assert len(self.core.tracklist.get_tracks().get()) == 4 self.assertInResponse("OK") def test_delete_entire_range_out_of_bounds(self): self.send_request('delete "8:9"') assert len(self.core.tracklist.get_tracks().get()) == 6 self.assertEqualResponse("ACK [2@0] {delete} Bad song index") def test_delete_upper_range_out_of_bounds(self): self.send_request('delete "5:9"') assert len(self.core.tracklist.get_tracks().get()) == 5 self.assertEqualResponse("OK") def test_deleteid(self): self.send_request('deleteid "1"') assert len(self.core.tracklist.get_tracks().get()) == 5 self.assertInResponse("OK") def test_deleteid_does_not_exist(self): self.send_request('deleteid "12345"') assert len(self.core.tracklist.get_tracks().get()) == 6 self.assertEqualResponse("ACK [50@0] {deleteid} No such song") class MoveCommandsTest(BasePopulatedTracklistTestCase): def test_move_songpos(self): self.send_request('move "1" "0"') result = [t.name for t in self.core.tracklist.get_tracks().get()] assert result == ["b", "a", "c", "d", "e", "ǂ"] self.assertInResponse("OK") def test_move_open_range(self): self.send_request('move "2:" "0"') result = [t.name for t in self.core.tracklist.get_tracks().get()] assert result == ["c", "d", "e", "ǂ", "a", "b"] self.assertInResponse("OK") def test_move_closed_range(self): self.send_request('move "1:3" "0"') result = [t.name for t in self.core.tracklist.get_tracks().get()] assert result == ["b", "c", "a", "d", "e", "ǂ"] self.assertInResponse("OK") def test_moveid(self): self.send_request('moveid "5" "2"') result = [t.name for t in self.core.tracklist.get_tracks().get()] assert result == ["a", "b", "e", "c", "d", "ǂ"] self.assertInResponse("OK") def test_moveid_with_tlid_not_found_in_tracklist_should_ack(self): self.send_request('moveid "10" "0"') self.assertEqualResponse("ACK [50@0] {moveid} No such song") class PlaylistFindCommandTest(protocol.BaseTestCase): def test_playlistfind(self): self.send_request('playlistfind "tag" "needle"') self.assertEqualResponse("ACK [0@0] {playlistfind} Not implemented") def test_playlistfind_by_filename_not_in_tracklist(self): self.send_request('playlistfind "filename" "file:///dev/null"') self.assertEqualResponse("OK") def test_playlistfind_by_filename_without_quotes(self): self.send_request('playlistfind filename "file:///dev/null"') self.assertEqualResponse("OK") def test_playlistfind_by_filename_in_tracklist(self): track = Track(uri="dummy:///exists") self.backend.library.dummy_library = [track] self.core.tracklist.add(uris=[track.uri]) self.send_request('playlistfind filename "dummy:///exists"') self.assertInResponse("file: dummy:///exists") self.assertInResponse("Id: 1") self.assertInResponse("Pos: 0") self.assertInResponse("OK") class PlaylistIdCommandTest(BasePopulatedTracklistTestCase): def test_playlistid_without_songid(self): self.send_request("playlistid") self.assertInResponse("Title: a") self.assertInResponse("Title: b") self.assertInResponse("OK") def test_playlistid_with_songid(self): self.send_request('playlistid "2"') self.assertNotInResponse("Title: a") self.assertNotInResponse("Id: 1") self.assertInResponse("Title: b") self.assertInResponse("Id: 2") self.assertInResponse("OK") def test_playlistid_with_not_existing_songid_fails(self): self.send_request('playlistid "25"') self.assertEqualResponse("ACK [50@0] {playlistid} No such song") class PlaylistInfoCommandTest(BasePopulatedTracklistTestCase): def test_playlist_returns_same_as_playlistinfo(self): playlist_response = self.send_request("playlist") playlistinfo_response = self.send_request("playlistinfo") assert playlist_response == playlistinfo_response def test_playlistinfo_without_songpos_or_range(self): self.send_request("playlistinfo") self.assertInResponse("Title: a") self.assertInResponse("Pos: 0") self.assertInResponse("Title: b") self.assertInResponse("Pos: 1") self.assertInResponse("Title: c") self.assertInResponse("Pos: 2") self.assertInResponse("Title: d") self.assertInResponse("Pos: 3") self.assertInResponse("Title: e") self.assertInResponse("Pos: 4") self.assertInResponse("Title: ǂ") self.assertInResponse("Pos: 5") self.assertInResponse("OK") def test_playlistinfo_with_songpos(self): # Make the track's CPID not match the playlist position self.core.tracklist.tlid = 17 self.send_request('playlistinfo "4"') self.assertNotInResponse("Title: a") self.assertNotInResponse("Pos: 0") self.assertNotInResponse("Title: b") self.assertNotInResponse("Pos: 1") self.assertNotInResponse("Title: c") self.assertNotInResponse("Pos: 2") self.assertNotInResponse("Title: d") self.assertNotInResponse("Pos: 3") self.assertInResponse("Title: e") self.assertInResponse("Pos: 4") self.assertNotInResponse("Title: ǂ") self.assertNotInResponse("Pos: 5") self.assertInResponse("OK") def test_playlistinfo_with_negative_songpos_same_as_playlistinfo(self): response1 = self.send_request('playlistinfo "-1"') response2 = self.send_request("playlistinfo") assert response1 == response2 def test_playlistinfo_with_open_range(self): self.send_request('playlistinfo "2:"') self.assertNotInResponse("Title: a") self.assertNotInResponse("Pos: 0") self.assertNotInResponse("Title: b") self.assertNotInResponse("Pos: 1") self.assertInResponse("Title: c") self.assertInResponse("Pos: 2") self.assertInResponse("Title: d") self.assertInResponse("Pos: 3") self.assertInResponse("Title: e") self.assertInResponse("Pos: 4") self.assertInResponse("Title: ǂ") self.assertInResponse("Pos: 5") self.assertInResponse("OK") def test_playlistinfo_with_closed_range(self): self.send_request('playlistinfo "2:4"') self.assertNotInResponse("Title: a") self.assertNotInResponse("Title: b") self.assertInResponse("Title: c") self.assertInResponse("Title: d") self.assertNotInResponse("Title: e") self.assertNotInResponse("Title: ǂ") self.assertInResponse("OK") def test_playlistinfo_with_too_high_start_of_range_returns_arg_error(self): self.send_request('playlistinfo "10:20"') self.assertEqualResponse("ACK [2@0] {playlistinfo} Bad song index") def test_playlistinfo_with_too_high_end_of_range_returns_ok(self): self.send_request('playlistinfo "0:20"') self.assertInResponse("OK") def test_playlistinfo_with_zero_returns_ok(self): self.send_request('playlistinfo "0"') self.assertInResponse("OK") class PlaylistSearchCommandTest(protocol.BaseTestCase): def test_playlistsearch(self): self.send_request('playlistsearch "any" "needle"') self.assertEqualResponse("ACK [0@0] {playlistsearch} Not implemented") def test_playlistsearch_without_quotes(self): self.send_request('playlistsearch any "needle"') self.assertEqualResponse("ACK [0@0] {playlistsearch} Not implemented") class PlChangeCommandTest(BasePopulatedTracklistTestCase): def test_plchanges_with_lower_version_returns_changes(self): self.send_request('plchanges "0"') self.assertInResponse("Title: a") self.assertInResponse("Title: b") self.assertInResponse("Title: c") self.assertInResponse("OK") def test_plchanges_with_equal_version_returns_nothing(self): assert self.core.tracklist.get_version().get() == 1 self.send_request('plchanges "1"') self.assertNotInResponse("Title: a") self.assertNotInResponse("Title: b") self.assertNotInResponse("Title: c") self.assertInResponse("OK") def test_plchanges_with_greater_version_returns_nothing(self): assert self.core.tracklist.get_version().get() == 1 self.send_request('plchanges "2"') self.assertNotInResponse("Title: a") self.assertNotInResponse("Title: b") self.assertNotInResponse("Title: c") self.assertInResponse("OK") def test_plchanges_with_minus_one_returns_entire_playlist(self): self.send_request('plchanges "-1"') self.assertInResponse("Title: a") self.assertInResponse("Title: b") self.assertInResponse("Title: c") self.assertInResponse("OK") def test_plchanges_without_quotes_works(self): self.send_request("plchanges 0") self.assertInResponse("Title: a") self.assertInResponse("Title: b") self.assertInResponse("Title: c") self.assertInResponse("OK") def test_plchangesposid(self): self.send_request('plchangesposid "0"') tl_tracks = self.core.tracklist.get_tl_tracks().get() self.assertInResponse("cpos: 0") self.assertInResponse(f"Id: {tl_tracks[0].tlid:d}") self.assertInResponse("cpos: 2") self.assertInResponse(f"Id: {tl_tracks[1].tlid:d}") self.assertInResponse("cpos: 2") self.assertInResponse(f"Id: {tl_tracks[2].tlid:d}") self.assertInResponse("OK") class PrioCommandTest(protocol.BaseTestCase): def test_prio(self): self.send_request("prio 255 0:10") self.assertEqualResponse("ACK [0@0] {prio} Not implemented") def test_prioid(self): self.send_request("prioid 255 17 23") self.assertEqualResponse("ACK [0@0] {prioid} Not implemented") class RangeIdCommandTest(protocol.BaseTestCase): def test_rangeid(self): self.send_request("rangeid 17 0:30") self.assertEqualResponse("ACK [0@0] {rangeid} Not implemented") # TODO: we only seem to be testing that don't touch the non shuffled region :/ class ShuffleCommandTest(BasePopulatedTracklistTestCase): def test_shuffle_without_range(self): version = self.core.tracklist.get_version().get() self.send_request("shuffle") assert version < self.core.tracklist.get_version().get() self.assertInResponse("OK") def test_shuffle_with_open_range(self): version = self.core.tracklist.get_version().get() self.send_request('shuffle "4:"') assert version < self.core.tracklist.get_version().get() result = [t.name for t in self.core.tracklist.get_tracks().get()] assert result[:4] == ["a", "b", "c", "d"] self.assertInResponse("OK") def test_shuffle_with_closed_range(self): version = self.core.tracklist.get_version().get() self.send_request('shuffle "1:3"') assert version < self.core.tracklist.get_version().get() result = [t.name for t in self.core.tracklist.get_tracks().get()] assert result[:1] == ["a"] assert result[3:] == ["d", "e", "ǂ"] self.assertInResponse("OK") class SwapCommandTest(BasePopulatedTracklistTestCase): def test_swap(self): self.send_request('swap "1" "4"') result = [t.name for t in self.core.tracklist.get_tracks().get()] assert result == ["a", "e", "c", "d", "b", "ǂ"] self.assertInResponse("OK") def test_swap_highest_position_first(self): self.send_request('swap "4" "1"') result = [t.name for t in self.core.tracklist.get_tracks().get()] assert result == ["a", "e", "c", "d", "b", "ǂ"] self.assertInResponse("OK") def test_swapid(self): self.send_request('swapid "2" "5"') result = [t.name for t in self.core.tracklist.get_tracks().get()] assert result == ["a", "e", "c", "d", "b", "ǂ"] self.assertInResponse("OK") def test_swapid_with_first_id_unknown_should_ack(self): self.send_request('swapid "1" "8"') self.assertEqualResponse("ACK [50@0] {swapid} No such song") def test_swapid_with_second_id_unknown_should_ack(self): self.send_request('swapid "8" "1"') self.assertEqualResponse("ACK [50@0] {swapid} No such song") class TagCommandTest(protocol.BaseTestCase): def test_addtagid(self): self.send_request("addtagid 17 artist Abba") self.assertEqualResponse("ACK [0@0] {addtagid} Not implemented") def test_cleartagid(self): self.send_request("cleartagid 17 artist") self.assertEqualResponse("ACK [0@0] {cleartagid} Not implemented") ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607133598.0 Mopidy-MPD-3.1.0/tests/protocol/test_idle.py0000644000175100001710000001761200000000000020272 0ustar00runnerdockerfrom unittest.mock import patch from mopidy_mpd.protocol.status import SUBSYSTEMS from tests import protocol class IdleHandlerTest(protocol.BaseTestCase): def idle_event(self, subsystem): self.session.on_event(subsystem) def assertEqualEvents(self, events): # noqa: N802 assert set(events) == self.context.events def assertEqualSubscriptions(self, events): # noqa: N802 assert set(events) == self.context.subscriptions def assertNoEvents(self): # noqa: N802 self.assertEqualEvents([]) def assertNoSubscriptions(self): # noqa: N802 self.assertEqualSubscriptions([]) def test_base_state(self): self.assertNoSubscriptions() self.assertNoEvents() self.assertNoResponse() def test_idle(self): self.send_request("idle") self.assertEqualSubscriptions(SUBSYSTEMS) self.assertNoEvents() self.assertNoResponse() def test_idle_disables_timeout(self): self.send_request("idle") self.connection.disable_timeout.assert_called_once_with() def test_noidle(self): self.send_request("noidle") self.assertNoSubscriptions() self.assertNoEvents() self.assertNoResponse() def test_idle_player(self): self.send_request("idle player") self.assertEqualSubscriptions(["player"]) self.assertNoEvents() self.assertNoResponse() def test_idle_output(self): self.send_request("idle output") self.assertEqualSubscriptions(["output"]) self.assertNoEvents() self.assertNoResponse() def test_idle_player_playlist(self): self.send_request("idle player playlist") self.assertEqualSubscriptions(["player", "playlist"]) self.assertNoEvents() self.assertNoResponse() def test_idle_then_noidle(self): self.send_request("idle") self.send_request("noidle") self.assertNoSubscriptions() self.assertNoEvents() self.assertOnceInResponse("OK") def test_idle_then_noidle_enables_timeout(self): self.send_request("idle") self.send_request("noidle") self.connection.enable_timeout.assert_called_once_with() def test_idle_then_play(self): with patch.object(self.session, "stop") as stop_mock: self.send_request("idle") self.send_request("play") stop_mock.assert_called_once_with() def test_idle_then_idle(self): with patch.object(self.session, "stop") as stop_mock: self.send_request("idle") self.send_request("idle") stop_mock.assert_called_once_with() def test_idle_player_then_play(self): with patch.object(self.session, "stop") as stop_mock: self.send_request("idle player") self.send_request("play") stop_mock.assert_called_once_with() def test_idle_then_player(self): self.send_request("idle") self.idle_event("player") self.assertNoSubscriptions() self.assertNoEvents() self.assertOnceInResponse("changed: player") self.assertOnceInResponse("OK") def test_idle_player_then_event_player(self): self.send_request("idle player") self.idle_event("player") self.assertNoSubscriptions() self.assertNoEvents() self.assertOnceInResponse("changed: player") self.assertOnceInResponse("OK") def test_idle_then_output(self): self.send_request("idle") self.idle_event("output") self.assertNoSubscriptions() self.assertNoEvents() self.assertOnceInResponse("changed: output") self.assertOnceInResponse("OK") def test_idle_output_then_event_output(self): self.send_request("idle output") self.idle_event("output") self.assertNoSubscriptions() self.assertNoEvents() self.assertOnceInResponse("changed: output") self.assertOnceInResponse("OK") def test_idle_player_then_noidle(self): self.send_request("idle player") self.send_request("noidle") self.assertNoSubscriptions() self.assertNoEvents() self.assertOnceInResponse("OK") def test_idle_player_playlist_then_noidle(self): self.send_request("idle player playlist") self.send_request("noidle") self.assertNoEvents() self.assertNoSubscriptions() self.assertOnceInResponse("OK") def test_idle_player_playlist_then_player(self): self.send_request("idle player playlist") self.idle_event("player") self.assertNoEvents() self.assertNoSubscriptions() self.assertOnceInResponse("changed: player") self.assertNotInResponse("changed: playlist") self.assertOnceInResponse("OK") def test_idle_playlist_then_player(self): self.send_request("idle playlist") self.idle_event("player") self.assertEqualEvents(["player"]) self.assertEqualSubscriptions(["playlist"]) self.assertNoResponse() def test_idle_playlist_then_player_then_playlist(self): self.send_request("idle playlist") self.idle_event("player") self.idle_event("playlist") self.assertNoEvents() self.assertNoSubscriptions() self.assertNotInResponse("changed: player") self.assertOnceInResponse("changed: playlist") self.assertOnceInResponse("OK") def test_player(self): self.idle_event("player") self.assertEqualEvents(["player"]) self.assertNoSubscriptions() self.assertNoResponse() def test_player_then_idle_player(self): self.idle_event("player") self.send_request("idle player") self.assertNoEvents() self.assertNoSubscriptions() self.assertOnceInResponse("changed: player") self.assertNotInResponse("changed: playlist") self.assertOnceInResponse("OK") def test_player_then_playlist(self): self.idle_event("player") self.idle_event("playlist") self.assertEqualEvents(["player", "playlist"]) self.assertNoSubscriptions() self.assertNoResponse() def test_player_then_idle(self): self.idle_event("player") self.send_request("idle") self.assertNoEvents() self.assertNoSubscriptions() self.assertOnceInResponse("changed: player") self.assertOnceInResponse("OK") def test_player_then_playlist_then_idle(self): self.idle_event("player") self.idle_event("playlist") self.send_request("idle") self.assertNoEvents() self.assertNoSubscriptions() self.assertOnceInResponse("changed: player") self.assertOnceInResponse("changed: playlist") self.assertOnceInResponse("OK") def test_player_then_idle_playlist(self): self.idle_event("player") self.send_request("idle playlist") self.assertEqualEvents(["player"]) self.assertEqualSubscriptions(["playlist"]) self.assertNoResponse() def test_player_then_idle_playlist_then_noidle(self): self.idle_event("player") self.send_request("idle playlist") self.send_request("noidle") self.assertNoEvents() self.assertNoSubscriptions() self.assertOnceInResponse("OK") def test_player_then_playlist_then_idle_playlist(self): self.idle_event("player") self.idle_event("playlist") self.send_request("idle playlist") self.assertNoEvents() self.assertNoSubscriptions() self.assertNotInResponse("changed: player") self.assertOnceInResponse("changed: playlist") self.assertOnceInResponse("OK") def test_output_then_idle_toggleoutput(self): self.idle_event("output") self.send_request("idle output") self.assertNoEvents() self.assertNoSubscriptions() self.assertOnceInResponse("changed: output") self.assertOnceInResponse("OK") ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607133598.0 Mopidy-MPD-3.1.0/tests/protocol/test_mount.py0000644000175100001710000000123200000000000020506 0ustar00runnerdockerfrom tests import protocol class MountTest(protocol.BaseTestCase): def test_mount(self): self.send_request("mount my_disk /dev/sda") self.assertEqualResponse("ACK [0@0] {mount} Not implemented") def test_unmount(self): self.send_request("unmount my_disk") self.assertEqualResponse("ACK [0@0] {unmount} Not implemented") def test_listmounts(self): self.send_request("listmounts") self.assertEqualResponse("ACK [0@0] {listmounts} Not implemented") def test_listneighbors(self): self.send_request("listneighbors") self.assertEqualResponse("ACK [0@0] {listneighbors} Not implemented") ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607133598.0 Mopidy-MPD-3.1.0/tests/protocol/test_music_db.py0000644000175100001710000013726500000000000021151 0ustar00runnerdockerimport unittest from unittest import mock from mopidy.models import Album, Artist, Playlist, Ref, SearchResult, Track from mopidy_mpd.protocol import music_db, stored_playlists from tests import protocol # TODO: split into more modules for faster parallel tests? class QueryFromMpdSearchFormatTest(unittest.TestCase): def test_dates_are_extracted(self): result = music_db._query_from_mpd_search_parameters( ["Date", "1974-01-02", "Date", "1975"], music_db._SEARCH_MAPPING ) assert result["date"][0] == "1974-01-02" assert result["date"][1] == "1975" def test_empty_value_is_ignored(self): result = music_db._query_from_mpd_search_parameters( ["Date", ""], music_db._SEARCH_MAPPING ) assert result == {} def test_whitespace_value_is_ignored(self): result = music_db._query_from_mpd_search_parameters( ["Date", " "], music_db._SEARCH_MAPPING ) assert result == {} # TODO Test more mappings class QueryFromMpdListFormatTest(unittest.TestCase): pass # TODO # TODO: why isn't core.playlists.filter getting deprecation warnings? class MusicDatabaseHandlerTest(protocol.BaseTestCase): def test_count(self): self.send_request('count "artist" "needle"') self.assertInResponse("songs: 0") self.assertInResponse("playtime: 0") self.assertInResponse("OK") def test_count_without_quotes(self): self.send_request('count artist "needle"') self.assertInResponse("songs: 0") self.assertInResponse("playtime: 0") self.assertInResponse("OK") def test_count_with_multiple_pairs(self): self.send_request('count "artist" "foo" "album" "bar"') self.assertInResponse("songs: 0") self.assertInResponse("playtime: 0") self.assertInResponse("OK") def test_count_correct_length(self): # Count the lone track self.backend.library.dummy_find_exact_result = SearchResult( tracks=[Track(uri="dummy:a", name="foo", date="2001", length=4000)] ) self.send_request('count "title" "foo"') self.assertInResponse("songs: 1") self.assertInResponse("playtime: 4") self.assertInResponse("OK") # Count multiple tracks self.backend.library.dummy_find_exact_result = SearchResult( tracks=[ Track(uri="dummy:b", date="2001", length=50000), Track(uri="dummy:c", date="2001", length=600000), ] ) self.send_request('count "date" "2001"') self.assertInResponse("songs: 2") self.assertInResponse("playtime: 650") self.assertInResponse("OK") def test_count_with_track_length_none(self): self.backend.library.dummy_find_exact_result = SearchResult( tracks=[Track(uri="dummy:b", date="2001", length=None)] ) self.send_request('count "date" "2001"') self.assertInResponse("songs: 1") self.assertInResponse("playtime: 0") self.assertInResponse("OK") def test_findadd(self): track = Track(uri="dummy:a", name="A") self.backend.library.dummy_library = [track] self.backend.library.dummy_find_exact_result = SearchResult( tracks=[track] ) assert self.core.tracklist.get_length().get() == 0 self.send_request('findadd "title" "A"') assert self.core.tracklist.get_length().get() == 1 assert self.core.tracklist.get_tracks().get()[0].uri == "dummy:a" self.assertInResponse("OK") def test_searchadd(self): track = Track(uri="dummy:a", name="A") self.backend.library.dummy_library = [track] self.backend.library.dummy_search_result = SearchResult(tracks=[track]) assert self.core.tracklist.get_length().get() == 0 self.send_request('searchadd "title" "a"') assert self.core.tracklist.get_length().get() == 1 assert self.core.tracklist.get_tracks().get()[0].uri == "dummy:a" self.assertInResponse("OK") def test_searchaddpl_appends_to_existing_playlist(self): playlist = self.core.playlists.create("my favs").get() playlist = playlist.replace( tracks=[ Track(uri="dummy:x", name="X"), Track(uri="dummy:y", name="y"), ] ) self.core.playlists.save(playlist) self.backend.library.dummy_search_result = SearchResult( tracks=[Track(uri="dummy:a", name="A")] ) items = self.core.playlists.get_items(playlist.uri).get() assert len(items) == 2 self.send_request('searchaddpl "my favs" "title" "a"') items = self.core.playlists.get_items(playlist.uri).get() assert len(items) == 3 assert items[0].uri == "dummy:x" assert items[1].uri == "dummy:y" assert items[2].uri == "dummy:a" self.assertInResponse("OK") def test_searchaddpl_creates_missing_playlist(self): self.backend.library.dummy_search_result = SearchResult( tracks=[Track(uri="dummy:a", name="A")] ) playlists = self.core.playlists.as_list().get() assert "my favs" not in {p.name for p in playlists} self.send_request('searchaddpl "my favs" "title" "a"') playlists = self.core.playlists.as_list().get() playlist = {p.name: p for p in playlists}["my favs"] items = self.core.playlists.get_items(playlist.uri).get() assert len(items) == 1 assert items[0].uri == "dummy:a" self.assertInResponse("OK") def test_listall_without_uri(self): tracks = [ Track(uri="dummy:/a", name="a"), Track(uri="dummy:/foo/b", name="b"), ] self.backend.library.dummy_library = tracks self.backend.library.dummy_browse_result = { "dummy:/": [ Ref.track(uri="dummy:/a", name="a"), Ref.directory(uri="dummy:/foo", name="foo"), Ref.album(uri="dummy:/album", name="album"), Ref.artist(uri="dummy:/artist", name="artist"), Ref.playlist(uri="dummy:/pl", name="pl"), ], "dummy:/foo": [Ref.track(uri="dummy:/foo/b", name="b")], } self.send_request("listall") self.assertInResponse("file: dummy:/a") self.assertInResponse("directory: dummy/foo") self.assertInResponse("directory: dummy/album") self.assertInResponse("directory: dummy/artist") self.assertInResponse("directory: dummy/pl") self.assertInResponse("file: dummy:/foo/b") self.assertInResponse("OK") def test_listall_with_uri(self): tracks = [ Track(uri="dummy:/a", name="a"), Track(uri="dummy:/foo/b", name="b"), ] self.backend.library.dummy_library = tracks self.backend.library.dummy_browse_result = { "dummy:/": [ Ref.track(uri="dummy:/a", name="a"), Ref.directory(uri="dummy:/foo", name="foo"), ], "dummy:/foo": [Ref.track(uri="dummy:/foo/b", name="b")], } self.send_request('listall "/dummy/foo"') self.assertNotInResponse("file: dummy:/a") self.assertInResponse("directory: dummy/foo") self.assertInResponse("file: dummy:/foo/b") self.assertInResponse("OK") def test_listall_with_unknown_uri(self): self.send_request('listall "/unknown"') self.assertEqualResponse("ACK [50@0] {listall} Not found") def test_listall_for_dir_with_and_without_leading_slash_is_the_same(self): self.backend.library.dummy_browse_result = { "dummy:/": [ Ref.track(uri="dummy:/a", name="a"), Ref.directory(uri="dummy:/foo", name="foo"), ] } response1 = self.send_request('listall "dummy"') response2 = self.send_request('listall "/dummy"') assert response1 == response2 def test_listall_for_dir_with_and_without_trailing_slash_is_the_same(self): self.backend.library.dummy_browse_result = { "dummy:/": [ Ref.track(uri="dummy:/a", name="a"), Ref.directory(uri="dummy:/foo", name="foo"), ] } response1 = self.send_request('listall "dummy"') response2 = self.send_request('listall "dummy/"') assert response1 == response2 def test_listall_duplicate(self): self.backend.library.dummy_browse_result = { "dummy:/": [ Ref.directory(uri="dummy:/a1", name="a"), Ref.directory(uri="dummy:/a2", name="a"), ] } self.send_request("listall") self.assertInResponse("directory: dummy/a") self.assertInResponse("directory: dummy/a [2]") def test_listallinfo_without_uri(self): tracks = [ Track(uri="dummy:/a", name="a"), Track(uri="dummy:/foo/b", name="b"), ] self.backend.library.dummy_library = tracks self.backend.library.dummy_browse_result = { "dummy:/": [ Ref.track(uri="dummy:/a", name="a"), Ref.directory(uri="dummy:/foo", name="foo"), Ref.album(uri="dummy:/album", name="album"), Ref.artist(uri="dummy:/artist", name="artist"), Ref.playlist(uri="dummy:/pl", name="pl"), ], "dummy:/foo": [Ref.track(uri="dummy:/foo/b", name="b")], } self.send_request("listallinfo") self.assertInResponse("file: dummy:/a") self.assertInResponse("Title: a") self.assertInResponse("directory: dummy/foo") self.assertInResponse("directory: dummy/album") self.assertInResponse("directory: dummy/artist") self.assertInResponse("directory: dummy/pl") self.assertInResponse("file: dummy:/foo/b") self.assertInResponse("Title: b") self.assertInResponse("OK") def test_listallinfo_with_uri(self): tracks = [ Track(uri="dummy:/a", name="a"), Track(uri="dummy:/foo/b", name="b"), ] self.backend.library.dummy_library = tracks self.backend.library.dummy_browse_result = { "dummy:/": [ Ref.track(uri="dummy:/a", name="a"), Ref.directory(uri="dummy:/foo", name="foo"), ], "dummy:/foo": [Ref.track(uri="dummy:/foo/b", name="b")], } self.send_request('listallinfo "/dummy/foo"') self.assertNotInResponse("file: dummy:/a") self.assertNotInResponse("Title: a") self.assertInResponse("directory: dummy/foo") self.assertInResponse("file: dummy:/foo/b") self.assertInResponse("Title: b") self.assertInResponse("OK") def test_listallinfo_with_unknown_uri(self): self.send_request('listallinfo "/unknown"') self.assertEqualResponse("ACK [50@0] {listallinfo} Not found") def test_listallinfo_for_dir_with_and_without_leading_slash_is_same(self): self.backend.library.dummy_browse_result = { "dummy:/": [ Ref.track(uri="dummy:/a", name="a"), Ref.directory(uri="dummy:/foo", name="foo"), ] } response1 = self.send_request('listallinfo "dummy"') response2 = self.send_request('listallinfo "/dummy"') assert response1 == response2 def test_listallinfo_for_dir_with_and_without_trailing_slash_is_same(self): self.backend.library.dummy_browse_result = { "dummy:/": [ Ref.track(uri="dummy:/a", name="a"), Ref.directory(uri="dummy:/foo", name="foo"), ] } response1 = self.send_request('listallinfo "dummy"') response2 = self.send_request('listallinfo "dummy/"') assert response1 == response2 def test_listallinfo_duplicate(self): self.backend.library.dummy_browse_result = { "dummy:/": [ Ref.directory(uri="dummy:/a1", name="a"), Ref.directory(uri="dummy:/a2", name="a"), ] } self.send_request("listallinfo") self.assertInResponse("directory: dummy/a") self.assertInResponse("directory: dummy/a [2]") def test_listfiles(self): self.send_request("listfiles") self.assertEqualResponse("ACK [0@0] {listfiles} Not implemented") @mock.patch.object(stored_playlists, "_get_last_modified") def test_lsinfo_without_path_returns_same_as_for_root( self, last_modified_mock ): last_modified_mock.return_value = "2015-08-05T22:51:06Z" self.backend.playlists.set_dummy_playlists( [Playlist(name="a", uri="dummy:/a")] ) response1 = self.send_request("lsinfo") response2 = self.send_request('lsinfo "/"') assert response1 == response2 @mock.patch.object(stored_playlists, "_get_last_modified") def test_lsinfo_with_empty_path_returns_same_as_for_root( self, last_modified_mock ): last_modified_mock.return_value = "2015-08-05T22:51:06Z" self.backend.playlists.set_dummy_playlists( [Playlist(name="a", uri="dummy:/a")] ) response1 = self.send_request('lsinfo ""') response2 = self.send_request('lsinfo "/"') assert response1 == response2 @mock.patch.object(stored_playlists, "_get_last_modified") def test_lsinfo_for_root_includes_playlists(self, last_modified_mock): last_modified_mock.return_value = "2015-08-05T22:51:06Z" self.backend.playlists.set_dummy_playlists( [Playlist(name="a", uri="dummy:/a")] ) self.send_request('lsinfo "/"') self.assertInResponse("playlist: a") self.assertInResponse("Last-Modified: 2015-08-05T22:51:06Z") self.assertInResponse("OK") def test_lsinfo_for_root_includes_dirs_for_each_lib_with_content(self): self.backend.library.dummy_browse_result = { "dummy:/": [ Ref.track(uri="dummy:/a", name="a"), Ref.directory(uri="dummy:/foo", name="foo"), ] } self.send_request('lsinfo "/"') self.assertInResponse("directory: dummy") self.assertInResponse("OK") @mock.patch.object(stored_playlists, "_get_last_modified") def test_lsinfo_for_dir_with_and_without_leading_slash_is_the_same( self, last_modified_mock ): last_modified_mock.return_value = "2015-08-05T22:51:06Z" self.backend.library.dummy_browse_result = { "dummy:/": [ Ref.track(uri="dummy:/a", name="a"), Ref.directory(uri="dummy:/foo", name="foo"), ] } response1 = self.send_request('lsinfo "dummy"') response2 = self.send_request('lsinfo "/dummy"') assert response1 == response2 @mock.patch.object(stored_playlists, "_get_last_modified") def test_lsinfo_for_dir_with_and_without_trailing_slash_is_the_same( self, last_modified_mock ): last_modified_mock.return_value = "2015-08-05T22:51:06Z" self.backend.library.dummy_browse_result = { "dummy:/": [ Ref.track(uri="dummy:/a", name="a"), Ref.directory(uri="dummy:/foo", name="foo"), ] } response1 = self.send_request('lsinfo "dummy"') response2 = self.send_request('lsinfo "dummy/"') assert response1 == response2 def test_lsinfo_for_dir_includes_tracks(self): self.backend.library.dummy_library = [ Track(uri="dummy:/a", name="a"), ] self.backend.library.dummy_browse_result = { "dummy:/": [Ref.track(uri="dummy:/a", name="a")] } self.send_request('lsinfo "/dummy"') self.assertInResponse("file: dummy:/a") self.assertInResponse("Title: a") self.assertInResponse("OK") def test_lsinfo_for_dir_includes_subdirs(self): self.backend.library.dummy_browse_result = { "dummy:/": [Ref.directory(uri="dummy:/foo", name="foo")] } self.send_request('lsinfo "/dummy"') self.assertInResponse("directory: dummy/foo") self.assertInResponse("OK") def test_lsinfo_for_empty_dir_returns_nothing(self): self.backend.library.dummy_browse_result = {"dummy:/": []} self.send_request('lsinfo "/dummy"') self.assertInResponse("OK") def test_lsinfo_for_dir_does_not_recurse(self): self.backend.library.dummy_library = [ Track(uri="dummy:/a", name="a"), ] self.backend.library.dummy_browse_result = { "dummy:/": [Ref.directory(uri="dummy:/foo", name="foo")], "dummy:/foo": [Ref.track(uri="dummy:/a", name="a")], } self.send_request('lsinfo "/dummy"') self.assertNotInResponse("file: dummy:/a") self.assertInResponse("OK") def test_lsinfo_for_dir_does_not_include_self(self): self.backend.library.dummy_browse_result = { "dummy:/": [Ref.directory(uri="dummy:/foo", name="foo")], "dummy:/foo": [Ref.track(uri="dummy:/a", name="a")], } self.send_request('lsinfo "/dummy"') self.assertNotInResponse("directory: dummy") self.assertInResponse("OK") def test_lsinfo_for_root_returns_browse_result_before_playlists(self): self.backend.library.dummy_browse_result = { "dummy:/": [ Ref.track(uri="dummy:/a", name="a"), Ref.directory(uri="dummy:/foo", name="foo"), ] } self.backend.playlists.set_dummy_playlists( [Playlist(name="a", uri="dummy:/a")] ) response = self.send_request('lsinfo "/"') assert response.index("directory: dummy") < response.index( "playlist: a" ) def test_lsinfo_duplicate(self): self.backend.library.dummy_browse_result = { "dummy:/": [ Ref.directory(uri="dummy:/a1", name="a"), Ref.directory(uri="dummy:/a2", name="a"), ] } self.send_request('lsinfo "/dummy"') self.assertInResponse("directory: dummy/a") self.assertInResponse("directory: dummy/a [2]") def test_update_without_uri(self): self.send_request("update") self.assertInResponse("updating_db: 0") self.assertInResponse("OK") def test_update_with_uri(self): self.send_request('update "file:///dev/urandom"') self.assertInResponse("updating_db: 0") self.assertInResponse("OK") def test_rescan_without_uri(self): self.send_request("rescan") self.assertInResponse("updating_db: 0") self.assertInResponse("OK") def test_rescan_with_uri(self): self.send_request('rescan "file:///dev/urandom"') self.assertInResponse("updating_db: 0") self.assertInResponse("OK") class MusicDatabaseFindTest(protocol.BaseTestCase): def test_find_includes_fake_artist_and_album_tracks(self): self.backend.library.dummy_find_exact_result = SearchResult( albums=[Album(uri="dummy:album:a", name="A", date="2001")], artists=[Artist(uri="dummy:artist:b", name="B")], tracks=[Track(uri="dummy:track:c", name="C")], ) self.send_request('find "any" "foo"') self.assertInResponse("file: dummy:artist:b") self.assertInResponse("Title: Artist: B") self.assertInResponse("file: dummy:album:a") self.assertInResponse("Title: Album: A") self.assertInResponse("Date: 2001") self.assertInResponse("file: dummy:track:c") self.assertInResponse("Title: C") self.assertInResponse("OK") def test_find_artist_does_not_include_fake_artist_tracks(self): self.backend.library.dummy_find_exact_result = SearchResult( albums=[Album(uri="dummy:album:a", name="A", date="2001")], artists=[Artist(uri="dummy:artist:b", name="B")], tracks=[Track(uri="dummy:track:c", name="C")], ) self.send_request('find "artist" "foo"') self.assertNotInResponse("file: dummy:artist:b") self.assertNotInResponse("Title: Artist: B") self.assertInResponse("file: dummy:album:a") self.assertInResponse("Title: Album: A") self.assertInResponse("Date: 2001") self.assertInResponse("file: dummy:track:c") self.assertInResponse("Title: C") self.assertInResponse("OK") def test_find_albumartist_does_not_include_fake_artist_tracks(self): self.backend.library.dummy_find_exact_result = SearchResult( albums=[Album(uri="dummy:album:a", name="A", date="2001")], artists=[Artist(uri="dummy:artist:b", name="B")], tracks=[Track(uri="dummy:track:c", name="C")], ) self.send_request('find "albumartist" "foo"') self.assertNotInResponse("file: dummy:artist:b") self.assertNotInResponse("Title: Artist: B") self.assertInResponse("file: dummy:album:a") self.assertInResponse("Title: Album: A") self.assertInResponse("Date: 2001") self.assertInResponse("file: dummy:track:c") self.assertInResponse("Title: C") self.assertInResponse("OK") def test_find_artist_and_album_does_not_include_fake_tracks(self): self.backend.library.dummy_find_exact_result = SearchResult( albums=[Album(uri="dummy:album:a", name="A", date="2001")], artists=[Artist(uri="dummy:artist:b", name="B")], tracks=[Track(uri="dummy:track:c", name="C")], ) self.send_request('find "artist" "foo" "album" "bar"') self.assertNotInResponse("file: dummy:artist:b") self.assertNotInResponse("Title: Artist: B") self.assertNotInResponse("file: dummy:album:a") self.assertNotInResponse("Title: Album: A") self.assertNotInResponse("Date: 2001") self.assertInResponse("file: dummy:track:c") self.assertInResponse("Title: C") self.assertInResponse("OK") def test_find_album(self): self.send_request('find "album" "what"') self.assertInResponse("OK") def test_find_album_without_quotes(self): self.send_request('find album "what"') self.assertInResponse("OK") def test_find_artist(self): self.send_request('find "artist" "what"') self.assertInResponse("OK") def test_find_artist_without_quotes(self): self.send_request('find artist "what"') self.assertInResponse("OK") def test_find_albumartist(self): self.send_request('find "albumartist" "what"') self.assertInResponse("OK") def test_find_albumartist_without_quotes(self): self.send_request('find albumartist "what"') self.assertInResponse("OK") def test_find_composer(self): self.send_request('find "composer" "what"') self.assertInResponse("OK") def test_find_composer_without_quotes(self): self.send_request('find composer "what"') self.assertInResponse("OK") def test_find_performer(self): self.send_request('find "performer" "what"') self.assertInResponse("OK") def test_find_performer_without_quotes(self): self.send_request('find performer "what"') self.assertInResponse("OK") def test_find_filename(self): self.send_request('find "filename" "afilename"') self.assertInResponse("OK") def test_find_filename_without_quotes(self): self.send_request('find filename "afilename"') self.assertInResponse("OK") def test_find_file(self): self.send_request('find "file" "afilename"') self.assertInResponse("OK") def test_find_file_without_quotes(self): self.send_request('find file "afilename"') self.assertInResponse("OK") def test_find_title(self): self.send_request('find "title" "what"') self.assertInResponse("OK") def test_find_title_without_quotes(self): self.send_request('find title "what"') self.assertInResponse("OK") def test_find_track_no(self): self.send_request('find "track" "10"') self.assertInResponse("OK") def test_find_track_no_without_quotes(self): self.send_request('find track "10"') self.assertInResponse("OK") def test_find_track_no_without_filter_value(self): self.send_request('find "track" ""') self.assertInResponse("OK") def test_find_genre(self): self.send_request('find "genre" "what"') self.assertInResponse("OK") def test_find_genre_without_quotes(self): self.send_request('find genre "what"') self.assertInResponse("OK") def test_find_date(self): self.send_request('find "date" "2002-01-01"') self.assertInResponse("OK") def test_find_date_without_quotes(self): self.send_request('find date "2002-01-01"') self.assertInResponse("OK") def test_find_date_with_capital_d_and_incomplete_date(self): self.send_request('find Date "2005"') self.assertInResponse("OK") def test_find_else_should_fail(self): self.send_request('find "somethingelse" "what"') self.assertEqualResponse("ACK [2@0] {find} incorrect arguments") def test_find_album_and_artist(self): self.send_request('find album "album_what" artist "artist_what"') self.assertInResponse("OK") def test_find_without_filter_value(self): self.send_request('find "album" ""') self.assertInResponse("OK") class MusicDatabaseListTest(protocol.BaseTestCase): def test_list(self): self.backend.library.dummy_get_distinct_result = { "artist": {"A Artist"} } self.send_request('list "artist" "artist" "foo"') self.assertInResponse("Artist: A Artist") self.assertInResponse("OK") def test_list_foo_returns_ack(self): self.send_request('list "foo"') self.assertEqualResponse("ACK [2@0] {list} Unknown tag type: foo") def test_list_without_type_returns_ack(self): self.send_request("list") self.assertEqualResponse( 'ACK [2@0] {list} too few arguments for "list"' ) # Track title def test_list_title(self): self.send_request('list "title"') self.assertInResponse("OK") def test_list_title_by_title(self): self.send_request('list "title" "title" "atitle"') self.assertInResponse("OK") # Artist def test_list_artist_with_quotes(self): self.send_request('list "artist"') self.assertInResponse("OK") def test_list_artist_without_quotes(self): self.send_request("list artist") self.assertInResponse("OK") def test_list_artist_without_quotes_and_capitalized(self): self.send_request("list Artist") self.assertInResponse("OK") def test_list_artist_with_query_of_one_token(self): self.send_request('list "artist" "anartist"') self.assertEqualResponse( 'ACK [2@0] {list} should be "Album" for 3 arguments' ) def test_list_artist_with_unknown_field_in_query_returns_ack(self): self.send_request('list "artist" "foo" "bar"') self.assertEqualResponse("ACK [2@0] {list} Unknown filter type") def test_list_artist_by_artist(self): self.send_request('list "artist" "artist" "anartist"') self.assertInResponse("OK") def test_list_artist_by_album(self): self.send_request('list "artist" "album" "analbum"') self.assertInResponse("OK") def test_list_artist_by_full_date(self): self.send_request('list "artist" "date" "2001-01-01"') self.assertInResponse("OK") def test_list_artist_by_year(self): self.send_request('list "artist" "date" "2001"') self.assertInResponse("OK") def test_list_artist_by_genre(self): self.send_request('list "artist" "genre" "agenre"') self.assertInResponse("OK") def test_list_artist_by_artist_and_album(self): self.send_request('list "artist" "artist" "anartist" "album" "analbum"') self.assertInResponse("OK") def test_list_artist_without_filter_value(self): self.send_request('list "artist" "artist" ""') self.assertInResponse("OK") def test_list_artist_should_not_return_artists_without_names(self): self.backend.library.dummy_find_exact_result = SearchResult( tracks=[Track(artists=[Artist(name="")])] ) self.send_request('list "artist"') self.assertNotInResponse("Artist: ") self.assertInResponse("OK") # Albumartist def test_list_albumartist_with_quotes(self): self.send_request('list "albumartist"') self.assertInResponse("OK") def test_list_albumartist_without_quotes(self): self.send_request("list albumartist") self.assertInResponse("OK") def test_list_albumartist_without_quotes_and_capitalized(self): self.send_request("list Albumartist") self.assertInResponse("OK") def test_list_albumartist_with_query_of_one_token(self): self.send_request('list "albumartist" "anartist"') self.assertEqualResponse( 'ACK [2@0] {list} should be "Album" for 3 arguments' ) def test_list_albumartist_with_unknown_field_in_query_returns_ack(self): self.send_request('list "albumartist" "foo" "bar"') self.assertEqualResponse("ACK [2@0] {list} Unknown filter type") def test_list_albumartist_by_artist(self): self.send_request('list "albumartist" "artist" "anartist"') self.assertInResponse("OK") def test_list_albumartist_by_album(self): self.send_request('list "albumartist" "album" "analbum"') self.assertInResponse("OK") def test_list_albumartist_by_full_date(self): self.send_request('list "albumartist" "date" "2001-01-01"') self.assertInResponse("OK") def test_list_albumartist_by_year(self): self.send_request('list "albumartist" "date" "2001"') self.assertInResponse("OK") def test_list_albumartist_by_genre(self): self.send_request('list "albumartist" "genre" "agenre"') self.assertInResponse("OK") def test_list_albumartist_by_artist_and_album(self): self.send_request( 'list "albumartist" "artist" "anartist" "album" "analbum"' ) self.assertInResponse("OK") def test_list_albumartist_without_filter_value(self): self.send_request('list "albumartist" "artist" ""') self.assertInResponse("OK") def test_list_albumartist_should_not_return_artists_without_names(self): self.backend.library.dummy_find_exact_result = SearchResult( tracks=[Track(album=Album(artists=[Artist(name="")]))] ) self.send_request('list "albumartist"') self.assertNotInResponse("Artist: ") self.assertNotInResponse("Albumartist: ") self.assertNotInResponse("Composer: ") self.assertNotInResponse("Performer: ") self.assertInResponse("OK") # Composer def test_list_composer_with_quotes(self): self.send_request('list "composer"') self.assertInResponse("OK") def test_list_composer_without_quotes(self): self.send_request("list composer") self.assertInResponse("OK") def test_list_composer_without_quotes_and_capitalized(self): self.send_request("list Composer") self.assertInResponse("OK") def test_list_composer_with_query_of_one_token(self): self.send_request('list "composer" "anartist"') self.assertEqualResponse( 'ACK [2@0] {list} should be "Album" for 3 arguments' ) def test_list_composer_with_unknown_field_in_query_returns_ack(self): self.send_request('list "composer" "foo" "bar"') self.assertEqualResponse("ACK [2@0] {list} Unknown filter type") def test_list_composer_by_artist(self): self.send_request('list "composer" "artist" "anartist"') self.assertInResponse("OK") def test_list_composer_by_album(self): self.send_request('list "composer" "album" "analbum"') self.assertInResponse("OK") def test_list_composer_by_full_date(self): self.send_request('list "composer" "date" "2001-01-01"') self.assertInResponse("OK") def test_list_composer_by_year(self): self.send_request('list "composer" "date" "2001"') self.assertInResponse("OK") def test_list_composer_by_genre(self): self.send_request('list "composer" "genre" "agenre"') self.assertInResponse("OK") def test_list_composer_by_artist_and_album(self): self.send_request( 'list "composer" "artist" "anartist" "album" "analbum"' ) self.assertInResponse("OK") def test_list_composer_without_filter_value(self): self.send_request('list "composer" "artist" ""') self.assertInResponse("OK") def test_list_composer_should_not_return_artists_without_names(self): self.backend.library.dummy_find_exact_result = SearchResult( tracks=[Track(composers=[Artist(name="")])] ) self.send_request('list "composer"') self.assertNotInResponse("Artist: ") self.assertNotInResponse("Albumartist: ") self.assertNotInResponse("Composer: ") self.assertNotInResponse("Performer: ") self.assertInResponse("OK") # Performer def test_list_performer_with_quotes(self): self.send_request('list "performer"') self.assertInResponse("OK") def test_list_performer_without_quotes(self): self.send_request("list performer") self.assertInResponse("OK") def test_list_performer_without_quotes_and_capitalized(self): self.send_request("list Albumartist") self.assertInResponse("OK") def test_list_performer_with_query_of_one_token(self): self.send_request('list "performer" "anartist"') self.assertEqualResponse( 'ACK [2@0] {list} should be "Album" for 3 arguments' ) def test_list_performer_with_unknown_field_in_query_returns_ack(self): self.send_request('list "performer" "foo" "bar"') self.assertEqualResponse("ACK [2@0] {list} Unknown filter type") def test_list_performer_by_artist(self): self.send_request('list "performer" "artist" "anartist"') self.assertInResponse("OK") def test_list_performer_by_album(self): self.send_request('list "performer" "album" "analbum"') self.assertInResponse("OK") def test_list_performer_by_full_date(self): self.send_request('list "performer" "date" "2001-01-01"') self.assertInResponse("OK") def test_list_performer_by_year(self): self.send_request('list "performer" "date" "2001"') self.assertInResponse("OK") def test_list_performer_by_genre(self): self.send_request('list "performer" "genre" "agenre"') self.assertInResponse("OK") def test_list_performer_by_artist_and_album(self): self.send_request( 'list "performer" "artist" "anartist" "album" "analbum"' ) self.assertInResponse("OK") def test_list_performer_without_filter_value(self): self.send_request('list "performer" "artist" ""') self.assertInResponse("OK") def test_list_performer_should_not_return_artists_without_names(self): self.backend.library.dummy_find_exact_result = SearchResult( tracks=[Track(performers=[Artist(name="")])] ) self.send_request('list "performer"') self.assertNotInResponse("Artist: ") self.assertNotInResponse("Albumartist: ") self.assertNotInResponse("Composer: ") self.assertNotInResponse("Performer: ") self.assertInResponse("OK") # Album def test_list_album_with_quotes(self): self.send_request('list "album"') self.assertInResponse("OK") def test_list_album_without_quotes(self): self.send_request("list album") self.assertInResponse("OK") def test_list_album_without_quotes_and_capitalized(self): self.send_request("list Album") self.assertInResponse("OK") def test_list_album_with_artist_name(self): self.backend.library.dummy_get_distinct_result = {"album": {"foo"}} self.send_request('list "album" "anartist"') self.assertInResponse("Album: foo") self.assertInResponse("OK") def test_list_album_with_artist_name_without_filter_value(self): self.send_request('list "album" ""') self.assertInResponse("OK") def test_list_album_by_artist(self): self.send_request('list "album" "artist" "anartist"') self.assertInResponse("OK") def test_list_album_by_album(self): self.send_request('list "album" "album" "analbum"') self.assertInResponse("OK") def test_list_album_by_albumartist(self): self.send_request('list "album" "albumartist" "anartist"') self.assertInResponse("OK") def test_list_album_by_composer(self): self.send_request('list "album" "composer" "anartist"') self.assertInResponse("OK") def test_list_album_by_performer(self): self.send_request('list "album" "performer" "anartist"') self.assertInResponse("OK") def test_list_album_by_full_date(self): self.send_request('list "album" "date" "2001-01-01"') self.assertInResponse("OK") def test_list_album_by_year(self): self.send_request('list "album" "date" "2001"') self.assertInResponse("OK") def test_list_album_by_genre(self): self.send_request('list "album" "genre" "agenre"') self.assertInResponse("OK") def test_list_album_by_artist_and_album(self): self.send_request('list "album" "artist" "anartist" "album" "analbum"') self.assertInResponse("OK") def test_list_album_without_filter_value(self): self.send_request('list "album" "artist" ""') self.assertInResponse("OK") def test_list_album_should_not_return_albums_without_names(self): self.backend.library.dummy_find_exact_result = SearchResult( tracks=[Track(album=Album(name=""))] ) self.send_request('list "album"') self.assertNotInResponse("Album: ") self.assertInResponse("OK") # Date def test_list_date_with_quotes(self): self.send_request('list "date"') self.assertInResponse("OK") def test_list_date_without_quotes(self): self.send_request("list date") self.assertInResponse("OK") def test_list_date_without_quotes_and_capitalized(self): self.send_request("list Date") self.assertInResponse("OK") def test_list_date_with_query_of_one_token(self): self.send_request('list "date" "anartist"') self.assertEqualResponse( 'ACK [2@0] {list} should be "Album" for 3 arguments' ) def test_list_date_by_artist(self): self.send_request('list "date" "artist" "anartist"') self.assertInResponse("OK") def test_list_date_by_album(self): self.send_request('list "date" "album" "analbum"') self.assertInResponse("OK") def test_list_date_by_full_date(self): self.send_request('list "date" "date" "2001-01-01"') self.assertInResponse("OK") def test_list_date_by_year(self): self.send_request('list "date" "date" "2001"') self.assertInResponse("OK") def test_list_date_by_genre(self): self.send_request('list "date" "genre" "agenre"') self.assertInResponse("OK") def test_list_date_by_artist_and_album(self): self.send_request('list "date" "artist" "anartist" "album" "analbum"') self.assertInResponse("OK") def test_list_date_without_filter_value(self): self.send_request('list "date" "artist" ""') self.assertInResponse("OK") def test_list_date_should_not_return_blank_dates(self): self.backend.library.dummy_find_exact_result = SearchResult( tracks=[Track(date="")] ) self.send_request('list "date"') self.assertNotInResponse("Date: ") self.assertInResponse("OK") # Genre def test_list_genre_with_quotes(self): self.send_request('list "genre"') self.assertInResponse("OK") def test_list_genre_without_quotes(self): self.send_request("list genre") self.assertInResponse("OK") def test_list_genre_without_quotes_and_capitalized(self): self.send_request("list Genre") self.assertInResponse("OK") def test_list_genre_with_query_of_one_token(self): self.send_request('list "genre" "anartist"') self.assertEqualResponse( 'ACK [2@0] {list} should be "Album" for 3 arguments' ) def test_list_genre_by_artist(self): self.send_request('list "genre" "artist" "anartist"') self.assertInResponse("OK") def test_list_genre_by_album(self): self.send_request('list "genre" "album" "analbum"') self.assertInResponse("OK") def test_list_genre_by_full_date(self): self.send_request('list "genre" "date" "2001-01-01"') self.assertInResponse("OK") def test_list_genre_by_year(self): self.send_request('list "genre" "date" "2001"') self.assertInResponse("OK") def test_list_genre_by_genre(self): self.send_request('list "genre" "genre" "agenre"') self.assertInResponse("OK") def test_list_genre_by_artist_and_album(self): self.send_request('list "genre" "artist" "anartist" "album" "analbum"') self.assertInResponse("OK") def test_list_genre_without_filter_value(self): self.send_request('list "genre" "artist" ""') self.assertInResponse("OK") class MusicDatabaseSearchTest(protocol.BaseTestCase): def test_search(self): self.backend.library.dummy_search_result = SearchResult( albums=[Album(uri="dummy:album:a", name="A")], artists=[Artist(uri="dummy:artist:b", name="B")], tracks=[Track(uri="dummy:track:c", name="C")], ) self.send_request('search "any" "foo"') self.assertInResponse("file: dummy:album:a") self.assertInResponse("Title: Album: A") self.assertInResponse("file: dummy:artist:b") self.assertInResponse("Title: Artist: B") self.assertInResponse("file: dummy:track:c") self.assertInResponse("Title: C") self.assertInResponse("OK") def test_search_album(self): self.send_request('search "album" "analbum"') self.assertInResponse("OK") def test_search_album_without_quotes(self): self.send_request('search album "analbum"') self.assertInResponse("OK") def test_search_album_without_filter_value(self): self.send_request('search "album" ""') self.assertInResponse("OK") def test_search_artist(self): self.send_request('search "artist" "anartist"') self.assertInResponse("OK") def test_search_artist_without_quotes(self): self.send_request('search artist "anartist"') self.assertInResponse("OK") def test_search_artist_without_filter_value(self): self.send_request('search "artist" ""') self.assertInResponse("OK") def test_search_albumartist(self): self.send_request('search "albumartist" "analbumartist"') self.assertInResponse("OK") def test_search_albumartist_without_quotes(self): self.send_request('search albumartist "analbumartist"') self.assertInResponse("OK") def test_search_albumartist_without_filter_value(self): self.send_request('search "albumartist" ""') self.assertInResponse("OK") def test_search_composer(self): self.send_request('search "composer" "acomposer"') self.assertInResponse("OK") def test_search_composer_without_quotes(self): self.send_request('search composer "acomposer"') self.assertInResponse("OK") def test_search_composer_without_filter_value(self): self.send_request('search "composer" ""') self.assertInResponse("OK") def test_search_performer(self): self.send_request('search "performer" "aperformer"') self.assertInResponse("OK") def test_search_performer_without_quotes(self): self.send_request('search performer "aperformer"') self.assertInResponse("OK") def test_search_performer_without_filter_value(self): self.send_request('search "performer" ""') self.assertInResponse("OK") def test_search_filename(self): self.send_request('search "filename" "afilename"') self.assertInResponse("OK") def test_search_filename_without_quotes(self): self.send_request('search filename "afilename"') self.assertInResponse("OK") def test_search_filename_without_filter_value(self): self.send_request('search "filename" ""') self.assertInResponse("OK") def test_search_file(self): self.send_request('search "file" "afilename"') self.assertInResponse("OK") def test_search_file_without_quotes(self): self.send_request('search file "afilename"') self.assertInResponse("OK") def test_search_file_without_filter_value(self): self.send_request('search "file" ""') self.assertInResponse("OK") def test_search_title(self): self.send_request('search "title" "atitle"') self.assertInResponse("OK") def test_search_title_without_quotes(self): self.send_request('search title "atitle"') self.assertInResponse("OK") def test_search_title_without_filter_value(self): self.send_request('search "title" ""') self.assertInResponse("OK") def test_search_any(self): self.send_request('search "any" "anything"') self.assertInResponse("OK") def test_search_any_without_quotes(self): self.send_request('search any "anything"') self.assertInResponse("OK") def test_search_any_without_filter_value(self): self.send_request('search "any" ""') self.assertInResponse("OK") def test_search_track_no(self): self.send_request('search "track" "10"') self.assertInResponse("OK") def test_search_track_no_without_quotes(self): self.send_request('search track "10"') self.assertInResponse("OK") def test_search_track_no_without_filter_value(self): self.send_request('search "track" ""') self.assertInResponse("OK") def test_search_genre(self): self.send_request('search "genre" "agenre"') self.assertInResponse("OK") def test_search_genre_without_quotes(self): self.send_request('search genre "agenre"') self.assertInResponse("OK") def test_search_genre_without_filter_value(self): self.send_request('search "genre" ""') self.assertInResponse("OK") def test_search_date(self): self.send_request('search "date" "2002-01-01"') self.assertInResponse("OK") def test_search_date_without_quotes(self): self.send_request('search date "2002-01-01"') self.assertInResponse("OK") def test_search_date_with_capital_d_and_incomplete_date(self): self.send_request('search Date "2005"') self.assertInResponse("OK") def test_search_date_without_filter_value(self): self.send_request('search "date" ""') self.assertInResponse("OK") def test_search_comment(self): self.send_request('search "comment" "acomment"') self.assertInResponse("OK") def test_search_comment_without_quotes(self): self.send_request('search comment "acomment"') self.assertInResponse("OK") def test_search_comment_without_filter_value(self): self.send_request('search "comment" ""') self.assertInResponse("OK") def test_search_else_should_fail(self): self.send_request('search "sometype" "something"') self.assertEqualResponse("ACK [2@0] {search} incorrect arguments") ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607133598.0 Mopidy-MPD-3.1.0/tests/protocol/test_playback.py0000644000175100001710000004356400000000000021150 0ustar00runnerdockerimport unittest from mopidy.core import PlaybackState from mopidy.models import Track from tests import protocol PAUSED = PlaybackState.PAUSED PLAYING = PlaybackState.PLAYING STOPPED = PlaybackState.STOPPED class PlaybackOptionsHandlerTest(protocol.BaseTestCase): def test_consume_off(self): self.send_request('consume "0"') assert not self.core.tracklist.get_consume().get() self.assertInResponse("OK") def test_consume_off_without_quotes(self): self.send_request("consume 0") assert not self.core.tracklist.get_consume().get() self.assertInResponse("OK") def test_consume_on(self): self.send_request('consume "1"') assert self.core.tracklist.get_consume().get() self.assertInResponse("OK") def test_consume_on_without_quotes(self): self.send_request("consume 1") assert self.core.tracklist.get_consume().get() self.assertInResponse("OK") def test_crossfade(self): self.send_request('crossfade "10"') self.assertInResponse("ACK [0@0] {crossfade} Not implemented") def test_random_off(self): self.send_request('random "0"') assert not self.core.tracklist.get_random().get() self.assertInResponse("OK") def test_random_off_without_quotes(self): self.send_request("random 0") assert not self.core.tracklist.get_random().get() self.assertInResponse("OK") def test_random_on(self): self.send_request('random "1"') assert self.core.tracklist.get_random().get() self.assertInResponse("OK") def test_random_on_without_quotes(self): self.send_request("random 1") assert self.core.tracklist.get_random().get() self.assertInResponse("OK") def test_repeat_off(self): self.send_request('repeat "0"') assert not self.core.tracklist.get_repeat().get() self.assertInResponse("OK") def test_repeat_off_without_quotes(self): self.send_request("repeat 0") assert not self.core.tracklist.get_repeat().get() self.assertInResponse("OK") def test_repeat_on(self): self.send_request('repeat "1"') assert self.core.tracklist.get_repeat().get() self.assertInResponse("OK") def test_repeat_on_without_quotes(self): self.send_request("repeat 1") assert self.core.tracklist.get_repeat().get() self.assertInResponse("OK") def test_single_off(self): self.send_request('single "0"') assert not self.core.tracklist.get_single().get() self.assertInResponse("OK") def test_single_off_without_quotes(self): self.send_request("single 0") assert not self.core.tracklist.get_single().get() self.assertInResponse("OK") def test_single_on(self): self.send_request('single "1"') assert self.core.tracklist.get_single().get() self.assertInResponse("OK") def test_single_on_without_quotes(self): self.send_request("single 1") assert self.core.tracklist.get_single().get() self.assertInResponse("OK") def test_replay_gain_mode_off(self): self.send_request('replay_gain_mode "off"') self.assertInResponse("ACK [0@0] {replay_gain_mode} Not implemented") def test_replay_gain_mode_track(self): self.send_request('replay_gain_mode "track"') self.assertInResponse("ACK [0@0] {replay_gain_mode} Not implemented") def test_replay_gain_mode_album(self): self.send_request('replay_gain_mode "album"') self.assertInResponse("ACK [0@0] {replay_gain_mode} Not implemented") def test_replay_gain_status_default(self): self.send_request("replay_gain_status") self.assertInResponse("OK") self.assertInResponse("replay_gain_mode: off") def test_mixrampdb(self): self.send_request('mixrampdb "10"') self.assertInResponse("ACK [0@0] {mixrampdb} Not implemented") def test_mixrampdelay(self): self.send_request('mixrampdelay "10"') self.assertInResponse("ACK [0@0] {mixrampdelay} Not implemented") @unittest.SkipTest def test_replay_gain_status_off(self): pass @unittest.SkipTest def test_replay_gain_status_track(self): pass @unittest.SkipTest def test_replay_gain_status_album(self): pass class PlaybackControlHandlerTest(protocol.BaseTestCase): def setUp(self): # noqa: N802 super().setUp() self.tracks = [ Track(uri="dummy:a", length=40000), Track(uri="dummy:b", length=40000), ] self.backend.library.dummy_library = self.tracks self.core.tracklist.add(uris=[t.uri for t in self.tracks]).get() def test_next(self): self.core.tracklist.clear().get() self.send_request("next") self.assertInResponse("OK") def test_pause_off(self): self.send_request('play "0"') self.send_request('pause "1"') self.send_request('pause "0"') assert PLAYING == self.core.playback.get_state().get() self.assertInResponse("OK") def test_pause_on(self): self.send_request('play "0"') self.send_request('pause "1"') assert PAUSED == self.core.playback.get_state().get() self.assertInResponse("OK") def test_pause_toggle(self): self.send_request('play "0"') assert PLAYING == self.core.playback.get_state().get() self.assertInResponse("OK") # Deprecated version self.send_request("pause") assert PAUSED == self.core.playback.get_state().get() self.assertInResponse("OK") self.send_request("pause") assert PLAYING == self.core.playback.get_state().get() self.assertInResponse("OK") def test_play_without_pos(self): self.send_request("play") assert PLAYING == self.core.playback.get_state().get() self.assertInResponse("OK") def test_play_with_pos(self): self.send_request('play "0"') assert PLAYING == self.core.playback.get_state().get() self.assertInResponse("OK") def test_play_with_pos_without_quotes(self): self.send_request("play 0") assert PLAYING == self.core.playback.get_state().get() self.assertInResponse("OK") def test_play_with_pos_out_of_bounds(self): self.core.tracklist.clear().get() self.send_request('play "0"') assert STOPPED == self.core.playback.get_state().get() self.assertInResponse("ACK [2@0] {play} Bad song index") def test_play_minus_one_plays_first_in_playlist_if_no_current_track(self): assert self.core.playback.get_current_track().get() is None self.send_request('play "-1"') assert PLAYING == self.core.playback.get_state().get() assert "dummy:a" == self.core.playback.get_current_track().get().uri self.assertInResponse("OK") def test_play_minus_one_plays_current_track_if_current_track_is_set(self): assert self.core.playback.get_current_track().get() is None self.core.playback.play() self.core.playback.next() self.core.playback.stop().get() assert self.core.playback.get_current_track().get() is not None self.send_request('play "-1"') assert PLAYING == self.core.playback.get_state().get() assert "dummy:b" == self.core.playback.get_current_track().get().uri self.assertInResponse("OK") def test_play_minus_one_on_empty_playlist_does_not_ack(self): self.core.tracklist.clear() self.send_request('play "-1"') assert STOPPED == self.core.playback.get_state().get() assert self.core.playback.get_current_track().get() is None self.assertInResponse("OK") def test_play_minus_is_ignored_if_playing(self): self.core.playback.play().get() self.core.playback.seek(30000) assert self.core.playback.get_time_position().get() >= 30000 assert PLAYING == self.core.playback.get_state().get() self.send_request('play "-1"') assert PLAYING == self.core.playback.get_state().get() assert self.core.playback.get_time_position().get() >= 30000 self.assertInResponse("OK") def test_play_minus_one_resumes_if_paused(self): self.core.playback.play().get() self.core.playback.seek(30000) assert self.core.playback.get_time_position().get() >= 30000 assert PLAYING == self.core.playback.get_state().get() self.core.playback.pause() assert PAUSED == self.core.playback.get_state().get() self.send_request('play "-1"') assert PLAYING == self.core.playback.get_state().get() assert self.core.playback.get_time_position().get() >= 30000 self.assertInResponse("OK") def test_playid(self): self.send_request('playid "1"') assert PLAYING == self.core.playback.get_state().get() self.assertInResponse("OK") def test_playid_without_quotes(self): self.send_request("playid 1") assert PLAYING == self.core.playback.get_state().get() self.assertInResponse("OK") def test_playid_minus_1_plays_first_in_playlist_if_no_current_track(self): assert self.core.playback.get_current_track().get() is None self.send_request('playid "-1"') assert PLAYING == self.core.playback.get_state().get() assert "dummy:a" == self.core.playback.get_current_track().get().uri self.assertInResponse("OK") def test_playid_minus_1_plays_current_track_if_current_track_is_set(self): assert self.core.playback.get_current_track().get() is None self.core.playback.play().get() self.core.playback.next().get() self.core.playback.stop() assert self.core.playback.get_current_track().get() is not None self.send_request('playid "-1"') assert PLAYING == self.core.playback.get_state().get() assert "dummy:b" == self.core.playback.get_current_track().get().uri self.assertInResponse("OK") def test_playid_minus_one_on_empty_playlist_does_not_ack(self): self.core.tracklist.clear() self.send_request('playid "-1"') assert STOPPED == self.core.playback.get_state().get() assert self.core.playback.get_current_track().get() is None self.assertInResponse("OK") def test_playid_minus_is_ignored_if_playing(self): self.core.playback.play().get() self.core.playback.seek(30000) assert self.core.playback.get_time_position().get() >= 30000 assert PLAYING == self.core.playback.get_state().get() self.send_request('playid "-1"') assert PLAYING == self.core.playback.get_state().get() assert self.core.playback.get_time_position().get() >= 30000 self.assertInResponse("OK") def test_playid_minus_one_resumes_if_paused(self): self.core.playback.play().get() self.core.playback.seek(30000) assert self.core.playback.get_time_position().get() >= 30000 assert PLAYING == self.core.playback.get_state().get() self.core.playback.pause() assert PAUSED == self.core.playback.get_state().get() self.send_request('playid "-1"') assert PLAYING == self.core.playback.get_state().get() assert self.core.playback.get_time_position().get() >= 30000 self.assertInResponse("OK") def test_playid_which_does_not_exist(self): self.send_request('playid "12345"') self.assertInResponse("ACK [50@0] {playid} No such song") def test_previous(self): self.core.tracklist.clear().get() self.send_request("previous") self.assertInResponse("OK") def test_seek_in_current_track(self): self.core.playback.play() self.send_request('seek "0" "30"') current_track = self.core.playback.get_current_track().get() assert current_track == self.tracks[0] assert self.core.playback.get_time_position().get() >= 30000 self.assertInResponse("OK") def test_seek_in_another_track(self): self.core.playback.play() current_track = self.core.playback.get_current_track().get() assert current_track != self.tracks[1] self.send_request('seek "1" "30"') current_track = self.core.playback.get_current_track().get() assert current_track == self.tracks[1] self.assertInResponse("OK") def test_seek_without_quotes(self): self.core.playback.play() self.send_request("seek 0 30") assert self.core.playback.get_time_position().get() >= 30000 self.assertInResponse("OK") def test_seek_with_float(self): self.core.playback.play() self.send_request('seek "0" "30.1"') assert self.core.playback.get_time_position().get() >= 30100 self.assertInResponse("OK") def test_seekid_in_current_track(self): self.core.playback.play() self.send_request('seekid "1" "30"') current_track = self.core.playback.get_current_track().get() assert current_track == self.tracks[0] assert self.core.playback.get_time_position().get() >= 30000 self.assertInResponse("OK") def test_seekid_in_another_track(self): self.core.playback.play() self.send_request('seekid "2" "30"') current_tl_track = self.core.playback.get_current_tl_track().get() assert current_tl_track.tlid == 2 assert current_tl_track.track == self.tracks[1] self.assertInResponse("OK") def test_seekid_with_float(self): self.core.playback.play() self.send_request('seekid "1" "30.1"') current_track = self.core.playback.get_current_track().get() assert current_track == self.tracks[0] assert self.core.playback.get_time_position().get() >= 30100 self.assertInResponse("OK") def test_seekcur_absolute_value(self): self.core.playback.play().get() self.send_request('seekcur "30"') assert self.core.playback.get_time_position().get() >= 30000 self.assertInResponse("OK") def test_seekcur_positive_diff(self): self.core.playback.play().get() self.core.playback.seek(10000) assert self.core.playback.get_time_position().get() >= 10000 self.send_request('seekcur "+20"') assert self.core.playback.get_time_position().get() >= 30000 self.assertInResponse("OK") def test_seekcur_negative_diff(self): self.core.playback.play().get() self.core.playback.seek(30000) assert self.core.playback.get_time_position().get() >= 30000 self.send_request('seekcur "-20"') assert self.core.playback.get_time_position().get() <= 15000 self.assertInResponse("OK") def test_seekcur_absolute_float(self): self.core.playback.play().get() self.send_request('seekcur "30.1"') assert self.core.playback.get_time_position().get() >= 30100 self.assertInResponse("OK") def test_seekcur_negative_float(self): self.core.playback.play().get() self.core.playback.seek(30000) assert self.core.playback.get_time_position().get() >= 30000 self.send_request('seekcur "-20.1"') assert self.core.playback.get_time_position().get() <= 10000 self.assertInResponse("OK") def test_stop(self): self.core.tracklist.clear().get() self.send_request("stop") assert STOPPED == self.core.playback.get_state().get() self.assertInResponse("OK") class VolumeTest(protocol.BaseTestCase): def test_setvol_below_min(self): self.send_request('setvol "-10"') assert 0 == self.core.mixer.get_volume().get() self.assertInResponse("OK") def test_setvol_min(self): self.send_request('setvol "0"') assert 0 == self.core.mixer.get_volume().get() self.assertInResponse("OK") def test_setvol_middle(self): self.send_request('setvol "50"') assert 50 == self.core.mixer.get_volume().get() self.assertInResponse("OK") def test_setvol_max(self): self.send_request('setvol "100"') assert 100 == self.core.mixer.get_volume().get() self.assertInResponse("OK") def test_setvol_above_max(self): self.send_request('setvol "110"') assert 100 == self.core.mixer.get_volume().get() self.assertInResponse("OK") def test_setvol_plus_is_ignored(self): self.send_request('setvol "+10"') assert 10 == self.core.mixer.get_volume().get() self.assertInResponse("OK") def test_setvol_without_quotes(self): self.send_request("setvol 50") assert 50 == self.core.mixer.get_volume().get() self.assertInResponse("OK") def test_volume_plus(self): self.core.mixer.set_volume(50) self.send_request("volume +20") assert 70 == self.core.mixer.get_volume().get() self.assertInResponse("OK") def test_volume_minus(self): self.core.mixer.set_volume(50) self.send_request("volume -20") assert 30 == self.core.mixer.get_volume().get() self.assertInResponse("OK") def test_volume_less_than_minus_100(self): self.core.mixer.set_volume(50) self.send_request("volume -110") assert 50 == self.core.mixer.get_volume().get() self.assertInResponse("ACK [2@0] {volume} Invalid volume value") def test_volume_more_than_plus_100(self): self.core.mixer.set_volume(50) self.send_request("volume +110") assert 50 == self.core.mixer.get_volume().get() self.assertInResponse("ACK [2@0] {volume} Invalid volume value") class VolumeWithNoMixerTest(protocol.BaseTestCase): enable_mixer = False def test_setvol_without_mixer_fails(self): self.send_request('setvol "100"') self.assertInResponse("ACK [52@0] {setvol} problems setting volume") def test_volume_without_mixer_failes(self): self.send_request("volume +100") self.assertInResponse("ACK [52@0] {volume} problems setting volume") ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607133598.0 Mopidy-MPD-3.1.0/tests/protocol/test_reflection.py0000644000175100001710000000765100000000000021511 0ustar00runnerdockerfrom tests import protocol class ReflectionHandlerTest(protocol.BaseTestCase): def test_config_is_not_allowed_across_the_network(self): self.send_request("config") self.assertEqualResponse( 'ACK [4@0] {config} you don\'t have permission for "config"' ) def test_commands_returns_list_of_all_commands(self): self.send_request("commands") # Check if some random commands are included self.assertInResponse("command: commands") self.assertInResponse("command: play") self.assertInResponse("command: status") # Check if commands you do not have access to are not present self.assertNotInResponse("command: config") self.assertNotInResponse("command: kill") # Check if the blacklisted commands are not present self.assertNotInResponse("command: command_list_begin") self.assertNotInResponse("command: command_list_ok_begin") self.assertNotInResponse("command: command_list_end") self.assertInResponse("command: idle") self.assertNotInResponse("command: noidle") self.assertNotInResponse("command: sticker") self.assertInResponse("OK") def test_decoders(self): self.send_request("decoders") self.assertInResponse("OK") def test_notcommands_returns_only_config_and_kill_and_ok(self): response = self.send_request("notcommands") assert 3 == len(response) self.assertInResponse("command: config") self.assertInResponse("command: kill") self.assertInResponse("OK") def test_tagtypes(self): self.send_request("tagtypes") self.assertInResponse("tagtype: Artist") self.assertInResponse("tagtype: ArtistSort") self.assertInResponse("tagtype: Album") self.assertInResponse("tagtype: AlbumArtist") self.assertInResponse("tagtype: AlbumArtistSort") self.assertInResponse("tagtype: Title") self.assertInResponse("tagtype: Track") self.assertInResponse("tagtype: Name") self.assertInResponse("tagtype: Genre") self.assertInResponse("tagtype: Date") self.assertInResponse("tagtype: Composer") self.assertInResponse("tagtype: Performer") self.assertInResponse("tagtype: Disc") self.assertInResponse("tagtype: MUSICBRAINZ_ARTISTID") self.assertInResponse("tagtype: MUSICBRAINZ_ALBUMID") self.assertInResponse("tagtype: MUSICBRAINZ_ALBUMARTISTID") self.assertInResponse("tagtype: MUSICBRAINZ_TRACKID") self.assertInResponse("OK") def test_urlhandlers(self): self.send_request("urlhandlers") self.assertInResponse("OK") self.assertInResponse("handler: dummy") class ReflectionWhenNotAuthedTest(protocol.BaseTestCase): def get_config(self): config = super().get_config() config["mpd"]["password"] = "topsecret" return config def test_commands_show_less_if_auth_required_and_not_authed(self): self.send_request("commands") # Not requiring auth self.assertInResponse("command: close") self.assertInResponse("command: commands") self.assertInResponse("command: notcommands") self.assertInResponse("command: password") self.assertInResponse("command: ping") # Requiring auth self.assertNotInResponse("command: play") self.assertNotInResponse("command: status") def test_notcommands_returns_more_if_auth_required_and_not_authed(self): self.send_request("notcommands") # Not requiring auth self.assertNotInResponse("command: close") self.assertNotInResponse("command: commands") self.assertNotInResponse("command: notcommands") self.assertNotInResponse("command: password") self.assertNotInResponse("command: ping") # Requiring auth self.assertInResponse("command: play") self.assertInResponse("command: status") ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607133598.0 Mopidy-MPD-3.1.0/tests/protocol/test_regression.py0000644000175100001710000001764700000000000021545 0ustar00runnerdockerimport random from unittest import mock from mopidy.models import Playlist, Ref, Track from mopidy_mpd.protocol import stored_playlists from tests import protocol def mock_shuffle(foo): foo[:] = [foo[1], foo[2], foo[5], foo[3], foo[4], foo[0]] class IssueGH17RegressionTest(protocol.BaseTestCase): """ The issue: http://github.com/mopidy/mopidy/issues/17 How to reproduce: - Play a playlist where one track cannot be played - Turn on random mode - Press next until you get to the unplayable track """ @mock.patch.object(protocol.core.tracklist.random, "shuffle", mock_shuffle) def test(self): tracks = [ Track(uri="dummy:a"), Track(uri="dummy:b"), Track(uri="dummy:error"), Track(uri="dummy:d"), Track(uri="dummy:e"), Track(uri="dummy:f"), ] self.audio.trigger_fake_playback_failure("dummy:error") self.backend.library.dummy_library = tracks self.core.tracklist.add(uris=[t.uri for t in tracks]).get() # Playlist order: abcfde self.send_request("play") assert "dummy:a" == self.core.playback.get_current_track().get().uri self.send_request('random "1"') self.send_request("next") assert "dummy:b" == self.core.playback.get_current_track().get().uri self.send_request("next") # Should now be at track 'c', but playback fails and it skips ahead assert "dummy:f" == self.core.playback.get_current_track().get().uri self.send_request("next") assert "dummy:d" == self.core.playback.get_current_track().get().uri self.send_request("next") assert "dummy:e" == self.core.playback.get_current_track().get().uri class IssueGH18RegressionTest(protocol.BaseTestCase): """ The issue: http://github.com/mopidy/mopidy/issues/18 How to reproduce: Play, random on, next, random off, next, next. At this point it gives the same song over and over. """ def test(self): tracks = [ Track(uri="dummy:a"), Track(uri="dummy:b"), Track(uri="dummy:c"), Track(uri="dummy:d"), Track(uri="dummy:e"), Track(uri="dummy:f"), ] self.backend.library.dummy_library = tracks self.core.tracklist.add(uris=[t.uri for t in tracks]).get() random.seed(1) self.send_request("play") self.send_request('random "1"') self.send_request("next") self.send_request('random "0"') self.send_request("next") self.send_request("next") tl_track_1 = self.core.playback.get_current_tl_track().get() self.send_request("next") tl_track_2 = self.core.playback.get_current_tl_track().get() self.send_request("next") tl_track_3 = self.core.playback.get_current_tl_track().get() assert tl_track_1 != tl_track_2 assert tl_track_2 != tl_track_3 class IssueGH22RegressionTest(protocol.BaseTestCase): """ The issue: http://github.com/mopidy/mopidy/issues/22 How to reproduce: Play, random on, remove all tracks from the current playlist (as in "delete" each one, not "clear"). Alternatively: Play, random on, remove a random track from the current playlist, press next until it crashes. """ def test(self): tracks = [ Track(uri="dummy:a"), Track(uri="dummy:b"), Track(uri="dummy:c"), Track(uri="dummy:d"), Track(uri="dummy:e"), Track(uri="dummy:f"), ] self.backend.library.dummy_library = tracks self.core.tracklist.add(uris=[t.uri for t in tracks]).get() random.seed(1) self.send_request("play") self.send_request('random "1"') self.send_request('deleteid "1"') self.send_request('deleteid "2"') self.send_request('deleteid "3"') self.send_request('deleteid "4"') self.send_request('deleteid "5"') self.send_request('deleteid "6"') self.send_request("status") class IssueGH69RegressionTest(protocol.BaseTestCase): """ The issue: https://github.com/mopidy/mopidy/issues/69 How to reproduce: Play track, stop, clear current playlist, load a new playlist, status. The status response now contains "song: None". """ def test(self): self.core.playlists.create("foo") tracks = [ Track(uri="dummy:a"), Track(uri="dummy:b"), Track(uri="dummy:c"), Track(uri="dummy:d"), Track(uri="dummy:e"), Track(uri="dummy:f"), ] self.backend.library.dummy_library = tracks self.core.tracklist.add(uris=[t.uri for t in tracks]).get() self.send_request("play") self.send_request("stop") self.send_request("clear") self.send_request('load "foo"') self.assertNotInResponse("song: None") class IssueGH113RegressionTest(protocol.BaseTestCase): r""" The issue: https://github.com/mopidy/mopidy/issues/113 How to reproduce: - Have a playlist with a name contining backslashes, like "all lart spotify:track:\w\{22\} pastes". - Try to load the playlist with the backslashes in the playlist name escaped. """ def test(self): self.core.playlists.create(r"all lart spotify:track:\w\{22\} pastes") self.send_request('lsinfo "/"') self.assertInResponse( r"playlist: all lart spotify:track:\w\{22\} pastes" ) self.send_request( r'listplaylistinfo "all lart spotify:track:\\w\\{22\\} pastes"' ) self.assertInResponse("OK") class IssueGH137RegressionTest(protocol.BaseTestCase): """ The issue: https://github.com/mopidy/mopidy/issues/137 How to reproduce: - Send "list" query with mismatching quotes """ def test(self): self.send_request( 'list Date Artist "Anita Ward" ' 'Album "This Is Remixed Hits - Mashups & Rare 12" Mixes"' ) self.assertInResponse("ACK [2@0] {list} Invalid unquoted character") class IssueGH1120RegressionTest(protocol.BaseTestCase): """ The issue: https://github.com/mopidy/mopidy/issues/1120 How to reproduce: - A playlist must be in both browse results and playlists - Call for instance ``lsinfo "/"`` to populate the cache with the playlist name from the playlist backend. - Call ``lsinfo "/dummy"`` to override the playlist name with the browse name. - Call ``lsinfo "/"`` and we now have an invalid name with ``/`` in it. """ @mock.patch.object(stored_playlists, "_get_last_modified") def test(self, last_modified_mock): last_modified_mock.return_value = "2015-08-05T22:51:06Z" self.backend.library.dummy_browse_result = { "dummy:/": [Ref.playlist(name="Top 100 tracks", uri="dummy:/1")], } self.backend.playlists.set_dummy_playlists( [Playlist(name="Top 100 tracks", uri="dummy:/1")] ) response1 = self.send_request('lsinfo "/"') self.send_request('lsinfo "/dummy"') response2 = self.send_request('lsinfo "/"') assert response1 == response2 class IssueGH1348RegressionTest(protocol.BaseTestCase): """ The issue: http://github.com/mopidy/mopidy/issues/1348 """ def test(self): self.backend.library.dummy_library = [Track(uri="dummy:a")] # Create a dummy playlist and trigger population of mapping self.send_request('playlistadd "testing1" "dummy:a"') self.send_request("listplaylists") # Create an other playlist which isn't in the map self.send_request('playlistadd "testing2" "dummy:a"') assert ["OK"] == self.send_request('rm "testing2"') playlists = self.backend.playlists.as_list().get() assert ["testing1"] == [ref.name for ref in playlists] ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607133598.0 Mopidy-MPD-3.1.0/tests/protocol/test_status.py0000644000175100001710000000344500000000000020677 0ustar00runnerdockerfrom mopidy.models import Album, Track from tests import protocol class StatusHandlerTest(protocol.BaseTestCase): def test_clearerror(self): self.send_request("clearerror") self.assertEqualResponse("ACK [0@0] {clearerror} Not implemented") def test_currentsong(self): track = Track(uri="dummy:/a") self.backend.library.dummy_library = [track] self.core.tracklist.add(uris=[track.uri]).get() self.core.playback.play().get() self.send_request("currentsong") self.assertInResponse("file: dummy:/a") self.assertInResponse("Time: 0") self.assertNotInResponse("Artist: ") self.assertNotInResponse("Title: ") self.assertNotInResponse("Album: ") self.assertNotInResponse("Track: 0") self.assertNotInResponse("Date: ") self.assertInResponse("Pos: 0") self.assertInResponse("Id: 1") self.assertInResponse("OK") def test_currentsong_unicode(self): track = Track( uri="dummy:/à", name="a nàme", album=Album(uri="something:àlbum:12345"), ) self.backend.library.dummy_library = [track] self.core.tracklist.add(uris=[track.uri]).get() self.core.playback.play().get() self.send_request("currentsong") self.assertInResponse("file: dummy:/à") self.assertInResponse("Title: a nàme") self.assertInResponse("X-AlbumUri: something:àlbum:12345") def test_currentsong_without_song(self): self.send_request("currentsong") self.assertInResponse("OK") def test_stats_command(self): self.send_request("stats") self.assertInResponse("OK") def test_status_command(self): self.send_request("status") self.assertInResponse("OK") ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607133598.0 Mopidy-MPD-3.1.0/tests/protocol/test_stickers.py0000644000175100001710000000235100000000000021176 0ustar00runnerdockerfrom tests import protocol class StickersHandlerTest(protocol.BaseTestCase): def test_sticker_get(self): self.send_request('sticker get "song" "file:///dev/urandom" "a_name"') self.assertEqualResponse("ACK [0@0] {sticker} Not implemented") def test_sticker_set(self): self.send_request( 'sticker set "song" "file:///dev/urandom" "a_name" "a_value"' ) self.assertEqualResponse("ACK [0@0] {sticker} Not implemented") def test_sticker_delete_with_name(self): self.send_request( 'sticker delete "song" "file:///dev/urandom" "a_name"' ) self.assertEqualResponse("ACK [0@0] {sticker} Not implemented") def test_sticker_delete_without_name(self): self.send_request('sticker delete "song" "file:///dev/urandom"') self.assertEqualResponse("ACK [0@0] {sticker} Not implemented") def test_sticker_list(self): self.send_request('sticker list "song" "file:///dev/urandom"') self.assertEqualResponse("ACK [0@0] {sticker} Not implemented") def test_sticker_find(self): self.send_request('sticker find "song" "file:///dev/urandom" "a_name"') self.assertEqualResponse("ACK [0@0] {sticker} Not implemented") ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607133598.0 Mopidy-MPD-3.1.0/tests/protocol/test_stored_playlists.py0000644000175100001710000005003400000000000022754 0ustar00runnerdockerfrom unittest import mock from mopidy.models import Playlist, Track from mopidy_mpd.protocol import stored_playlists from tests import protocol class PlaylistsHandlerTest(protocol.BaseTestCase): def test_listplaylist(self): self.backend.playlists.set_dummy_playlists( [ Playlist( name="name", uri="dummy:name", tracks=[Track(uri="dummy:a")] ) ] ) self.send_request('listplaylist "name"') self.assertInResponse("file: dummy:a") self.assertInResponse("OK") def test_listplaylist_unicode(self): self.backend.playlists.set_dummy_playlists( [ Playlist( name="nàmé", uri="dummy:nàmé", tracks=[Track(uri="dummy:à")] ) ] ) self.send_request('listplaylist "nàmé"') self.assertInResponse("file: dummy:à") self.assertInResponse("OK") def test_listplaylist_without_quotes(self): self.backend.playlists.set_dummy_playlists( [ Playlist( name="name", uri="dummy:name", tracks=[Track(uri="dummy:a")] ) ] ) self.send_request("listplaylist name") self.assertInResponse("file: dummy:a") self.assertInResponse("OK") def test_listplaylist_fails_if_no_playlist_is_found(self): self.send_request('listplaylist "name"') self.assertEqualResponse("ACK [50@0] {listplaylist} No such playlist") def test_listplaylist_duplicate(self): playlist1 = Playlist(name="a", uri="dummy:a1", tracks=[Track(uri="b")]) playlist2 = Playlist(name="a", uri="dummy:a2", tracks=[Track(uri="c")]) self.backend.playlists.set_dummy_playlists([playlist1, playlist2]) self.send_request('listplaylist "a [2]"') self.assertInResponse("file: c") self.assertInResponse("OK") def test_listplaylistinfo(self): tracks = [ Track(uri="dummy:a", name="Track A", length=5000), Track(uri="dummy:é", name="Track é", length=5000), ] self.backend.library.dummy_library = tracks self.backend.playlists.set_dummy_playlists( [Playlist(name="name", uri="dummy:name", tracks=tracks)] ) self.send_request('listplaylistinfo "name"') self.assertInResponse("file: dummy:a") self.assertInResponse("Title: Track A") self.assertInResponse("Time: 5") self.assertInResponse("file: dummy:é") self.assertInResponse("Title: Track é") self.assertNotInResponse("Track: 0") self.assertNotInResponse("Pos: 0") self.assertInResponse("OK") def test_listplaylistinfo_without_quotes(self): tracks = [ Track(uri="dummy:a"), ] self.backend.library.dummy_library = tracks self.backend.playlists.set_dummy_playlists( [Playlist(name="name", uri="dummy:name", tracks=tracks)] ) self.send_request("listplaylistinfo name") self.assertInResponse("file: dummy:a") self.assertNotInResponse("Track: 0") self.assertNotInResponse("Pos: 0") self.assertInResponse("OK") def test_listplaylistinfo_fails_if_no_playlist_is_found(self): self.send_request('listplaylistinfo "name"') self.assertEqualResponse( "ACK [50@0] {listplaylistinfo} No such playlist" ) def test_listplaylistinfo_duplicate(self): tracks = [ Track(uri="dummy:b"), Track(uri="dummy:c"), ] self.backend.library.dummy_library = tracks playlist1 = Playlist(name="a", uri="dummy:a1", tracks=tracks[:1]) playlist2 = Playlist(name="a", uri="dummy:a2", tracks=tracks[1:]) self.backend.playlists.set_dummy_playlists([playlist1, playlist2]) self.send_request('listplaylistinfo "a [2]"') self.assertInResponse("file: dummy:c") self.assertNotInResponse("Track: 0") self.assertNotInResponse("Pos: 0") self.assertInResponse("OK") @mock.patch.object(stored_playlists, "_get_last_modified") def test_listplaylists(self, last_modified_mock): last_modified_mock.return_value = "2015-08-05T22:51:06Z" self.backend.playlists.set_dummy_playlists( [ Playlist(name="a", uri="dummy:a"), Playlist(name="é", uri="dummy:é"), ] ) self.send_request("listplaylists") self.assertInResponse("playlist: a") self.assertInResponse("playlist: é") # Date without milliseconds and with time zone information self.assertInResponse("Last-Modified: 2015-08-05T22:51:06Z") self.assertInResponse("OK") def test_listplaylists_duplicate(self): playlist1 = Playlist(name="a", uri="dummy:a1") playlist2 = Playlist(name="a", uri="dummy:a2") self.backend.playlists.set_dummy_playlists([playlist1, playlist2]) self.send_request("listplaylists") self.assertInResponse("playlist: a") self.assertInResponse("playlist: a [2]") self.assertInResponse("OK") def test_listplaylists_ignores_playlists_without_name(self): last_modified = 1390942873222 self.backend.playlists.set_dummy_playlists( [Playlist(name="", uri="dummy:", last_modified=last_modified)] ) self.send_request("listplaylists") self.assertNotInResponse("playlist: ") self.assertInResponse("OK") def test_listplaylists_replaces_newline_with_space(self): self.backend.playlists.set_dummy_playlists( [Playlist(name="a\n", uri="dummy:")] ) self.send_request("listplaylists") self.assertInResponse("playlist: a ") self.assertNotInResponse("playlist: a\n") self.assertInResponse("OK") def test_listplaylists_replaces_carriage_return_with_space(self): self.backend.playlists.set_dummy_playlists( [Playlist(name="a\r", uri="dummy:")] ) self.send_request("listplaylists") self.assertInResponse("playlist: a ") self.assertNotInResponse("playlist: a\r") self.assertInResponse("OK") def test_listplaylists_replaces_forward_slash_with_pipe(self): self.backend.playlists.set_dummy_playlists( [Playlist(name="a/b", uri="dummy:")] ) self.send_request("listplaylists") self.assertInResponse("playlist: a|b") self.assertNotInResponse("playlist: a/b") self.assertInResponse("OK") def test_load_appends_to_tracklist(self): tracks = [ Track(uri="dummy:a"), Track(uri="dummy:b"), Track(uri="dummy:c"), Track(uri="dummy:d"), Track(uri="dummy:ǫ"), ] self.backend.library.dummy_library = tracks self.core.tracklist.add(uris=["dummy:a", "dummy:b"]).get() assert len(self.core.tracklist.get_tracks().get()) == 2 self.backend.playlists.set_dummy_playlists( [Playlist(name="A-list", uri="dummy:A-list", tracks=tracks[2:])] ) self.send_request('load "A-list"') tracks = self.core.tracklist.get_tracks().get() assert 5 == len(tracks) assert "dummy:a" == tracks[0].uri assert "dummy:b" == tracks[1].uri assert "dummy:c" == tracks[2].uri assert "dummy:d" == tracks[3].uri assert "dummy:ǫ" == tracks[4].uri self.assertInResponse("OK") def test_load_with_range_loads_part_of_playlist(self): tracks = [ Track(uri="dummy:a"), Track(uri="dummy:b"), Track(uri="dummy:c"), Track(uri="dummy:d"), Track(uri="dummy:e"), ] self.backend.library.dummy_library = tracks self.core.tracklist.add(uris=["dummy:a", "dummy:b"]).get() assert len(self.core.tracklist.get_tracks().get()) == 2 self.backend.playlists.set_dummy_playlists( [Playlist(name="A-list", uri="dummy:A-list", tracks=tracks[2:])] ) self.send_request('load "A-list" "1:2"') tracks = self.core.tracklist.get_tracks().get() assert 3 == len(tracks) assert "dummy:a" == tracks[0].uri assert "dummy:b" == tracks[1].uri assert "dummy:d" == tracks[2].uri self.assertInResponse("OK") def test_load_with_range_without_end_loads_rest_of_playlist(self): tracks = [ Track(uri="dummy:a"), Track(uri="dummy:b"), Track(uri="dummy:c"), Track(uri="dummy:d"), Track(uri="dummy:e"), ] self.backend.library.dummy_library = tracks self.core.tracklist.add(uris=["dummy:a", "dummy:b"]).get() assert len(self.core.tracklist.get_tracks().get()) == 2 self.backend.playlists.set_dummy_playlists( [Playlist(name="A-list", uri="dummy:A-list", tracks=tracks[2:])] ) self.send_request('load "A-list" "1:"') tracks = self.core.tracklist.get_tracks().get() assert 4 == len(tracks) assert "dummy:a" == tracks[0].uri assert "dummy:b" == tracks[1].uri assert "dummy:d" == tracks[2].uri assert "dummy:e" == tracks[3].uri self.assertInResponse("OK") def test_load_unknown_playlist_acks(self): self.send_request('load "unknown playlist"') assert 0 == len(self.core.tracklist.get_tracks().get()) self.assertEqualResponse("ACK [50@0] {load} No such playlist") # No invalid name check for load. self.send_request('load "unknown/playlist"') self.assertEqualResponse("ACK [50@0] {load} No such playlist") def test_load_full_track_metadata(self): tracks = [ Track(uri="dummy:a", name="Track A", length=5000), ] self.backend.library.dummy_library = tracks self.backend.playlists.set_dummy_playlists( [ Playlist( name="A-list", uri="dummy:a1", tracks=[Track(uri="dummy:a")] ) ] ) self.send_request('load "A-list"') tracks = self.core.tracklist.get_tracks().get() assert len(tracks) == 1 assert tracks[0].uri == "dummy:a" assert tracks[0].name == "Track A" assert tracks[0].length == 5000 self.assertInResponse("OK") def test_playlistadd(self): tracks = [ Track(uri="dummy:a"), Track(uri="dummy:b"), ] self.backend.library.dummy_library = tracks self.backend.playlists.set_dummy_playlists( [Playlist(name="name", uri="dummy:a1", tracks=[tracks[0]])] ) self.send_request('playlistadd "name" "dummy:b"') self.assertInResponse("OK") assert 2 == len(self.backend.playlists.get_items("dummy:a1").get()) def test_playlistadd_creates_playlist(self): tracks = [ Track(uri="dummy:é"), ] self.backend.library.dummy_library = tracks self.send_request('playlistadd "namé" "dummy:é"') self.assertInResponse("OK") assert self.backend.playlists.lookup("dummy:namé").get() is not None def test_playlistadd_invalid_name_acks(self): self.send_request('playlistadd "foo/bar" "dummy:a"') self.assertInResponse( "ACK [2@0] {playlistadd} playlist name is " "invalid: playlist names may not contain " "slashes, newlines or carriage returns" ) def test_playlistclear(self): self.backend.playlists.set_dummy_playlists( [Playlist(name="namé", uri="dummy:a1", tracks=[Track(uri="b")])] ) self.send_request('playlistclear "namé"') self.assertInResponse("OK") assert 0 == len(self.backend.playlists.get_items("dummy:a1").get()) def test_playlistclear_creates_playlist(self): self.send_request('playlistclear "name"') self.assertInResponse("OK") assert self.backend.playlists.lookup("dummy:name").get() is not None def test_playlistclear_creates_playlist_save_fails(self): self.backend.playlists.set_allow_save(False) self.send_request('playlistclear "name"') self.assertInResponse( "ACK [0@0] {playlistclear} Backend with " 'scheme "dummy" failed to save playlist' ) def test_playlistclear_invalid_name_acks(self): self.send_request('playlistclear "foo/bar"') self.assertInResponse( "ACK [2@0] {playlistclear} playlist name is " "invalid: playlist names may not contain " "slashes, newlines or carriage returns" ) def test_playlistdelete(self): tracks = [ Track(uri="dummy:a"), Track(uri="dummy:b"), Track(uri="dummy:c"), ] # len() == 3 self.backend.playlists.set_dummy_playlists( [Playlist(name="namé", uri="dummy:a1", tracks=tracks)] ) self.send_request('playlistdelete "namé" "2"') self.assertInResponse("OK") assert 2 == len(self.backend.playlists.get_items("dummy:a1").get()) def test_playlistdelete_save_fails(self): tracks = [ Track(uri="dummy:a"), Track(uri="dummy:b"), Track(uri="dummy:c"), ] # len() == 3 self.backend.playlists.set_dummy_playlists( [Playlist(name="name", uri="dummy:a1", tracks=tracks)] ) self.backend.playlists.set_allow_save(False) self.send_request('playlistdelete "name" "2"') self.assertInResponse( "ACK [0@0] {playlistdelete} Backend with " 'scheme "dummy" failed to save playlist' ) def test_playlistdelete_invalid_name_acks(self): self.send_request('playlistdelete "foo/bar" "0"') self.assertInResponse( "ACK [2@0] {playlistdelete} playlist name is " "invalid: playlist names may not contain " "slashes, newlines or carriage returns" ) def test_playlistdelete_unknown_playlist_acks(self): self.send_request('playlistdelete "foobar" "0"') self.assertInResponse("ACK [50@0] {playlistdelete} No such playlist") def test_playlistdelete_unknown_index_acks(self): self.send_request('save "foobar"') self.send_request('playlistdelete "foobar" "0"') self.assertInResponse("ACK [2@0] {playlistdelete} Bad song index") def test_playlistmove(self): tracks = [ Track(uri="dummy:a"), Track(uri="dummy:b"), Track(uri="dummy:c"), # this one is getting moved to top ] self.backend.playlists.set_dummy_playlists( [Playlist(name="namé", uri="dummy:a1", tracks=tracks)] ) self.send_request('playlistmove "namé" "2" "0"') self.assertInResponse("OK") assert ( "dummy:c" == self.backend.playlists.get_items("dummy:a1").get()[0].uri ) def test_playlistmove_save_fails(self): tracks = [ Track(uri="dummy:a"), Track(uri="dummy:b"), Track(uri="dummy:c"), # this one is getting moved to top ] self.backend.playlists.set_dummy_playlists( [Playlist(name="name", uri="dummy:a1", tracks=tracks)] ) self.backend.playlists.set_allow_save(False) self.send_request('playlistmove "name" "2" "0"') self.assertInResponse( "ACK [0@0] {playlistmove} Backend with " 'scheme "dummy" failed to save playlist' ) def test_playlistmove_invalid_name_acks(self): self.send_request('playlistmove "foo/bar" "0" "1"') self.assertInResponse( "ACK [2@0] {playlistmove} playlist name is " "invalid: playlist names may not contain " "slashes, newlines or carriage returns" ) def test_playlistmove_unknown_playlist_acks(self): self.send_request('playlistmove "foobar" "0" "1"') self.assertInResponse("ACK [50@0] {playlistmove} No such playlist") def test_playlistmove_unknown_position_acks(self): self.send_request('save "foobar"') self.send_request('playlistmove "foobar" "0" "1"') self.assertInResponse("ACK [2@0] {playlistmove} Bad song index") def test_playlistmove_same_index_shortcircuits_everything(self): # Bad indexes on unknown playlist: self.send_request('playlistmove "foobar" "0" "0"') self.assertInResponse("OK") self.send_request('playlistmove "foobar" "100000" "100000"') self.assertInResponse("OK") # Bad indexes on known playlist: self.send_request('save "foobar"') self.send_request('playlistmove "foobar" "0" "0"') self.assertInResponse("OK") self.send_request('playlistmove "foobar" "10" "10"') self.assertInResponse("OK") # Invalid playlist name: self.send_request('playlistmove "foo/bar" "0" "0"') self.assertInResponse("OK") def test_rename(self): self.backend.playlists.set_dummy_playlists( [Playlist(name="old_name", uri="dummy:a1", tracks=[Track(uri="b")])] ) self.send_request('rename "old_name" "new_namé"') self.assertInResponse("OK") assert self.backend.playlists.lookup("dummy:new_namé").get() is not None def test_rename_save_fails(self): self.backend.playlists.set_dummy_playlists( [Playlist(name="old_name", uri="dummy:a1", tracks=[Track(uri="b")])] ) self.backend.playlists.set_allow_save(False) self.send_request('rename "old_name" "new_name"') self.assertInResponse( "ACK [0@0] {rename} Backend with " 'scheme "dummy" failed to save playlist' ) def test_rename_unknown_playlist_acks(self): self.send_request('rename "foo" "bar"') self.assertInResponse("ACK [50@0] {rename} No such playlist") def test_rename_to_existing_acks(self): self.send_request('save "foo"') self.send_request('save "bar"') self.send_request('rename "foo" "bar"') self.assertInResponse("ACK [56@0] {rename} Playlist already exists") def test_rename_invalid_name_acks(self): expected = ( "ACK [2@0] {rename} playlist name is invalid: playlist " "names may not contain slashes, newlines or carriage " "returns" ) self.send_request('rename "foo/bar" "bar"') self.assertInResponse(expected) self.send_request('rename "foo" "foo/bar"') self.assertInResponse(expected) self.send_request('rename "bar/foo" "foo/bar"') self.assertInResponse(expected) def test_rm(self): self.backend.playlists.set_dummy_playlists( [Playlist(name="namé", uri="dummy:à1", tracks=[Track(uri="b")])] ) self.send_request('rm "namé"') self.assertInResponse("OK") assert self.backend.playlists.lookup("dummy:à1").get() is None def test_rm_unknown_playlist_acks(self): self.send_request('rm "name"') self.assertInResponse("ACK [50@0] {rm} No such playlist") def test_rm_invalid_name_acks(self): self.send_request('rm "foo/bar"') self.assertInResponse( "ACK [2@0] {rm} playlist name is invalid: " "playlist names may not contain slashes, " "newlines or carriage returns" ) def test_save(self): self.send_request('save "namé"') self.assertInResponse("OK") assert self.backend.playlists.lookup("dummy:namé").get() is not None def test_save_fails(self): self.backend.playlists.set_allow_save(False) self.send_request('save "name"') self.assertInResponse( "ACK [0@0] {save} Backend with " 'scheme "dummy" failed to save playlist' ) def test_save_invalid_name_acks(self): self.send_request('save "foo/bar"') self.assertInResponse( "ACK [2@0] {save} playlist name is invalid: " "playlist names may not contain slashes, " "newlines or carriage returns" ) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607133598.0 Mopidy-MPD-3.1.0/tests/test_actor.py0000644000175100001710000000322200000000000016614 0ustar00runnerdockerfrom unittest import mock import pytest from mopidy_mpd import actor # NOTE: Should be kept in sync with all events from mopidy.core.listener @pytest.mark.parametrize( "event,expected", [ (["track_playback_paused", "tl_track", "time_position"], None), (["track_playback_resumed", "tl_track", "time_position"], None), (["track_playback_started", "tl_track"], None), (["track_playback_ended", "tl_track", "time_position"], None), (["playback_state_changed", "old_state", "new_state"], "player"), (["tracklist_changed"], "playlist"), (["playlists_loaded"], "stored_playlist"), (["playlist_changed", "playlist"], "stored_playlist"), (["playlist_deleted", "uri"], "stored_playlist"), (["options_changed"], "options"), (["volume_changed", "volume"], "mixer"), (["mute_changed", "mute"], "output"), (["seeked", "time_position"], "player"), (["stream_title_changed", "title"], "playlist"), ], ) def test_idle_hooked_up_correctly(event, expected): config = { "mpd": { "hostname": "foobar", "port": 1234, "zeroconf": None, "max_connections": None, "connection_timeout": None, } } with mock.patch.object(actor.MpdFrontend, "_setup_server"): frontend = actor.MpdFrontend(core=mock.Mock(), config=config) with mock.patch("mopidy.listener.send") as send_mock: frontend.on_event(event[0], **{e: None for e in event[1:]}) if expected is None: assert not send_mock.call_args else: send_mock.assert_called_once_with(mock.ANY, expected) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607133598.0 Mopidy-MPD-3.1.0/tests/test_commands.py0000644000175100001710000002336500000000000017317 0ustar00runnerdockerimport unittest from mopidy_mpd import exceptions, protocol class TestConverts(unittest.TestCase): def test_integer(self): assert 123 == protocol.INT("123") assert (-123) == protocol.INT("-123") assert 123 == protocol.INT("+123") self.assertRaises(ValueError, protocol.INT, "3.14") self.assertRaises(ValueError, protocol.INT, "") self.assertRaises(ValueError, protocol.INT, "abc") self.assertRaises(ValueError, protocol.INT, "12 34") def test_unsigned_integer(self): assert 123 == protocol.UINT("123") self.assertRaises(ValueError, protocol.UINT, "-123") self.assertRaises(ValueError, protocol.UINT, "+123") self.assertRaises(ValueError, protocol.UINT, "3.14") self.assertRaises(ValueError, protocol.UINT, "") self.assertRaises(ValueError, protocol.UINT, "abc") self.assertRaises(ValueError, protocol.UINT, "12 34") def test_boolean(self): assert protocol.BOOL("1") is True assert protocol.BOOL("0") is False self.assertRaises(ValueError, protocol.BOOL, "3.14") self.assertRaises(ValueError, protocol.BOOL, "") self.assertRaises(ValueError, protocol.BOOL, "true") self.assertRaises(ValueError, protocol.BOOL, "false") self.assertRaises(ValueError, protocol.BOOL, "abc") self.assertRaises(ValueError, protocol.BOOL, "12 34") def test_range(self): assert slice(1, 2) == protocol.RANGE("1") assert slice(0, 1) == protocol.RANGE("0") assert slice(0, None) == protocol.RANGE("0:") assert slice(1, 3) == protocol.RANGE("1:3") self.assertRaises(ValueError, protocol.RANGE, "3.14") self.assertRaises(ValueError, protocol.RANGE, "1:abc") self.assertRaises(ValueError, protocol.RANGE, "abc:1") self.assertRaises(ValueError, protocol.RANGE, "2:1") self.assertRaises(ValueError, protocol.RANGE, "-1:2") self.assertRaises(ValueError, protocol.RANGE, "1 : 2") self.assertRaises(ValueError, protocol.RANGE, "") self.assertRaises(ValueError, protocol.RANGE, "true") self.assertRaises(ValueError, protocol.RANGE, "false") self.assertRaises(ValueError, protocol.RANGE, "abc") self.assertRaises(ValueError, protocol.RANGE, "12 34") class TestCommands(unittest.TestCase): def setUp(self): # noqa: N802 self.commands = protocol.Commands() def test_add_as_a_decorator(self): @self.commands.add("test") def test(context): pass def test_register_second_command_to_same_name_fails(self): def func(context): pass self.commands.add("foo")(func) with self.assertRaises(Exception): self.commands.add("foo")(func) def test_function_only_takes_context_succeeds(self): sentinel = object() self.commands.add("bar")(lambda context: sentinel) assert sentinel == self.commands.call(["bar"]) def test_function_has_required_arg_succeeds(self): sentinel = object() self.commands.add("bar")(lambda context, required: sentinel) assert sentinel == self.commands.call(["bar", "arg"]) def test_function_has_optional_args_succeeds(self): sentinel = object() self.commands.add("bar")(lambda context, optional=None: sentinel) assert sentinel == self.commands.call(["bar"]) assert sentinel == self.commands.call(["bar", "arg"]) def test_function_has_required_and_optional_args_succeeds(self): sentinel = object() def func(context, required, optional=None): return sentinel self.commands.add("bar")(func) assert sentinel == self.commands.call(["bar", "arg"]) assert sentinel == self.commands.call(["bar", "arg", "arg"]) def test_function_has_varargs_succeeds(self): sentinel, args = object(), [] self.commands.add("bar")(lambda context, *args: sentinel) for _ in range(10): assert sentinel == self.commands.call((["bar"] + args)) args.append("test") def test_function_has_only_varags_succeeds(self): sentinel = object() self.commands.add("baz")(lambda *args: sentinel) assert sentinel == self.commands.call(["baz"]) def test_function_has_no_arguments_fails(self): with self.assertRaises(TypeError): self.commands.add("test")(lambda: True) def test_function_has_required_and_varargs_fails(self): with self.assertRaises(TypeError): def func(context, required, *args): pass self.commands.add("test")(func) def test_function_has_optional_and_varargs_fails(self): with self.assertRaises(TypeError): def func(context, optional=None, *args): pass self.commands.add("test")(func) def test_function_hash_keywordargs_fails(self): with self.assertRaises(TypeError): self.commands.add("test")(lambda context, **kwargs: True) def test_call_chooses_correct_handler(self): sentinel1, sentinel2, sentinel3 = object(), object(), object() self.commands.add("foo")(lambda context: sentinel1) self.commands.add("bar")(lambda context: sentinel2) self.commands.add("baz")(lambda context: sentinel3) assert sentinel1 == self.commands.call(["foo"]) assert sentinel2 == self.commands.call(["bar"]) assert sentinel3 == self.commands.call(["baz"]) def test_call_with_nonexistent_handler(self): with self.assertRaises(exceptions.MpdUnknownCommand): self.commands.call(["bar"]) def test_call_passes_context(self): sentinel = object() self.commands.add("foo")(lambda context: context) assert sentinel == self.commands.call(["foo"], context=sentinel) def test_call_without_args_fails(self): with self.assertRaises(exceptions.MpdNoCommand): self.commands.call([]) def test_call_passes_required_argument(self): self.commands.add("foo")(lambda context, required: required) assert "test123" == self.commands.call(["foo", "test123"]) def test_call_passes_optional_argument(self): sentinel = object() self.commands.add("foo")(lambda context, optional=sentinel: optional) assert sentinel == self.commands.call(["foo"]) assert "test" == self.commands.call(["foo", "test"]) def test_call_passes_required_and_optional_argument(self): def func(context, required, optional=None): return (required, optional) self.commands.add("foo")(func) assert ("arg", None) == self.commands.call(["foo", "arg"]) assert ("arg", "kwarg") == self.commands.call(["foo", "arg", "kwarg"]) def test_call_passes_varargs(self): self.commands.add("foo")(lambda context, *args: args) def test_call_incorrect_args(self): self.commands.add("foo")(lambda context: context) with self.assertRaises(exceptions.MpdArgError): self.commands.call(["foo", "bar"]) self.commands.add("bar")(lambda context, required: context) with self.assertRaises(exceptions.MpdArgError): self.commands.call(["bar", "bar", "baz"]) self.commands.add("baz")(lambda context, optional=None: context) with self.assertRaises(exceptions.MpdArgError): self.commands.call(["baz", "bar", "baz"]) def test_validator_gets_applied_to_required_arg(self): sentinel = object() def func(context, required): return required self.commands.add("test", required=lambda v: sentinel)(func) assert sentinel == self.commands.call(["test", "foo"]) def test_validator_gets_applied_to_optional_arg(self): sentinel = object() def func(context, optional=None): return optional self.commands.add("foo", optional=lambda v: sentinel)(func) assert sentinel == self.commands.call(["foo", "123"]) def test_validator_skips_optional_default(self): sentinel = object() def func(context, optional=sentinel): return optional self.commands.add("foo", optional=lambda v: None)(func) assert sentinel == self.commands.call(["foo"]) def test_validator_applied_to_non_existent_arg_fails(self): self.commands.add("foo")(lambda context, arg: arg) with self.assertRaises(TypeError): def func(context, wrong_arg): return wrong_arg self.commands.add("bar", arg=lambda v: v)(func) def test_validator_called_context_fails(self): return # TODO: how to handle this with self.assertRaises(TypeError): def func(context): pass self.commands.add("bar", context=lambda v: v)(func) def test_validator_value_error_is_converted(self): def validdate(value): raise ValueError def func(context, arg): pass self.commands.add("bar", arg=validdate)(func) with self.assertRaises(exceptions.MpdArgError): self.commands.call(["bar", "test"]) def test_auth_required_gets_stored(self): def func1(context): pass def func2(context): pass self.commands.add("foo")(func1) self.commands.add("bar", auth_required=False)(func2) assert self.commands.handlers["foo"].auth_required assert not self.commands.handlers["bar"].auth_required def test_list_command_gets_stored(self): def func1(context): pass def func2(context): pass self.commands.add("foo")(func1) self.commands.add("bar", list_command=False)(func2) assert self.commands.handlers["foo"].list_command assert not self.commands.handlers["bar"].list_command ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607133598.0 Mopidy-MPD-3.1.0/tests/test_dispatcher.py0000644000175100001710000000645400000000000017644 0ustar00runnerdockerimport unittest import pykka import pytest from mopidy import core from mopidy.models import Ref from mopidy_mpd.dispatcher import MpdContext, MpdDispatcher from mopidy_mpd.exceptions import MpdAckError from mopidy_mpd.uri_mapper import MpdUriMapper from tests import dummy_backend class MpdDispatcherTest(unittest.TestCase): def setUp(self): # noqa: N802 config = {"mpd": {"password": None, "command_blacklist": ["disabled"]}} self.backend = dummy_backend.create_proxy() self.dispatcher = MpdDispatcher(config=config) self.core = core.Core.start(backends=[self.backend]).proxy() def tearDown(self): # noqa: N802 pykka.ActorRegistry.stop_all() def test_call_handler_for_unknown_command_raises_exception(self): with self.assertRaises(MpdAckError) as cm: self.dispatcher._call_handler("an_unknown_command with args") assert ( cm.exception.get_mpd_ack() == 'ACK [5@0] {} unknown command "an_unknown_command"' ) def test_handling_unknown_request_yields_error(self): result = self.dispatcher.handle_request("an unhandled request") assert result[0] == 'ACK [5@0] {} unknown command "an"' def test_handling_blacklisted_command(self): result = self.dispatcher.handle_request("disabled") assert ( result[0] == 'ACK [0@0] {disabled} "disabled" has been disabled in the server' ) @pytest.fixture def a_track(): return Ref.track(uri="dummy:/a", name="a") @pytest.fixture def b_track(): return Ref.track(uri="dummy:/foo/b", name="b") @pytest.fixture def backend_to_browse(a_track, b_track): backend = dummy_backend.create_proxy() backend.library.dummy_browse_result = { "dummy:/": [a_track, Ref.directory(uri="dummy:/foo", name="foo")], "dummy:/foo": [b_track], } return backend @pytest.fixture def mpd_context(backend_to_browse): mopidy_core = core.Core.start(backends=[backend_to_browse]).proxy() uri_map = MpdUriMapper(mopidy_core) return MpdContext(None, core=mopidy_core, uri_map=uri_map) class TestMpdContext: @classmethod def teardown_class(cls): pykka.ActorRegistry.stop_all() def test_browse_root(self, mpd_context, a_track): results = mpd_context.browse("dummy", recursive=False, lookup=False) assert [("/dummy/a", a_track), ("/dummy/foo", None)] == list(results) def test_browse_root_recursive(self, mpd_context, a_track, b_track): results = mpd_context.browse("dummy", recursive=True, lookup=False) assert [ ("/dummy", None), ("/dummy/a", a_track), ("/dummy/foo", None), ("/dummy/foo/b", b_track), ] == list(results) @pytest.mark.parametrize( "bad_ref", [ Ref.track(uri="dummy:/x"), Ref.track(name="x"), Ref.directory(uri="dummy:/y"), Ref.directory(name="y"), ], ) def test_browse_skips_bad_refs( self, backend_to_browse, a_track, bad_ref, mpd_context ): backend_to_browse.library.dummy_browse_result = { "dummy:/": [bad_ref, a_track], } results = mpd_context.browse("dummy", recursive=False, lookup=False) assert [("/dummy/a", a_track)] == list(results) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607133598.0 Mopidy-MPD-3.1.0/tests/test_exceptions.py0000644000175100001710000000370300000000000017671 0ustar00runnerdockerimport unittest from mopidy_mpd.exceptions import ( MpdAckError, MpdNoCommand, MpdNoExistError, MpdNotImplemented, MpdPermissionError, MpdSystemError, MpdUnknownCommand, ) class MpdExceptionsTest(unittest.TestCase): def test_mpd_not_implemented_is_a_mpd_ack_error(self): try: raise MpdNotImplemented except MpdAckError as exc: assert ( exc.message # noqa: B306: Our own exception == "Not implemented" ) def test_get_mpd_ack_with_default_values(self): e = MpdAckError("A description") assert e.get_mpd_ack() == "ACK [0@0] {None} A description" def test_get_mpd_ack_with_values(self): try: raise MpdAckError("A description", index=7, command="foo") except MpdAckError as e: assert e.get_mpd_ack() == "ACK [0@7] {foo} A description" def test_mpd_unknown_command(self): try: raise MpdUnknownCommand(command="play") except MpdAckError as e: assert e.get_mpd_ack() == 'ACK [5@0] {} unknown command "play"' def test_mpd_no_command(self): try: raise MpdNoCommand except MpdAckError as e: assert e.get_mpd_ack() == "ACK [5@0] {} No command given" def test_mpd_system_error(self): try: raise MpdSystemError("foo") except MpdSystemError as e: assert e.get_mpd_ack() == "ACK [52@0] {None} foo" def test_mpd_permission_error(self): try: raise MpdPermissionError(command="foo") except MpdPermissionError as e: assert ( e.get_mpd_ack() == 'ACK [4@0] {foo} you don\'t have permission for "foo"' ) def test_mpd_noexist_error(self): try: raise MpdNoExistError(command="foo") except MpdNoExistError as e: assert e.get_mpd_ack() == "ACK [50@0] {foo} " ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607133598.0 Mopidy-MPD-3.1.0/tests/test_extension.py0000644000175100001710000000110500000000000017516 0ustar00runnerdockerfrom mopidy_mpd import Extension def test_get_default_config(): ext = Extension() config = ext.get_default_config() assert "[mpd]" in config assert "enabled = true" in config def test_get_config_schema(): ext = Extension() schema = ext.get_config_schema() assert "hostname" in schema assert "port" in schema assert "password" in schema assert "max_connections" in schema assert "connection_timeout" in schema assert "zeroconf" in schema assert "command_blacklist" in schema assert "default_playlist_scheme" in schema ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607133598.0 Mopidy-MPD-3.1.0/tests/test_path_utils.py0000644000175100001710000000077200000000000017667 0ustar00runnerdockerimport os import unittest from tests import path_utils # TODO: kill this in favour of just os.path.getmtime + mocks class MtimeTest(unittest.TestCase): def tearDown(self): # noqa: N802 path_utils.mtime.undo_fake() def test_mtime_of_current_dir(self): mtime_dir = int(os.stat(".").st_mtime) assert mtime_dir == path_utils.mtime(".") def test_fake_time_is_returned(self): path_utils.mtime.set_fake_time(123456) assert path_utils.mtime(".") == 123456 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607133598.0 Mopidy-MPD-3.1.0/tests/test_session.py0000644000175100001710000000150500000000000017171 0ustar00runnerdockerimport logging from unittest.mock import Mock, sentinel from mopidy_mpd import dispatcher, network, session def test_on_start_logged(caplog): caplog.set_level(logging.INFO) connection = Mock(spec=network.Connection) session.MpdSession(connection).on_start() assert f"New MPD connection from {connection}" in caplog.text def test_on_line_received_logged(caplog): caplog.set_level(logging.DEBUG) connection = Mock(spec=network.Connection) mpd_session = session.MpdSession(connection) mpd_session.dispatcher = Mock(spec=dispatcher.MpdDispatcher) mpd_session.dispatcher.handle_request.return_value = [str(sentinel.resp)] mpd_session.on_line_received(sentinel.line) assert f"Request from {connection}: {sentinel.line}" in caplog.text assert f"Response to {connection}:" in caplog.text ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607133598.0 Mopidy-MPD-3.1.0/tests/test_status.py0000644000175100001710000001775500000000000017047 0ustar00runnerdockerimport unittest import pykka from mopidy import core from mopidy.core import PlaybackState from mopidy.models import Track from mopidy_mpd import dispatcher from mopidy_mpd.protocol import status from tests import dummy_audio, dummy_backend, dummy_mixer PAUSED = PlaybackState.PAUSED PLAYING = PlaybackState.PLAYING STOPPED = PlaybackState.STOPPED # FIXME migrate to using protocol.BaseTestCase instead of status.stats # directly? class StatusHandlerTest(unittest.TestCase): def setUp(self): # noqa: N802 config = {"core": {"max_tracklist_length": 10000}} self.audio = dummy_audio.create_proxy() self.mixer = dummy_mixer.create_proxy() self.backend = dummy_backend.create_proxy(audio=self.audio) self.core = core.Core.start( config, audio=self.audio, mixer=self.mixer, backends=[self.backend], ).proxy() self.dispatcher = dispatcher.MpdDispatcher(core=self.core) self.context = self.dispatcher.context def tearDown(self): # noqa: N802 pykka.ActorRegistry.stop_all() def set_tracklist(self, tracks): self.backend.library.dummy_library = tracks self.core.tracklist.add(uris=[track.uri for track in tracks]).get() def test_stats_method(self): result = status.stats(self.context) assert "artists" in result assert int(result["artists"]) >= 0 assert "albums" in result assert int(result["albums"]) >= 0 assert "songs" in result assert int(result["songs"]) >= 0 assert "uptime" in result assert int(result["uptime"]) >= 0 assert "db_playtime" in result assert int(result["db_playtime"]) >= 0 assert "db_update" in result assert int(result["db_update"]) >= 0 assert "playtime" in result assert int(result["playtime"]) >= 0 def test_status_method_contains_volume_with_na_value(self): result = dict(status.status(self.context)) assert "volume" in result assert int(result["volume"]) == (-1) def test_status_method_contains_volume(self): self.core.mixer.set_volume(17) result = dict(status.status(self.context)) assert "volume" in result assert int(result["volume"]) == 17 def test_status_method_contains_repeat_is_0(self): result = dict(status.status(self.context)) assert "repeat" in result assert int(result["repeat"]) == 0 def test_status_method_contains_repeat_is_1(self): self.core.tracklist.set_repeat(True) result = dict(status.status(self.context)) assert "repeat" in result assert int(result["repeat"]) == 1 def test_status_method_contains_random_is_0(self): result = dict(status.status(self.context)) assert "random" in result assert int(result["random"]) == 0 def test_status_method_contains_random_is_1(self): self.core.tracklist.set_random(True) result = dict(status.status(self.context)) assert "random" in result assert int(result["random"]) == 1 def test_status_method_contains_single(self): result = dict(status.status(self.context)) assert "single" in result assert int(result["single"]) in (0, 1) def test_status_method_contains_consume_is_0(self): result = dict(status.status(self.context)) assert "consume" in result assert int(result["consume"]) == 0 def test_status_method_contains_consume_is_1(self): self.core.tracklist.set_consume(True) result = dict(status.status(self.context)) assert "consume" in result assert int(result["consume"]) == 1 def test_status_method_contains_playlist(self): result = dict(status.status(self.context)) assert "playlist" in result assert int(result["playlist"]) >= 0 assert int(result["playlist"]) <= ((2 ** 31) - 1) def test_status_method_contains_playlistlength(self): result = dict(status.status(self.context)) assert "playlistlength" in result assert int(result["playlistlength"]) >= 0 def test_status_method_contains_xfade(self): result = dict(status.status(self.context)) assert "xfade" in result assert int(result["xfade"]) >= 0 def test_status_method_contains_state_is_play(self): self.core.playback.set_state(PLAYING) result = dict(status.status(self.context)) assert "state" in result assert result["state"] == "play" def test_status_method_contains_state_is_stop(self): self.core.playback.set_state(STOPPED) result = dict(status.status(self.context)) assert "state" in result assert result["state"] == "stop" def test_status_method_contains_state_is_pause(self): self.core.playback.set_state(PLAYING) self.core.playback.set_state(PAUSED) result = dict(status.status(self.context)) assert "state" in result assert result["state"] == "pause" def test_status_method_when_playlist_loaded_contains_song(self): self.set_tracklist([Track(uri="dummy:/a")]) self.core.playback.play().get() result = dict(status.status(self.context)) assert "song" in result assert int(result["song"]) >= 0 def test_status_method_when_playlist_loaded_contains_tlid_as_songid(self): self.set_tracklist([Track(uri="dummy:/a")]) self.core.playback.play().get() result = dict(status.status(self.context)) assert "songid" in result assert int(result["songid"]) == 1 def test_status_method_when_playlist_loaded_contains_nextsong(self): self.set_tracklist([Track(uri="dummy:/a"), Track(uri="dummy:/b")]) self.core.playback.play().get() result = dict(status.status(self.context)) assert "nextsong" in result assert int(result["nextsong"]) >= 0 def test_status_method_when_playlist_loaded_contains_nextsongid(self): self.set_tracklist([Track(uri="dummy:/a"), Track(uri="dummy:/b")]) self.core.playback.play().get() result = dict(status.status(self.context)) assert "nextsongid" in result assert int(result["nextsongid"]) == 2 def test_status_method_when_playing_contains_time_with_no_length(self): self.set_tracklist([Track(uri="dummy:/a", length=None)]) self.core.playback.play().get() result = dict(status.status(self.context)) assert "time" in result (position, total) = result["time"].split(":") position = int(position) total = int(total) assert position <= total def test_status_method_when_playing_contains_time_with_length(self): self.set_tracklist([Track(uri="dummy:/a", length=10000)]) self.core.playback.play().get() result = dict(status.status(self.context)) assert "time" in result (position, total) = result["time"].split(":") position = int(position) total = int(total) assert position <= total def test_status_method_when_playing_contains_elapsed(self): self.set_tracklist([Track(uri="dummy:/a", length=60000)]) self.core.playback.play().get() self.core.playback.pause() self.core.playback.seek(59123) result = dict(status.status(self.context)) assert "elapsed" in result assert result["elapsed"] == "59.123" def test_status_method_when_starting_playing_contains_elapsed_zero(self): self.set_tracklist([Track(uri="dummy:/a", length=10000)]) self.core.playback.play().get() self.core.playback.pause() result = dict(status.status(self.context)) assert "elapsed" in result assert result["elapsed"] == "0.000" def test_status_method_when_playing_contains_bitrate(self): self.set_tracklist([Track(uri="dummy:/a", bitrate=3200)]) self.core.playback.play().get() result = dict(status.status(self.context)) assert "bitrate" in result assert int(result["bitrate"]) == 3200 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607133598.0 Mopidy-MPD-3.1.0/tests/test_tokenizer.py0000644000175100001710000001443300000000000017524 0ustar00runnerdockerimport unittest from mopidy_mpd import exceptions, tokenize class TestTokenizer(unittest.TestCase): def assertTokenizeEquals(self, expected, line): # noqa: N802 assert expected == tokenize.split(line) def assertTokenizeRaises(self, exception, message, line): # noqa: N802 with self.assertRaises(exception) as cm: tokenize.split(line) assert cm.exception.message == message def test_empty_string(self): ex = exceptions.MpdNoCommand msg = "No command given" self.assertTokenizeRaises(ex, msg, "") self.assertTokenizeRaises(ex, msg, " ") self.assertTokenizeRaises(ex, msg, "\t\t\t") def test_command(self): self.assertTokenizeEquals(["test"], "test") self.assertTokenizeEquals(["test123"], "test123") self.assertTokenizeEquals(["foo_bar"], "foo_bar") def test_command_trailing_whitespace(self): self.assertTokenizeEquals(["test"], "test ") self.assertTokenizeEquals(["test"], "test\t\t\t") def test_command_leading_whitespace(self): ex = exceptions.MpdUnknownError msg = "Letter expected" self.assertTokenizeRaises(ex, msg, " test") self.assertTokenizeRaises(ex, msg, "\ttest") def test_invalid_command(self): ex = exceptions.MpdUnknownError msg = "Invalid word character" self.assertTokenizeRaises(ex, msg, "foo/bar") self.assertTokenizeRaises(ex, msg, "æøå") self.assertTokenizeRaises(ex, msg, "test?") self.assertTokenizeRaises(ex, msg, 'te"st') def test_unquoted_param(self): self.assertTokenizeEquals(["test", "param"], "test param") self.assertTokenizeEquals(["test", "param"], "test\tparam") def test_unquoted_param_leading_whitespace(self): self.assertTokenizeEquals(["test", "param"], "test param") self.assertTokenizeEquals(["test", "param"], "test\t\tparam") def test_unquoted_param_trailing_whitespace(self): self.assertTokenizeEquals(["test", "param"], "test param ") self.assertTokenizeEquals(["test", "param"], "test param\t\t") def test_unquoted_param_invalid_chars(self): ex = exceptions.MpdArgError msg = "Invalid unquoted character" self.assertTokenizeRaises(ex, msg, 'test par"m') self.assertTokenizeRaises(ex, msg, "test foo\bbar") self.assertTokenizeRaises(ex, msg, 'test foo"bar"baz') self.assertTokenizeRaises(ex, msg, "test foo'bar") def test_unquoted_param_numbers(self): self.assertTokenizeEquals(["test", "123"], "test 123") self.assertTokenizeEquals(["test", "+123"], "test +123") self.assertTokenizeEquals(["test", "-123"], "test -123") self.assertTokenizeEquals(["test", "3.14"], "test 3.14") def test_unquoted_param_extended_chars(self): self.assertTokenizeEquals(["test", "æøå"], "test æøå") self.assertTokenizeEquals(["test", "?#$"], "test ?#$") self.assertTokenizeEquals(["test", "/foo/bar/"], "test /foo/bar/") self.assertTokenizeEquals(["test", "foo\\bar"], "test foo\\bar") def test_unquoted_params(self): self.assertTokenizeEquals(["test", "foo", "bar"], "test foo bar") def test_quoted_param(self): self.assertTokenizeEquals(["test", "param"], 'test "param"') self.assertTokenizeEquals(["test", "param"], 'test\t"param"') def test_quoted_param_leading_whitespace(self): self.assertTokenizeEquals(["test", "param"], 'test "param"') self.assertTokenizeEquals(["test", "param"], 'test\t\t"param"') def test_quoted_param_trailing_whitespace(self): self.assertTokenizeEquals(["test", "param"], 'test "param" ') self.assertTokenizeEquals(["test", "param"], 'test "param"\t\t') def test_quoted_param_invalid_chars(self): ex = exceptions.MpdArgError msg = "Space expected after closing '\"'" self.assertTokenizeRaises(ex, msg, 'test "foo"bar"') self.assertTokenizeRaises(ex, msg, 'test "foo"bar" ') self.assertTokenizeRaises(ex, msg, 'test "foo"bar') self.assertTokenizeRaises(ex, msg, 'test "foo"bar ') def test_quoted_param_numbers(self): self.assertTokenizeEquals(["test", "123"], 'test "123"') self.assertTokenizeEquals(["test", "+123"], 'test "+123"') self.assertTokenizeEquals(["test", "-123"], 'test "-123"') self.assertTokenizeEquals(["test", "3.14"], 'test "3.14"') def test_quoted_param_spaces(self): self.assertTokenizeEquals(["test", "foo bar"], 'test "foo bar"') self.assertTokenizeEquals(["test", "foo bar"], 'test "foo bar"') self.assertTokenizeEquals(["test", " param\t"], 'test " param\t"') def test_quoted_param_extended_chars(self): self.assertTokenizeEquals(["test", "æøå"], 'test "æøå"') self.assertTokenizeEquals(["test", "?#$"], 'test "?#$"') self.assertTokenizeEquals(["test", "/foo/bar/"], 'test "/foo/bar/"') def test_quoted_param_escaping(self): self.assertTokenizeEquals(["test", "\\"], r'test "\\"') self.assertTokenizeEquals(["test", '"'], r'test "\""') self.assertTokenizeEquals(["test", " "], r'test "\ "') self.assertTokenizeEquals(["test", "\\n"], r'test "\\\n"') def test_quoted_params(self): self.assertTokenizeEquals(["test", "foo", "bar"], 'test "foo" "bar"') def test_mixed_params(self): self.assertTokenizeEquals(["test", "foo", "bar"], 'test foo "bar"') self.assertTokenizeEquals(["test", "foo", "bar"], 'test "foo" bar') self.assertTokenizeEquals(["test", "1", "2"], 'test 1 "2"') self.assertTokenizeEquals(["test", "1", "2"], 'test "1" 2') self.assertTokenizeEquals( ["test", "foo bar", "baz", "123"], 'test "foo bar" baz 123' ) self.assertTokenizeEquals( ["test", 'foo"bar', "baz", "123"], r'test "foo\"bar" baz 123' ) def test_unbalanced_quotes(self): ex = exceptions.MpdArgError msg = "Invalid unquoted character" self.assertTokenizeRaises(ex, msg, 'test "foo bar" baz"') def test_missing_closing_quote(self): ex = exceptions.MpdArgError msg = "Missing closing '\"'" self.assertTokenizeRaises(ex, msg, 'test "foo') self.assertTokenizeRaises(ex, msg, 'test "foo a ') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607133598.0 Mopidy-MPD-3.1.0/tests/test_translator.py0000644000175100001710000001521600000000000017703 0ustar00runnerdockerimport unittest from mopidy.models import Album, Artist, Playlist, TlTrack, Track from mopidy_mpd import translator from tests import path_utils class TrackMpdFormatTest(unittest.TestCase): track = Track( uri="à uri", artists=[Artist(name="an artist")], name="a nàme", album=Album( name="an album", num_tracks=13, artists=[Artist(name="an other artist")], uri="urischeme:àlbum:12345", ), track_no=7, composers=[Artist(name="a composer")], performers=[Artist(name="a performer")], genre="a genre", date="1977-01-01", disc_no=1, comment="a comment", length=137000, ) def setUp(self): # noqa: N802 self.media_dir = "/dir/subdir" path_utils.mtime.set_fake_time(1234567) def tearDown(self): # noqa: N802 path_utils.mtime.undo_fake() def test_track_to_mpd_format_for_empty_track(self): result = translator.track_to_mpd_format( Track(uri="a uri", length=137000) ) assert ("file", "a uri") in result assert ("Time", 137) in result assert ("Artist", "") not in result assert ("Title", "") not in result assert ("Album", "") not in result assert ("Track", 0) not in result assert ("Date", "") not in result assert len(result) == 2 def test_track_to_mpd_format_with_position(self): result = translator.track_to_mpd_format(Track(), position=1) assert ("Pos", 1) not in result def test_track_to_mpd_format_with_tlid(self): result = translator.track_to_mpd_format(TlTrack(1, Track())) assert ("Id", 1) not in result def test_track_to_mpd_format_with_position_and_tlid(self): result = translator.track_to_mpd_format( TlTrack(2, Track(uri="a uri")), position=1 ) assert ("Pos", 1) in result assert ("Id", 2) in result def test_track_to_mpd_format_for_nonempty_track(self): result = translator.track_to_mpd_format( TlTrack(122, self.track), position=9 ) assert ("file", "à uri") in result assert ("Time", 137) in result assert ("Artist", "an artist") in result assert ("Title", "a nàme") in result assert ("Album", "an album") in result assert ("AlbumArtist", "an other artist") in result assert ("Composer", "a composer") in result assert ("Performer", "a performer") in result assert ("Genre", "a genre") in result assert ("Track", "7/13") in result assert ("Date", "1977-01-01") in result assert ("Disc", 1) in result assert ("Pos", 9) in result assert ("Id", 122) in result assert ("X-AlbumUri", "urischeme:àlbum:12345") in result assert ("Comment", "a comment") not in result assert len(result) == 15 def test_track_to_mpd_format_with_last_modified(self): track = self.track.replace(last_modified=995303899000) result = translator.track_to_mpd_format(track) assert ("Last-Modified", "2001-07-16T17:18:19Z") in result def test_track_to_mpd_format_with_last_modified_of_zero(self): track = self.track.replace(last_modified=0) result = translator.track_to_mpd_format(track) keys = [k for k, v in result] assert "Last-Modified" not in keys def test_track_to_mpd_format_musicbrainz_trackid(self): track = self.track.replace(musicbrainz_id="foo") result = translator.track_to_mpd_format(track) assert ("MUSICBRAINZ_TRACKID", "foo") in result def test_track_to_mpd_format_musicbrainz_albumid(self): album = self.track.album.replace(musicbrainz_id="foo") track = self.track.replace(album=album) result = translator.track_to_mpd_format(track) assert ("MUSICBRAINZ_ALBUMID", "foo") in result def test_track_to_mpd_format_musicbrainz_albumartistid(self): artist = list(self.track.artists)[0].replace(musicbrainz_id="foo") album = self.track.album.replace(artists=[artist]) track = self.track.replace(album=album) result = translator.track_to_mpd_format(track) assert ("MUSICBRAINZ_ALBUMARTISTID", "foo") in result def test_track_to_mpd_format_musicbrainz_artistid(self): artist = list(self.track.artists)[0].replace(musicbrainz_id="foo") track = self.track.replace(artists=[artist]) result = translator.track_to_mpd_format(track) assert ("MUSICBRAINZ_ARTISTID", "foo") in result def test_concat_multi_values(self): artists = [Artist(name="ABBA"), Artist(name="Beatles")] translated = translator.concat_multi_values(artists, "name") assert translated == "ABBA;Beatles" def test_concat_multi_values_artist_with_no_name(self): artists = [Artist(name=None)] translated = translator.concat_multi_values(artists, "name") assert translated == "" def test_concat_multi_values_artist_with_no_musicbrainz_id(self): artists = [Artist(name="Jah Wobble")] translated = translator.concat_multi_values(artists, "musicbrainz_id") assert translated == "" def test_track_to_mpd_format_with_stream_title(self): result = translator.track_to_mpd_format(self.track, stream_title="foo") assert ("Name", "a nàme") in result assert ("Title", "foo") in result def test_track_to_mpd_format_with_empty_stream_title(self): result = translator.track_to_mpd_format(self.track, stream_title="") assert ("Name", "a nàme") in result assert ("Title", "") not in result def test_track_to_mpd_format_with_stream_and_no_track_name(self): track = self.track.replace(name=None) result = translator.track_to_mpd_format(track, stream_title="foo") assert ("Name", "") not in result assert ("Title", "foo") in result class PlaylistMpdFormatTest(unittest.TestCase): def test_mpd_format(self): playlist = Playlist( tracks=[ Track(uri="foo", track_no=1), Track(uri="bàr", track_no=2), Track(uri="baz", track_no=3), ] ) result = translator.playlist_to_mpd_format(playlist) assert len(result) == 3 def test_mpd_format_with_range(self): playlist = Playlist( tracks=[ Track(uri="foo", track_no=1), Track(uri="bàr", track_no=2), Track(uri="baz", track_no=3), ] ) result = translator.playlist_to_mpd_format(playlist, 1, 2) assert len(result) == 1 assert dict(result[0])["Track"] == 2 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607133598.0 Mopidy-MPD-3.1.0/tox.ini0000644000175100001710000000074200000000000014250 0ustar00runnerdocker[tox] envlist = py37, py38, py39, black, check-manifest, flake8 [testenv] sitepackages = true deps = .[test] commands = python -m pytest \ --basetemp={envtmpdir} \ --cov=mopidy_mpd --cov-report=term-missing \ {posargs} [testenv:black] deps = .[lint] commands = python -m black --check . [testenv:check-manifest] deps = .[lint] commands = python -m check_manifest [testenv:flake8] deps = .[lint] commands = python -m flake8 --show-source --statistics