Mopidy-Local-3.1.1/0000775000175000017500000000000013614772521014223 5ustar jodaljodal00000000000000Mopidy-Local-3.1.1/.circleci/0000775000175000017500000000000013614772521016056 5ustar jodaljodal00000000000000Mopidy-Local-3.1.1/.circleci/config.yml0000664000175000017500000000206513577631225020054 0ustar jodaljodal00000000000000version: 2.1 orbs: codecov: codecov/codecov@1.0.5 workflows: version: 2 test: jobs: - py38 - py37 - black - check-manifest - flake8 jobs: py38: &test-template docker: - image: mopidy/ci-python:3.8 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 py37: <<: *test-template docker: - image: mopidy/ci-python:3.7 black: *test-template check-manifest: *test-template flake8: *test-template Mopidy-Local-3.1.1/CHANGELOG.rst0000664000175000017500000000302613614772467016256 0ustar jodaljodal00000000000000********* Changelog ********* v3.1.1 (2020-01-31) =================== - Handle scan results without duration gracefully. (#36, PR: #35) v3.1.0 (2020-01-09) =================== - Improve handling of paths with arbitrary encodings in the scanner. (#20, PR: #29) - Add ``.cue`` to the list of excluded file extensions. (PR: #29) - Replace ``os.path`` usage with ``pathlib`` to handle arbitrary file path encodings better. (#20, PR: #30) - Add an ``included_files_extensions`` config. (#8, PR: #32) - Remove broken support for creating URIs from MusicBrainz IDs. This was turned off in Mopidy < 3, but was by accident enabled by default in Mopidy-Local 3.0. Now the broken feature is fully removed. (#26, PR: #31) v3.0.0 (2019-12-22) =================== - Depend on final release of Mopidy 3.0.0. v3.0.0a3 (2019-12-15) ===================== - Move parts of the scanner functionality from Mopidy. (#19) v3.0.0a2 (2019-12-09) ===================== - Require Python >= 3.7. (PR: #10) - Require Mopidy >= 3.0.0a5. (PR: #10) - Require Pykka >= 2.0.1. (PR: #10) - Update project setup. (PR: #10) - Merge Mopidy-Local-SQLite into Mopidy-Local (PR: #10) - Merge Mopidy-Local-Images web extension into Mopidy-Local. (PR: #10) - Remove support for pluggable libraries, remove the JSON library storage, and use SQLite instead. (PR: #10) v3.0.0a1 (2019-08-04) ===================== Initial release which extracts the Mopidy-Local extension from Mopidy core. This is an alpha release because it depends on the pre-releases of Mopidy 3.0. Mopidy-Local-3.1.1/LICENSE0000664000175000017500000002613613577631225015243 0ustar jodaljodal00000000000000 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. Mopidy-Local-3.1.1/MANIFEST.in0000664000175000017500000000053513577631225015767 0ustar jodaljodal00000000000000include *.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 * recursive-include mopidy_local/www *.html recursive-include mopidy_local/sql *.sql Mopidy-Local-3.1.1/Mopidy_Local.egg-info/0000775000175000017500000000000013614772521020270 5ustar jodaljodal00000000000000Mopidy-Local-3.1.1/Mopidy_Local.egg-info/PKG-INFO0000664000175000017500000001642313614772521021373 0ustar jodaljodal00000000000000Metadata-Version: 2.1 Name: Mopidy-Local Version: 3.1.1 Summary: Mopidy extension for playing music from your local music archive Home-page: https://github.com/mopidy/mopidy-local Author: Stein Magnus Jodal Author-email: stein.magnus@jodal.no License: Apache License, Version 2.0 Description: ************ Mopidy-Local ************ .. image:: https://img.shields.io/pypi/v/Mopidy-Local :target: https://pypi.org/project/Mopidy-Local/ :alt: Latest PyPI version .. image:: https://img.shields.io/circleci/build/gh/mopidy/mopidy-local :target: https://circleci.com/gh/mopidy/mopidy-local :alt: CircleCI build status .. image:: https://img.shields.io/codecov/c/gh/mopidy/mopidy-local :target: https://codecov.io/gh/mopidy/mopidy-local :alt: Test coverage `Mopidy`_ extension for playing music from your local music archive. .. _Mopidy: https://www.mopidy.com/ Table of contents ================= - `Maintainer wanted`_ - Installation_ - Configuration_ - Usage_ - `Generating a library`_ - `Updating the library`_ - `Project resources`_ - Credits_ Maintainer wanted ================= Mopidy-Local 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-Local See https://mopidy.com/ext/local/ for alternative installation methods. Configuration ============= Before starting Mopidy, you must add configuration for Mopidy-Local to your Mopidy configuration file:: [local] media_dir = /path/to/your/music/archive The following configuration values are available: - ``local/enabled``: If the local extension should be enabled or not. Defaults to ``true``. - ``local/media_dir``: Path to directory with local media files. - ``local/scan_timeout``: Number of milliseconds before giving up scanning a file and moving on to the next file. - ``local/scan_follow_symlinks``: If we should follow symlinks found in ``local/media_dir``. - ``local/scan_flush_threshold``: Number of tracks to wait before telling library it should try and store its progress so far. Some libraries might not respect this setting. Set this to zero to disable flushing. - ``local/included_file_extensions``: File extensions to include when scanning the media directory. Values should be separated by either comma or newline. Each file extension should start with a dot, .e.g. ``.flac``. Setting any values here will override the existence of ``local/excluded_file_extensions``. - ``local/excluded_file_extensions``: File extensions to exclude when scanning the media directory. Values should be separated by either comma or newline. Each file extension should start with a dot, .e.g. ``.html``. Defaults to a list of common non-audio file extensions often found in music collections. This config value has no effect if ``local/included_file_extensions`` is set. - ``local/directories``: List of top-level directory names and URIs for browsing. - ``local/timeout``: Database connection timeout in seconds. - ``local/use_artist_sortname``: Whether to use the sortname field for ordering artist browse results. Disabled by default, since this may give confusing results if not all artists in the library have proper sortnames. - ``local/album_art_files``: List of file names to check for when searching for external album art. These may contain UNIX shell patterns, i.e. ``*``, ``?``, etc. Usage ===== Generating a library -------------------- The command ``mopidy local scan`` will scan the path set in the ``local/media_dir`` config value for any audio files and build a library of metadata. To make a local library for your music available for Mopidy: #. Ensure that the ``local/media_dir`` config value points to where your music is located. Check the current setting by running:: mopidy config #. Scan your media library.:: mopidy local scan #. Start Mopidy, find the music library in a client, and play some local music! Updating the library -------------------- When you've added or removed music in your collection and want to update Mopidy's index of your local library, you need to rescan:: mopidy local scan Project resources ================= - `Source code `_ - `Issue tracker `_ - `Changelog `_ Credits ======= - Original authors: `Stein Magnus Jodal `__ and `Thomas Adamcik `__ for the Mopidy-Local extension in Mopidy core. `Thomas Kemmer `__ for the SQLite storage and support for embedded album art. - 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: Topic :: Multimedia :: Sound/Audio :: Players Requires-Python: >=3.7 Provides-Extra: lint Provides-Extra: release Provides-Extra: test Provides-Extra: dev Mopidy-Local-3.1.1/Mopidy_Local.egg-info/SOURCES.txt0000664000175000017500000000203513614772521022154 0ustar jodaljodal00000000000000CHANGELOG.rst LICENSE MANIFEST.in README.rst setup.cfg setup.py tox.ini .circleci/config.yml Mopidy_Local.egg-info/PKG-INFO Mopidy_Local.egg-info/SOURCES.txt Mopidy_Local.egg-info/dependency_links.txt Mopidy_Local.egg-info/entry_points.txt Mopidy_Local.egg-info/not-zip-safe Mopidy_Local.egg-info/requires.txt Mopidy_Local.egg-info/top_level.txt mopidy_local/__init__.py mopidy_local/actor.py mopidy_local/commands.py mopidy_local/ext.conf mopidy_local/library.py mopidy_local/mtimes.py mopidy_local/playback.py mopidy_local/schema.py mopidy_local/storage.py mopidy_local/translator.py mopidy_local/web.py mopidy_local/sql/schema.sql mopidy_local/sql/upgrade-v1.sql mopidy_local/sql/upgrade-v2.sql mopidy_local/sql/upgrade-v3.sql mopidy_local/sql/upgrade-v4.sql mopidy_local/sql/upgrade-v5.sql mopidy_local/www/index.html tests/__init__.py tests/dummy_audio.py tests/test_extension.py tests/test_library.py tests/test_mtimes.py tests/test_playback.py tests/test_schema.py tests/test_tracklist.py tests/test_translator.py tests/data/local/library.json.gzMopidy-Local-3.1.1/Mopidy_Local.egg-info/dependency_links.txt0000664000175000017500000000000113614772521024336 0ustar jodaljodal00000000000000 Mopidy-Local-3.1.1/Mopidy_Local.egg-info/entry_points.txt0000664000175000017500000000005513614772521023566 0ustar jodaljodal00000000000000[mopidy.ext] local = mopidy_local:Extension Mopidy-Local-3.1.1/Mopidy_Local.egg-info/not-zip-safe0000664000175000017500000000000113577716776022540 0ustar jodaljodal00000000000000 Mopidy-Local-3.1.1/Mopidy_Local.egg-info/requires.txt0000664000175000017500000000046213614772521022672 0ustar jodaljodal00000000000000Mopidy>=3.0.0 Pykka>=2.0.1 setuptools uritools>=1.0 [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 Mopidy-Local-3.1.1/Mopidy_Local.egg-info/top_level.txt0000664000175000017500000000001513614772521023016 0ustar jodaljodal00000000000000mopidy_local Mopidy-Local-3.1.1/PKG-INFO0000664000175000017500000001642313614772521015326 0ustar jodaljodal00000000000000Metadata-Version: 2.1 Name: Mopidy-Local Version: 3.1.1 Summary: Mopidy extension for playing music from your local music archive Home-page: https://github.com/mopidy/mopidy-local Author: Stein Magnus Jodal Author-email: stein.magnus@jodal.no License: Apache License, Version 2.0 Description: ************ Mopidy-Local ************ .. image:: https://img.shields.io/pypi/v/Mopidy-Local :target: https://pypi.org/project/Mopidy-Local/ :alt: Latest PyPI version .. image:: https://img.shields.io/circleci/build/gh/mopidy/mopidy-local :target: https://circleci.com/gh/mopidy/mopidy-local :alt: CircleCI build status .. image:: https://img.shields.io/codecov/c/gh/mopidy/mopidy-local :target: https://codecov.io/gh/mopidy/mopidy-local :alt: Test coverage `Mopidy`_ extension for playing music from your local music archive. .. _Mopidy: https://www.mopidy.com/ Table of contents ================= - `Maintainer wanted`_ - Installation_ - Configuration_ - Usage_ - `Generating a library`_ - `Updating the library`_ - `Project resources`_ - Credits_ Maintainer wanted ================= Mopidy-Local 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-Local See https://mopidy.com/ext/local/ for alternative installation methods. Configuration ============= Before starting Mopidy, you must add configuration for Mopidy-Local to your Mopidy configuration file:: [local] media_dir = /path/to/your/music/archive The following configuration values are available: - ``local/enabled``: If the local extension should be enabled or not. Defaults to ``true``. - ``local/media_dir``: Path to directory with local media files. - ``local/scan_timeout``: Number of milliseconds before giving up scanning a file and moving on to the next file. - ``local/scan_follow_symlinks``: If we should follow symlinks found in ``local/media_dir``. - ``local/scan_flush_threshold``: Number of tracks to wait before telling library it should try and store its progress so far. Some libraries might not respect this setting. Set this to zero to disable flushing. - ``local/included_file_extensions``: File extensions to include when scanning the media directory. Values should be separated by either comma or newline. Each file extension should start with a dot, .e.g. ``.flac``. Setting any values here will override the existence of ``local/excluded_file_extensions``. - ``local/excluded_file_extensions``: File extensions to exclude when scanning the media directory. Values should be separated by either comma or newline. Each file extension should start with a dot, .e.g. ``.html``. Defaults to a list of common non-audio file extensions often found in music collections. This config value has no effect if ``local/included_file_extensions`` is set. - ``local/directories``: List of top-level directory names and URIs for browsing. - ``local/timeout``: Database connection timeout in seconds. - ``local/use_artist_sortname``: Whether to use the sortname field for ordering artist browse results. Disabled by default, since this may give confusing results if not all artists in the library have proper sortnames. - ``local/album_art_files``: List of file names to check for when searching for external album art. These may contain UNIX shell patterns, i.e. ``*``, ``?``, etc. Usage ===== Generating a library -------------------- The command ``mopidy local scan`` will scan the path set in the ``local/media_dir`` config value for any audio files and build a library of metadata. To make a local library for your music available for Mopidy: #. Ensure that the ``local/media_dir`` config value points to where your music is located. Check the current setting by running:: mopidy config #. Scan your media library.:: mopidy local scan #. Start Mopidy, find the music library in a client, and play some local music! Updating the library -------------------- When you've added or removed music in your collection and want to update Mopidy's index of your local library, you need to rescan:: mopidy local scan Project resources ================= - `Source code `_ - `Issue tracker `_ - `Changelog `_ Credits ======= - Original authors: `Stein Magnus Jodal `__ and `Thomas Adamcik `__ for the Mopidy-Local extension in Mopidy core. `Thomas Kemmer `__ for the SQLite storage and support for embedded album art. - 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: Topic :: Multimedia :: Sound/Audio :: Players Requires-Python: >=3.7 Provides-Extra: lint Provides-Extra: release Provides-Extra: test Provides-Extra: dev Mopidy-Local-3.1.1/README.rst0000664000175000017500000001217313614770065015717 0ustar jodaljodal00000000000000************ Mopidy-Local ************ .. image:: https://img.shields.io/pypi/v/Mopidy-Local :target: https://pypi.org/project/Mopidy-Local/ :alt: Latest PyPI version .. image:: https://img.shields.io/circleci/build/gh/mopidy/mopidy-local :target: https://circleci.com/gh/mopidy/mopidy-local :alt: CircleCI build status .. image:: https://img.shields.io/codecov/c/gh/mopidy/mopidy-local :target: https://codecov.io/gh/mopidy/mopidy-local :alt: Test coverage `Mopidy`_ extension for playing music from your local music archive. .. _Mopidy: https://www.mopidy.com/ Table of contents ================= - `Maintainer wanted`_ - Installation_ - Configuration_ - Usage_ - `Generating a library`_ - `Updating the library`_ - `Project resources`_ - Credits_ Maintainer wanted ================= Mopidy-Local 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-Local See https://mopidy.com/ext/local/ for alternative installation methods. Configuration ============= Before starting Mopidy, you must add configuration for Mopidy-Local to your Mopidy configuration file:: [local] media_dir = /path/to/your/music/archive The following configuration values are available: - ``local/enabled``: If the local extension should be enabled or not. Defaults to ``true``. - ``local/media_dir``: Path to directory with local media files. - ``local/scan_timeout``: Number of milliseconds before giving up scanning a file and moving on to the next file. - ``local/scan_follow_symlinks``: If we should follow symlinks found in ``local/media_dir``. - ``local/scan_flush_threshold``: Number of tracks to wait before telling library it should try and store its progress so far. Some libraries might not respect this setting. Set this to zero to disable flushing. - ``local/included_file_extensions``: File extensions to include when scanning the media directory. Values should be separated by either comma or newline. Each file extension should start with a dot, .e.g. ``.flac``. Setting any values here will override the existence of ``local/excluded_file_extensions``. - ``local/excluded_file_extensions``: File extensions to exclude when scanning the media directory. Values should be separated by either comma or newline. Each file extension should start with a dot, .e.g. ``.html``. Defaults to a list of common non-audio file extensions often found in music collections. This config value has no effect if ``local/included_file_extensions`` is set. - ``local/directories``: List of top-level directory names and URIs for browsing. - ``local/timeout``: Database connection timeout in seconds. - ``local/use_artist_sortname``: Whether to use the sortname field for ordering artist browse results. Disabled by default, since this may give confusing results if not all artists in the library have proper sortnames. - ``local/album_art_files``: List of file names to check for when searching for external album art. These may contain UNIX shell patterns, i.e. ``*``, ``?``, etc. Usage ===== Generating a library -------------------- The command ``mopidy local scan`` will scan the path set in the ``local/media_dir`` config value for any audio files and build a library of metadata. To make a local library for your music available for Mopidy: #. Ensure that the ``local/media_dir`` config value points to where your music is located. Check the current setting by running:: mopidy config #. Scan your media library.:: mopidy local scan #. Start Mopidy, find the music library in a client, and play some local music! Updating the library -------------------- When you've added or removed music in your collection and want to update Mopidy's index of your local library, you need to rescan:: mopidy local scan Project resources ================= - `Source code `_ - `Issue tracker `_ - `Changelog `_ Credits ======= - Original authors: `Stein Magnus Jodal `__ and `Thomas Adamcik `__ for the Mopidy-Local extension in Mopidy core. `Thomas Kemmer `__ for the SQLite storage and support for embedded album art. - Current maintainer: None. Maintainer wanted, see section above. - `Contributors `_ Mopidy-Local-3.1.1/mopidy_local/0000775000175000017500000000000013614772521016676 5ustar jodaljodal00000000000000Mopidy-Local-3.1.1/mopidy_local/__init__.py0000664000175000017500000000435313614770065021015 0ustar jodaljodal00000000000000import pathlib import pkg_resources from mopidy import config, ext __version__ = pkg_resources.get_distribution("Mopidy-Local").version class Extension(ext.Extension): dist_name = "Mopidy-Local" ext_name = "local" 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["library"] = config.Deprecated() schema["media_dir"] = config.Path() schema["data_dir"] = config.Deprecated() schema["playlists_dir"] = config.Deprecated() schema["tag_cache_file"] = config.Deprecated() schema["scan_timeout"] = config.Integer(minimum=1000, maximum=1000 * 60 * 60) schema["scan_flush_threshold"] = config.Integer(minimum=0) schema["scan_follow_symlinks"] = config.Boolean() schema["included_file_extensions"] = config.List(optional=True) schema["excluded_file_extensions"] = config.List(optional=True) schema["directories"] = config.List() schema["timeout"] = config.Integer(optional=True, minimum=1) schema["use_artist_sortname"] = config.Boolean() schema["album_art_files"] = config.List(optional=True) return schema def setup(self, registry): from .actor import LocalBackend registry.add("backend", LocalBackend) registry.add("http:app", {"name": self.ext_name, "factory": self.webapp}) def get_command(self): from .commands import LocalCommand return LocalCommand() def webapp(self, config, core): from .web import ImageHandler, IndexHandler image_dir = self.get_image_dir(config) return [ (r"/(index.html)?", IndexHandler, {"root": image_dir}), (r"/(.+)", ImageHandler, {"path": image_dir}), ] # TODO: Add *paths to Extension.get_data_dir()? @classmethod def get_data_subdir(cls, config, *paths): data_dir = cls.get_data_dir(config) dir_path = data_dir.joinpath(*paths) dir_path.mkdir(parents=True, exist_ok=True) return dir_path @classmethod def get_image_dir(cls, config): return cls.get_data_subdir(config, "images") Mopidy-Local-3.1.1/mopidy_local/actor.py0000664000175000017500000000115213577631225020362 0ustar jodaljodal00000000000000import logging import pykka from mopidy import backend from mopidy_local import storage from mopidy_local.library import LocalLibraryProvider from mopidy_local.playback import LocalPlaybackProvider logger = logging.getLogger(__name__) class LocalBackend(pykka.ThreadingActor, backend.Backend): uri_schemes = ["local"] def __init__(self, config, audio): super().__init__() self.config = config storage.check_dirs_and_files(config) self.playback = LocalPlaybackProvider(audio=audio, backend=self) self.library = LocalLibraryProvider(backend=self, config=config) Mopidy-Local-3.1.1/mopidy_local/commands.py0000664000175000017500000002236113614772130021051 0ustar jodaljodal00000000000000import logging import pathlib import time from mopidy import commands, exceptions from mopidy.audio import scan, tags from mopidy_local import mtimes, storage, translator logger = logging.getLogger(__name__) MIN_DURATION_MS = 100 # Shortest length of track to include. class LocalCommand(commands.Command): def __init__(self): super().__init__() self.add_child("scan", ScanCommand()) self.add_child("clear", ClearCommand()) class ClearCommand(commands.Command): help = "Clear local media files from the local library." def run(self, args, config): library = storage.LocalStorageProvider(config) prompt = "Are you sure you want to clear the library? [y/N] " if input(prompt).lower() != "y": print("Clearing library aborted") return 0 if library.clear(): print("Library successfully cleared") return 0 print("Unable to clear library") return 1 class ScanCommand(commands.Command): help = "Scan local media files and populate the local library." def __init__(self): super().__init__() self.add_argument( "--limit", action="store", type=int, dest="limit", default=None, help="Maximum number of tracks to scan", ) self.add_argument( "--force", action="store_true", dest="force", default=False, help="Force rescan of all media files", ) def run(self, args, config): media_dir = pathlib.Path(config["local"]["media_dir"]).resolve() library = storage.LocalStorageProvider(config) file_mtimes = self._find_files( media_dir=media_dir, follow_symlinks=config["local"]["scan_follow_symlinks"], ) files_to_update, files_in_library = self._check_tracks_in_library( media_dir=media_dir, file_mtimes=file_mtimes, library=library, force_rescan=args.force, ) files_to_update.update( self._find_files_to_scan( media_dir=media_dir, file_mtimes=file_mtimes, files_in_library=files_in_library, included_file_exts=[ file_ext.lower() for file_ext in config["local"]["included_file_extensions"] ], excluded_file_exts=[ file_ext.lower() for file_ext in config["local"]["excluded_file_extensions"] ], ) ) self._scan_metadata( media_dir=media_dir, file_mtimes=file_mtimes, files=files_to_update, library=library, timeout=config["local"]["scan_timeout"], flush_threshold=config["local"]["scan_flush_threshold"], limit=args.limit, ) library.close() return 0 def _find_files(self, *, media_dir, follow_symlinks): logger.info(f"Finding files in {media_dir.as_uri()} ...") file_mtimes, file_errors = mtimes.find_mtimes(media_dir, follow=follow_symlinks) logger.info(f"Found {len(file_mtimes)} files in {media_dir.as_uri()}") if file_errors: logger.warning( f"Encountered {len(file_errors)} errors " f"while finding files in {media_dir.as_uri()}" ) for path in file_errors: logger.warning(f"Error for {path.as_uri()}: {file_errors[path]}") return file_mtimes def _check_tracks_in_library( self, *, media_dir, file_mtimes, library, force_rescan ): num_tracks = library.load() logger.info(f"Checking {num_tracks} tracks from library") uris_to_remove = set() files_to_update = set() files_in_library = set() for track in library.begin(): absolute_path = translator.local_uri_to_path(track.uri, media_dir) mtime = file_mtimes.get(absolute_path) if mtime is None: logger.debug(f"Removing {track.uri}: File not found") uris_to_remove.add(track.uri) elif mtime > track.last_modified or force_rescan: files_to_update.add(absolute_path) files_in_library.add(absolute_path) logger.info(f"Removing {len(uris_to_remove)} missing tracks") for local_uri in uris_to_remove: library.remove(local_uri) return files_to_update, files_in_library def _find_files_to_scan( self, *, media_dir, file_mtimes, files_in_library, included_file_exts, excluded_file_exts, ): files_to_update = set() def _is_hidden_file(relative_path, file_uri): if any(p.startswith(".") for p in relative_path.parts): logger.debug(f"Skipped {file_uri}: Hidden directory/file") return True else: return False def _extension_filters( relative_path, file_uri, included_file_exts, excluded_file_exts ): if included_file_exts: if relative_path.suffix.lower() in included_file_exts: logger.debug(f"Added {file_uri}: File extension on included list") return True else: logger.debug( f"Skipped {file_uri}: File extension not on included list" ) return False else: if relative_path.suffix.lower() in excluded_file_exts: logger.debug(f"Skipped {file_uri}: File extension on excluded list") return False else: logger.debug( f"Included {file_uri}: File extension not on excluded list" ) return True for absolute_path in file_mtimes: relative_path = absolute_path.relative_to(media_dir) file_uri = absolute_path.as_uri() if ( not _is_hidden_file(relative_path, file_uri) and _extension_filters( relative_path, file_uri, included_file_exts, excluded_file_exts ) and absolute_path not in files_in_library ): files_to_update.add(absolute_path) logger.info(f"Found {len(files_to_update)} tracks which need to be updated") return files_to_update def _scan_metadata( self, *, media_dir, file_mtimes, files, library, timeout, flush_threshold, limit ): logger.info("Scanning...") files = sorted(files)[:limit] scanner = scan.Scanner(timeout) progress = _ScanProgress(batch_size=flush_threshold, total=len(files)) for absolute_path in files: try: file_uri = absolute_path.as_uri() result = scanner.scan(file_uri) if not result.playable: logger.warning( f"Failed scanning {file_uri}: No audio found in file" ) elif result.duration is None: logger.warning( f"Failed scanning {file_uri}: " "No duration information found in file" ) elif result.duration < MIN_DURATION_MS: logger.warning( f"Failed scanning {file_uri}: " f"Track shorter than {MIN_DURATION_MS}ms" ) else: local_uri = translator.path_to_local_track_uri( absolute_path, media_dir ) mtime = file_mtimes.get(absolute_path) track = tags.convert_tags_to_track(result.tags).replace( uri=local_uri, length=result.duration, last_modified=mtime ) library.add(track, result.tags, result.duration) logger.debug(f"Added {track.uri}") except exceptions.ScannerError as error: logger.warning(f"Failed scanning {file_uri}: {error}") if progress.increment(): progress.log() if library.flush(): logger.debug("Progress flushed") progress.log() logger.info("Done scanning") class _ScanProgress: def __init__(self, *, batch_size, total): self.count = 0 self.batch_size = batch_size self.total = total self.start = time.time() def increment(self): self.count += 1 return self.batch_size and self.count % self.batch_size == 0 def log(self): duration = time.time() - self.start if self.count >= self.total or not self.count: logger.info( f"Scanned {self.count} of {self.total} files in {duration:.3f}s." ) else: remainder = duration / self.count * (self.total - self.count) logger.info( f"Scanned {self.count} of {self.total} files " f"in {duration:.3f}s, ~{remainder:.0f}s left" ) Mopidy-Local-3.1.1/mopidy_local/ext.conf0000664000175000017500000000250013614770065020343 0ustar jodaljodal00000000000000[local] enabled = true library = sqlite media_dir = $XDG_MUSIC_DIR scan_timeout = 1000 scan_flush_threshold = 100 scan_follow_symlinks = false included_file_extensions = excluded_file_extensions = .cue .directory .html .jpeg .jpg .log .nfo .pdf .png .txt .zip # top-level directories for browsing, as directories = Albums local:directory?type=album Artists local:directory?type=artist Composers local:directory?type=artist&role=composer Genres local:directory?type=genre Performers local:directory?type=artist&role=performer Release Years local:directory?type=date&format=%25Y Tracks local:directory?type=track Last Week's Updates local:directory?max-age=604800 Last Month's Updates local:directory?max-age=2592000 # database connection timeout in seconds timeout = 10 # whether to use the sortname field for ordering artist browse # results; disabled by default, since this may give confusing results # if not all artists in the library have proper sortnames use_artist_sortname = false # a list of file names to check for when searching for external album # art; may contain UNIX shell patterns, i.e. "*", "?", etc. album_art_files = *.jpg, *.jpeg, *.png Mopidy-Local-3.1.1/mopidy_local/library.py0000664000175000017500000001714313614770065020723 0ustar jodaljodal00000000000000import logging import operator import sqlite3 import uritools from mopidy import backend, models from mopidy.models import Ref, SearchResult from . import Extension, schema logger = logging.getLogger(__name__) def date_ref(date): return Ref.directory( uri=uritools.uricompose("local", None, "directory", {"date": date}), name=date ) def genre_ref(genre): return Ref.directory( uri=uritools.uricompose("local", None, "directory", {"genre": genre}), name=genre, ) class LocalLibraryProvider(backend.LibraryProvider): ROOT_DIRECTORY_URI = "local:directory" root_directory = models.Ref.directory(uri=ROOT_DIRECTORY_URI, name="Local media") def __init__(self, backend, config): super().__init__(backend) self._config = ext_config = config[Extension.ext_name] self._data_dir = Extension.get_data_dir(config) self._directories = [] for line in ext_config["directories"]: name, uri = line.rsplit(None, 1) ref = Ref.directory(uri=uri, name=name) self._directories.append(ref) self._dbpath = self._data_dir / "library.db" self._connection = None def load(self): with self._connect() as connection: version = schema.load(connection) logger.debug("Using SQLite database schema v%s", version) return schema.count_tracks(connection) def lookup(self, uri): try: if uri.startswith("local:album"): return list(schema.lookup(self._connect(), Ref.ALBUM, uri)) elif uri.startswith("local:artist"): return list(schema.lookup(self._connect(), Ref.ARTIST, uri)) elif uri.startswith("local:track"): return list(schema.lookup(self._connect(), Ref.TRACK, uri)) else: raise ValueError("Invalid lookup URI") except Exception as e: logger.error("Lookup error for %s: %s", uri, e) return [] def browse(self, uri): try: if uri == self.ROOT_DIRECTORY_URI: return self._directories elif uri.startswith("local:directory"): return self._browse_directory(uri) elif uri.startswith("local:artist"): return self._browse_artist(uri) elif uri.startswith("local:album"): return self._browse_album(uri) else: raise ValueError("Invalid browse URI") except Exception as e: logger.error("Error browsing %s: %s", uri, e) return [] def search(self, query=None, limit=100, offset=0, uris=None, exact=False): q = [] for field, values in query.items() if query else []: q.extend((field, value) for value in values) filters = [f for uri in uris or [] for f in self._filters(uri) if f] with self._connect() as c: tracks = schema.search_tracks(c, q, limit, offset, exact, filters) uri = uritools.uricompose("local", path="search", query=q) return SearchResult(uri=uri, tracks=tracks) def get_images(self, uris): images = {} with self._connect() as c: for uri in uris: if uri.startswith("local:album"): images[uri] = schema.get_album_images(c, uri) elif uri.startswith("local:track"): images[uri] = schema.get_track_images(c, uri) return images def get_distinct(self, field, query=None): q = [] for key, values in query.items() if query else []: q.extend((key, value) for value in values) return set(schema.list_distinct(self._connect(), field, q)) def _connect(self): if not self._connection: self._connection = sqlite3.connect( self._dbpath, factory=schema.Connection, timeout=self._config["timeout"], check_same_thread=False, ) return self._connection def _browse_album(self, uri, order=("disc_no", "track_no", "name")): return schema.browse(self._connect(), Ref.TRACK, order, album=uri) def _browse_artist(self, uri, order=("type", "name COLLATE NOCASE")): with self._connect() as c: albums = schema.browse(c, Ref.ALBUM, order, albumartist=uri) refs = schema.browse(c, order=order, artist=uri) album_uris, tracks = {ref.uri for ref in albums}, [] for ref in refs: if ref.type == Ref.ALBUM and ref.uri not in album_uris: albums.append( Ref.directory( uri=uritools.uricompose( "local", None, "directory", dict(type=Ref.TRACK, album=ref.uri, artist=uri), ), name=ref.name, ) ) elif ref.type == Ref.TRACK: tracks.append(ref) else: logger.debug("Skipped SQLite browse result %s", ref.uri) albums.sort(key=operator.attrgetter("name")) return albums + tracks def _browse_directory(self, uri, order=("type", "name COLLATE NOCASE")): query = dict(uritools.urisplit(uri).getquerylist()) type = query.pop("type", None) role = query.pop("role", None) # TODO: handle these in schema (generically)? if type == "date": format = query.get("format", "%Y-%m-%d") return list(map(date_ref, schema.dates(self._connect(), format=format))) if type == "genre": return list(map(genre_ref, schema.list_distinct(self._connect(), "genre"))) # Fix #38: keep sort order of album tracks; this also applies # to composers and performers if type == Ref.TRACK and "album" in query: order = ("disc_no", "track_no", "name") if type == Ref.ARTIST and self._config["use_artist_sortname"]: order = ("coalesce(sortname, name) COLLATE NOCASE",) roles = role or ("artist", "albumartist") # FIXME: re-think 'roles'... refs = [] for ref in schema.browse( self._connect(), type, order, role=roles, **query ): # noqa if ref.type == Ref.TRACK or (not query and not role): refs.append(ref) elif ref.type == Ref.ALBUM: refs.append( Ref.directory( uri=uritools.uricompose( "local", None, "directory", dict(query, type=Ref.TRACK, album=ref.uri), # noqa ), name=ref.name, ) ) elif ref.type == Ref.ARTIST: refs.append( Ref.directory( uri=uritools.uricompose( "local", None, "directory", dict(query, **{role: ref.uri}) ), name=ref.name, ) ) else: logger.warning("Unexpected SQLite browse result: %r", ref) return refs def _filters(self, uri): if uri.startswith("local:directory"): return [dict(uritools.urisplit(uri).getquerylist())] elif uri.startswith("local:artist"): return [{"artist": uri}, {"albumartist": uri}] elif uri.startswith("local:album"): return [{"album": uri}] else: return [] Mopidy-Local-3.1.1/mopidy_local/mtimes.py0000664000175000017500000000621713577631225020557 0ustar jodaljodal00000000000000import pathlib import queue import stat import threading from mopidy import exceptions class FindError(exceptions.MopidyException): def __init__(self, message, errno=None): super().__init__(message, errno) self.errno = errno def find_mtimes(root, follow=False): results, errors = _find(root, relative=False, follow=follow) # return the mtimes as integer milliseconds mtimes = {f: int(st.st_mtime * 1000) for f, st in results.items()} return mtimes, errors def _find(root, thread_count=10, relative=False, follow=False): """Threaded find implementation that provides stat results for files. Tries to protect against sym/hardlink loops by keeping an eye on parent (st_dev, st_ino) pairs. :param Path root: root directory to search from, may not be a file :param int thread_count: number of workers to use, mainly useful to mitigate network lag when scanning on NFS etc. :param bool relative: if results should be relative to root or absolute :param bool follow: if symlinks should be followed """ root = pathlib.Path(root).resolve() threads = [] results = {} errors = {} done = threading.Event() work = queue.Queue() work.put((root, [])) if not relative: root = None args = (root, follow, done, work, results, errors) for _ in range(thread_count): t = threading.Thread(target=_find_worker, args=args) t.daemon = True t.start() threads.append(t) work.join() done.set() for t in threads: t.join() return results, errors def _find_worker(root, follow, done, work, results, errors): """Worker thread for collecting stat() results. :param Path root: directory to make results relative to :param bool follow: if symlinks should be followed :param threading.Event done: event indicating that all work has been done :param queue.Queue work: queue of paths to process :param dict results: shared dictionary for storing all the stat() results :param dict errors: shared dictionary for storing any per path errors """ while not done.is_set(): try: entry, parents = work.get(block=False) except queue.Empty: continue if root: path = entry.relative_to(root) else: path = entry try: if follow: st = entry.stat() else: st = entry.lstat() if (st.st_dev, st.st_ino) in parents: errors[path] = FindError("Sym/hardlink loop found.") continue if stat.S_ISDIR(st.st_mode): for e in entry.iterdir(): work.put((e, parents + [(st.st_dev, st.st_ino)])) elif stat.S_ISREG(st.st_mode): results[path] = st elif stat.S_ISLNK(st.st_mode): errors[path] = FindError("Not following symlinks.") else: errors[path] = FindError("Not a file or directory.") except OSError as exc: errors[path] = FindError(exc.strerror, exc.errno) finally: work.task_done() Mopidy-Local-3.1.1/mopidy_local/playback.py0000664000175000017500000000042113577631225021036 0ustar jodaljodal00000000000000from mopidy import backend from mopidy_local import translator class LocalPlaybackProvider(backend.PlaybackProvider): def translate_uri(self, uri): return translator.local_uri_to_file_uri( uri, self.backend.config["local"]["media_dir"] ) Mopidy-Local-3.1.1/mopidy_local/schema.py0000664000175000017500000003572213614770065020522 0ustar jodaljodal00000000000000import logging import operator import pathlib import re import sqlite3 from mopidy.models import Album, Artist, Image, Ref, Track _IMAGE_SIZE_RE = re.compile(r".*-(\d+)x(\d+)\.(?:png|gif|jpeg)$") _IMAGES_QUERY = "SELECT images FROM album WHERE images IS NOT NULL" _ALBUM_IMAGE_QUERY = "SELECT images FROM album WHERE uri = ?" _TRACK_IMAGE_QUERY = """ SELECT album.images AS images FROM track LEFT OUTER JOIN album ON track.album = album.uri WHERE track.uri = ? """ _BROWSE_QUERIES = { None: """ SELECT CASE WHEN album.uri IS NULL THEN '%s' ELSE '%s' END AS type, coalesce(album.uri, track.uri) AS uri, coalesce(album.name, track.name) AS name FROM track LEFT OUTER JOIN album ON track.album = album.uri WHERE %%s GROUP BY coalesce(album.uri, track.uri) ORDER BY %%s """ % (Ref.TRACK, Ref.ALBUM), Ref.ALBUM: """ SELECT '%s' AS type, uri AS uri, name AS name FROM album WHERE %%s ORDER BY %%s """ % Ref.ALBUM, Ref.ARTIST: """ SELECT '%s' AS type, uri AS uri, name AS name FROM artist WHERE %%s ORDER BY %%s """ % Ref.ARTIST, Ref.TRACK: """ SELECT '%s' AS type, uri AS uri, name AS name FROM track WHERE %%s ORDER BY %%s """ % Ref.TRACK, } _BROWSE_FILTERS = { None: { "album": "track.album = ?", "albumartist": "album.artists = ?", "artist": "track.artists = ?", "composer": "track.composers = ?", "date": "track.date LIKE ? || '%'", "genre": "track.genre = ?", "performer": "track.performers = ?", "max-age": "track.last_modified >= (strftime('%s', 'now') - ?) * 1000", }, Ref.ARTIST: { "role": { "albumartist": """EXISTS ( SELECT * FROM album WHERE album.artists = artist.uri )""", "artist": """EXISTS ( SELECT * FROM track WHERE track.artists = artist.uri )""", "composer": """EXISTS ( SELECT * FROM track WHERE track.composers = artist.uri )""", "performer": """EXISTS ( SELECT * FROM track WHERE track.performers = artist.uri )""", }, }, Ref.ALBUM: { "albumartist": "artists = ?", "artist": """? IN ( SELECT artists FROM track WHERE album = album.uri )""", "composer": """? IN ( SELECT composers FROM track WHERE album = album.uri )""", "date": """EXISTS ( SELECT * FROM track WHERE album = album.uri AND date LIKE ? || '%' )""", "genre": """? IN ( SELECT genre FROM track WHERE album = album.uri )""", "performer": """? IN ( SELECT performers FROM track WHERE album = album.uri )""", "max-age": """EXISTS ( SELECT * FROM track WHERE album = album.uri AND last_modified >= (strftime('%s', 'now') - ?) * 1000 )""", }, Ref.TRACK: { "album": "album = ?", "albumartist": """? IN ( SELECT artists FROM album WHERE uri = track.album )""", "artist": "artists = ?", "composer": "composers = ?", "date": "date LIKE ? || '%'", "genre": "genre = ?", "performer": "performers = ?", "max-age": "last_modified >= (strftime('%s', 'now') - ?) * 1000", }, } _LOOKUP_QUERIES = { Ref.ALBUM: """ SELECT * FROM tracks WHERE album_uri = ? """, Ref.ARTIST: """ SELECT * FROM tracks WHERE ? IN (artist_uri, albumartist_uri) """, Ref.TRACK: """ SELECT * FROM tracks WHERE uri = ? """, } _SEARCH_SQL = """ SELECT * FROM tracks WHERE docid IN (SELECT docid FROM %s WHERE %s) """ _SEARCH_FILTERS = { "album": "album_uri = ?", "albumartist": "albumartist_uri = ?", "artist": "artist_uri = ?", "composer": "composer_uri = ?", "date": "date LIKE ? || '%'", "genre": "genre = ?", "performer": "performer_uri = ?", "max-age": "last_modified >= (strftime('%s', 'now') - ?) * 1000", } _SEARCH_FIELDS = { "uri", "track_name", "album", "artist", "composer", "performer", "albumartist", "genre", "track_no", "date", "comment", } schema_version = 6 logger = logging.getLogger(__name__) class Connection(sqlite3.Connection): class Row(sqlite3.Row): def __getattr__(self, name): return self[name] def __init__(self, *args, **kwargs): sqlite3.Connection.__init__(self, *args, **kwargs) self.execute("PRAGMA foreign_keys = ON") self.row_factory = self.Row def load(c): sql_dir = pathlib.Path(__file__).parent / "sql" user_version = c.execute("PRAGMA user_version").fetchone()[0] while user_version != schema_version: if user_version: logger.info("Upgrading SQLite database schema v%s", user_version) filename = "upgrade-v%s.sql" % user_version else: logger.info("Creating SQLite database schema v%s", schema_version) filename = "schema.sql" with open(sql_dir / filename) as fh: c.executescript(fh.read()) new_version = c.execute("PRAGMA user_version").fetchone()[0] assert new_version != user_version user_version = new_version return user_version def tracks(c): return list(map(_track, c.execute("SELECT * FROM tracks"))) def list_distinct(c, field, query=tuple()): if field not in _SEARCH_FIELDS: raise LookupError("Invalid search field: %s" % field) sql = ( """ SELECT DISTINCT %s AS field FROM search WHERE field IS NOT NULL """ % field ) terms = [] params = [] for key, value in query: if key == "any": terms.append("? IN (%s)" % ",".join(_SEARCH_FIELDS)) elif key in _SEARCH_FIELDS: terms.append("%s = ?" % key) else: raise LookupError("Invalid search field: %s" % key) params.append(value) if terms: sql += " AND " + " AND ".join(terms) logger.debug("SQLite list query %r: %s", params, sql) return list(map(operator.itemgetter(0), c.execute(sql, params))) def dates(c, format="%Y-%m-%d"): return list( map( operator.itemgetter(0), c.execute( """ SELECT DISTINCT strftime(?, date) AS date FROM track WHERE date IS NOT NULL ORDER BY date """, [format], ), ) ) def lookup(c, type, uri): return list(map(_track, c.execute(_LOOKUP_QUERIES[type], [uri]))) def exists(c, uri): rows = c.execute("SELECT EXISTS(SELECT * FROM track WHERE uri = ?)", [uri]) return rows.fetchone()[0] def browse(c, type=None, order=("type", "name COLLATE NOCASE"), **kwargs): filters, params = _filters(_BROWSE_FILTERS[type], **kwargs) sql = _BROWSE_QUERIES[type] % (" AND ".join(filters) or "1", ", ".join(order)) logger.debug("SQLite browse query %r: %s", params, sql) return [Ref(**row) for row in c.execute(sql, params)] def search_tracks(c, query, limit, offset, exact, filters=tuple()): if not query: sql, params = ("SELECT * FROM tracks WHERE 1", []) elif exact: sql, params = _indexed_query(query) else: sql, params = _fulltext_query(query) clauses = [] for kwargs in filters: f, p = _filters(_SEARCH_FILTERS, **kwargs) if f: clauses.append("(%s)" % " AND ".join(f)) params.extend(p) else: logger.debug("Skipped SQLite search filter %r", kwargs) if clauses: sql += " AND (%s)" % " OR ".join(clauses) sql += " LIMIT ? OFFSET ?" params += [limit, offset] logger.debug("SQLite search query %r: %s", params, sql) rows = c.execute(sql, params) return list(map(_track, rows)) def get_image_uris(c): rows = c.execute(_IMAGES_QUERY) return (uri for row in rows for uri in row.images.split()) def get_album_images(c, uri): images = [] for row in c.execute(_ALBUM_IMAGE_QUERY, (uri,)): images.extend(_images(row.images)) return images def get_track_images(c, uri): images = [] for row in c.execute(_TRACK_IMAGE_QUERY, (uri,)): images.extend(_images(row.images)) return images def insert_artists(c, artists): if not artists: return None if len(artists) != 1: logger.warning("Ignoring multiple artists: %r", artists) artist = next(iter(artists)) _insert( c, "artist", { "uri": artist.uri, "name": artist.name, "sortname": artist.sortname, "musicbrainz_id": artist.musicbrainz_id, }, ) return artist.uri def insert_album(c, album, images=None): if not album or not album.name: return None _insert( c, "album", { "uri": album.uri, "name": album.name, "artists": insert_artists(c, album.artists), "num_tracks": album.num_tracks, "num_discs": album.num_discs, "date": album.date, "musicbrainz_id": album.musicbrainz_id, "images": " ".join(images) if images else None, }, ) return album.uri def insert_track(c, track, images=None): _insert( c, "track", { "uri": track.uri, "name": track.name, "album": insert_album(c, track.album, images), "artists": insert_artists(c, track.artists), "composers": insert_artists(c, track.composers), "performers": insert_artists(c, track.performers), "genre": track.genre, "track_no": track.track_no, "disc_no": track.disc_no, "date": track.date, "length": track.length, "bitrate": track.bitrate, "comment": track.comment, "musicbrainz_id": track.musicbrainz_id, "last_modified": track.last_modified, }, ) return track.uri def delete_track(c, uri): c.execute("DELETE FROM track WHERE uri = ?", (uri,)) def count_tracks(c): return c.execute("SELECT count(*) FROM track").fetchone()[0] def cleanup(c): c.execute( """ DELETE FROM album WHERE NOT EXISTS ( SELECT uri FROM track WHERE track.album = album.uri ) """ ) c.execute( """ DELETE FROM artist WHERE NOT EXISTS ( SELECT uri FROM track WHERE track.artists = artist.uri UNION SELECT uri FROM track WHERE track.composers = artist.uri UNION SELECT uri FROM track WHERE track.performers = artist.uri UNION SELECT uri FROM album WHERE album.artists = artist.uri ) """ ) c.execute("ANALYZE") def clear(c): c.executescript( """ DELETE FROM track; DELETE FROM album; DELETE FROM artist; VACUUM; """ ) def _insert(c, table, params): sql = "INSERT OR REPLACE INTO {} ({}) VALUES ({})".format( table, ", ".join(params.keys()), ", ".join(["?"] * len(params)) ) logger.debug("SQLite insert statement: %s %r", sql, params.values()) return c.execute(sql, list(params.values())) def _filters(mapping, role=None, **kwargs): filters, params = [], [] if role and "role" in mapping: rolemap = mapping["role"] if isinstance(role, (str, bytes)): filters.append(rolemap[role]) else: filters.append(" OR ".join(rolemap[r] for r in role)) for key, value in kwargs.items(): if key in mapping: filters.append(mapping[key]) params.append(value) else: logger.debug("Skipped SQLite filter expression: %s=%r", key, value) return (filters, params) def _indexed_query(query): terms = [] params = [] for field, value in query: if field == "any": terms.append("? IN (%s)" % ",".join(_SEARCH_FIELDS)) elif field in _SEARCH_FIELDS: terms.append("%s = ?" % field) else: raise LookupError("Invalid search field: %s" % field) params.append(value) return (_SEARCH_SQL % ("search", " AND ".join(terms)), params) def _fulltext_query(query): terms = [] params = [] for field, value in query: if field == "any": terms.append(_SEARCH_SQL % ("fts", "fts MATCH ?")) elif field in _SEARCH_FIELDS: terms.append(_SEARCH_SQL % ("fts", "%s MATCH ?" % field)) else: raise LookupError("Invalid search field: %s" % field) params.append(value) return (" INTERSECT ".join(terms), params) def _track(row): kwargs = { "uri": row.uri, "name": row.name, "genre": row.genre, "track_no": row.track_no, "disc_no": row.disc_no, "date": row.date, "length": row.length, "bitrate": row.bitrate, "comment": row.comment, "musicbrainz_id": row.musicbrainz_id, "last_modified": row.last_modified, } if row.album_uri is not None: if row.albumartist_uri is not None: albumartists = [ Artist( uri=row.albumartist_uri, name=row.albumartist_name, sortname=row.albumartist_sortname, musicbrainz_id=row.albumartist_musicbrainz_id, ) ] else: albumartists = None kwargs["album"] = Album( uri=row.album_uri, name=row.album_name, artists=albumartists, num_tracks=row.album_num_tracks, num_discs=row.album_num_discs, date=row.album_date, musicbrainz_id=row.album_musicbrainz_id, ) if row.artist_uri is not None: kwargs["artists"] = [ Artist( uri=row.artist_uri, name=row.artist_name, sortname=row.artist_sortname, musicbrainz_id=row.artist_musicbrainz_id, ) ] if row.composer_uri is not None: kwargs["composers"] = [ Artist( uri=row.composer_uri, name=row.composer_name, sortname=row.composer_sortname, musicbrainz_id=row.composer_musicbrainz_id, ) ] if row.performer_uri is not None: kwargs["performers"] = [ Artist( uri=row.performer_uri, name=row.performer_name, sortname=row.performer_sortname, musicbrainz_id=row.performer_musicbrainz_id, ) ] return Track(**kwargs) def _images(field): images = [] for uri in field.split() if field else []: m = _IMAGE_SIZE_RE.match(uri) if m: width = int(m.group(1)) height = int(m.group(2)) images.append(Image(uri=uri, width=width, height=height)) else: images.append(Image(uri=uri)) return images Mopidy-Local-3.1.1/mopidy_local/sql/0000775000175000017500000000000013614772521017475 5ustar jodaljodal00000000000000Mopidy-Local-3.1.1/mopidy_local/sql/schema.sql0000664000175000017500000001762713577631225021476 0ustar jodaljodal00000000000000-- Mopidy-Local-SQLite schema BEGIN EXCLUSIVE TRANSACTION; PRAGMA user_version = 6; -- schema version CREATE TABLE artist ( uri TEXT PRIMARY KEY, -- artist URI name TEXT NOT NULL, -- artist name sortname TEXT, -- artist name for sorting musicbrainz_id TEXT -- MusicBrainz ID ); CREATE TABLE album ( uri TEXT PRIMARY KEY, -- album URI name TEXT NOT NULL, -- album name artists TEXT, -- (list of Artist) album artists num_tracks INTEGER, -- number of tracks in album num_discs INTEGER, -- number of discs in album date TEXT, -- album release date (YYYY or YYYY-MM-DD) musicbrainz_id TEXT, -- MusicBrainz ID images TEXT, -- (list of strings) album image URIs FOREIGN KEY (artists) REFERENCES artist (uri) ); CREATE TABLE track ( uri TEXT PRIMARY KEY, -- track URI name TEXT NOT NULL, -- track name album TEXT, -- track album artists TEXT, -- (list of Artist) – track artists composers TEXT, -- (list of Artist) – track composers performers TEXT, -- (list of Artist) – track performers genre TEXT, -- track genre track_no INTEGER, -- track number in album disc_no INTEGER, -- disc number in album date TEXT, -- track release date (YYYY or YYYY-MM-DD) length INTEGER, -- track length in milliseconds bitrate INTEGER, -- bitrate in kbit/s comment TEXT, -- track comment musicbrainz_id TEXT, -- MusicBrainz ID last_modified INTEGER, -- Represents last modification time FOREIGN KEY (album) REFERENCES album (uri), FOREIGN KEY (artists) REFERENCES artist (uri), FOREIGN KEY (composers) REFERENCES artist (uri), FOREIGN KEY (performers) REFERENCES artist (uri) ); CREATE INDEX album_name_index ON album (name); CREATE INDEX album_artists_index ON album (artists); CREATE INDEX album_date_index ON album (date); CREATE INDEX artist_name_index ON artist (name); CREATE INDEX track_name_index ON track (name); CREATE INDEX track_album_index ON track (album); CREATE INDEX track_artists_index ON track (artists); CREATE INDEX track_composers_index ON track (composers); CREATE INDEX track_performers_index ON track (performers); CREATE INDEX track_genre_index ON track (genre); CREATE INDEX track_track_no_index ON track (track_no); CREATE INDEX track_date_index ON track (date); CREATE INDEX track_comment_index on track (comment); CREATE INDEX track_last_modified_index on track (last_modified); -- Convenience views CREATE VIEW albums AS SELECT album.uri AS uri, album.name AS name, artist.uri AS artist_uri, artist.name AS artist_name, artist.sortname AS artist_sortname, artist.musicbrainz_id AS artist_musicbrainz_id, album.num_tracks AS num_tracks, album.num_discs AS num_discs, album.date AS date, album.musicbrainz_id AS musicbrainz_id, album.images AS images FROM album LEFT OUTER JOIN artist ON album.artists = artist.uri; CREATE VIEW tracks AS SELECT track.rowid AS docid, track.uri AS uri, track.name AS name, track.genre AS genre, track.track_no AS track_no, track.disc_no AS disc_no, track.date AS date, track.length AS length, track.bitrate AS bitrate, track.comment AS comment, track.musicbrainz_id AS musicbrainz_id, track.last_modified AS last_modified, album.uri AS album_uri, album.name AS album_name, album.num_tracks AS album_num_tracks, album.num_discs AS album_num_discs, album.date AS album_date, album.musicbrainz_id AS album_musicbrainz_id, album.images AS album_images, artist.uri AS artist_uri, artist.name AS artist_name, artist.sortname AS artist_sortname, artist.musicbrainz_id AS artist_musicbrainz_id, composer.uri AS composer_uri, composer.name AS composer_name, composer.sortname AS composer_sortname, composer.musicbrainz_id AS composer_musicbrainz_id, performer.uri AS performer_uri, performer.name AS performer_name, performer.sortname AS performer_sortname, performer.musicbrainz_id AS performer_musicbrainz_id, albumartist.uri AS albumartist_uri, albumartist.name AS albumartist_name, albumartist.sortname AS albumartist_sortname, albumartist.musicbrainz_id AS albumartist_musicbrainz_id FROM track LEFT OUTER JOIN album ON track.album = album.uri LEFT OUTER JOIN artist ON track.artists = artist.uri LEFT OUTER JOIN artist AS composer ON track.composers = composer.uri LEFT OUTER JOIN artist AS performer ON track.performers = performer.uri LEFT OUTER JOIN artist AS albumartist ON album.artists = albumartist.uri; -- Indexed search; column names match Mopidy query fields CREATE VIEW search AS SELECT docid AS docid, uri AS uri, name AS track_name, album_name AS album, artist_name AS artist, composer_name AS composer, performer_name AS performer, albumartist_name AS albumartist, genre AS genre, track_no AS track_no, coalesce(date, album_date) AS date, comment AS comment FROM tracks; -- Full-text search; column names match Mopidy query fields CREATE VIRTUAL TABLE fts USING fts3 ( uri, track_name, album, artist, composer, performer, albumartist, genre, track_no, date, comment ); CREATE TRIGGER track_after_insert AFTER INSERT ON track BEGIN INSERT INTO fts ( docid, uri, track_name, album, artist, composer, performer, albumartist, genre, track_no, date, comment ) SELECT * FROM search WHERE docid = new.rowid; END; CREATE TRIGGER track_after_update AFTER UPDATE ON track BEGIN INSERT INTO fts ( docid, uri, track_name, album, artist, composer, performer, albumartist, genre, track_no, date, comment ) SELECT * FROM search WHERE docid = new.rowid; END; CREATE TRIGGER track_before_update BEFORE UPDATE ON track BEGIN DELETE FROM fts WHERE docid = old.rowid; END; CREATE TRIGGER track_before_delete BEFORE DELETE ON track BEGIN DELETE FROM fts WHERE docid = old.rowid; END; END TRANSACTION; Mopidy-Local-3.1.1/mopidy_local/sql/upgrade-v1.sql0000664000175000017500000001143213577631225022175 0ustar jodaljodal00000000000000-- Mopidy-Local-SQLite schema upgrade v1 -> v2 BEGIN EXCLUSIVE TRANSACTION; -- update schema CREATE INDEX album_name_index ON album (name); CREATE INDEX album_artists_index ON album (artists); CREATE INDEX artist_name_index ON artist (name); CREATE INDEX track_name_index ON track (name); CREATE INDEX track_album_index ON track (album); CREATE INDEX track_artists_index ON track (artists); CREATE INDEX track_composers_index ON track (composers); CREATE INDEX track_performers_index ON track (performers); CREATE INDEX track_genre_index ON track (genre); CREATE INDEX track_comment_index on track (comment); CREATE VIEW tracks AS SELECT track.rowid AS docid, track.uri AS uri, track.name AS name, track.genre AS genre, track.track_no AS track_no, track.disc_no AS disc_no, track.date AS date, track.length AS length, track.bitrate AS bitrate, track.comment AS comment, track.musicbrainz_id AS musicbrainz_id, track.last_modified AS last_modified, album.uri AS album_uri, album.name AS album_name, album.num_tracks AS album_num_tracks, album.num_discs AS album_num_discs, album.date AS album_date, album.musicbrainz_id AS album_musicbrainz_id, album.images AS album_images, artist.uri AS artist_uri, artist.name AS artist_name, artist.musicbrainz_id AS artist_musicbrainz_id, composer.uri AS composer_uri, composer.name AS composer_name, composer.musicbrainz_id AS composer_musicbrainz_id, performer.uri AS performer_uri, performer.name AS performer_name, performer.musicbrainz_id AS performer_musicbrainz_id, albumartist.uri AS albumartist_uri, albumartist.name AS albumartist_name, albumartist.musicbrainz_id AS albumartist_musicbrainz_id FROM track LEFT OUTER JOIN album ON track.album = album.uri LEFT OUTER JOIN artist ON track.artists = artist.uri LEFT OUTER JOIN artist AS composer ON track.composers = composer.uri LEFT OUTER JOIN artist AS performer ON track.performers = performer.uri LEFT OUTER JOIN artist AS albumartist ON album.artists = albumartist.uri; CREATE VIEW search AS SELECT docid AS docid, uri AS uri, name AS track_name, album_name AS album, artist_name AS artist, composer_name AS composer, performer_name AS performer, albumartist_name AS albumartist, genre AS genre, track_no AS track_no, coalesce(date, album_date) AS date, comment AS comment FROM tracks; CREATE VIRTUAL TABLE fts USING fts3 ( uri, track_name, album, artist, composer, performer, albumartist, genre, track_no, date, comment ); CREATE TRIGGER track_after_insert AFTER INSERT ON track BEGIN INSERT INTO fts ( docid, uri, track_name, album, artist, composer, performer, albumartist, genre, track_no, date, comment ) SELECT * FROM search WHERE docid = new.rowid; END; CREATE TRIGGER track_after_update AFTER UPDATE ON track BEGIN INSERT INTO fts ( docid, uri, track_name, album, artist, composer, performer, albumartist, genre, track_no, date, comment ) SELECT * FROM search WHERE docid = new.rowid; END; CREATE TRIGGER track_before_update BEFORE UPDATE ON track BEGIN DELETE FROM fts WHERE docid = old.rowid; END; CREATE TRIGGER track_before_delete BEFORE DELETE ON track BEGIN DELETE FROM fts WHERE docid = old.rowid; END; -- update date INSERT INTO fts ( docid, uri, track_name, album, artist, composer, performer, albumartist, genre, track_no, date, comment ) SELECT * FROM search; PRAGMA user_version = 2; -- update schema version END TRANSACTION; Mopidy-Local-3.1.1/mopidy_local/sql/upgrade-v2.sql0000664000175000017500000000242113577631225022174 0ustar jodaljodal00000000000000-- Mopidy-Local-SQLite schema upgrade v2 -> v3 BEGIN EXCLUSIVE TRANSACTION; CREATE VIEW albums AS SELECT album.uri AS uri, album.name AS name, artist.uri AS artist_uri, artist.name AS artist_name, artist.musicbrainz_id AS artist_musicbrainz_id, album.num_tracks AS num_tracks, album.num_discs AS num_discs, album.date AS date, album.musicbrainz_id AS musicbrainz_id, album.images AS images FROM album LEFT OUTER JOIN artist ON album.artists = artist.uri; CREATE VIEW artists AS SELECT uri, name, musicbrainz_id FROM artist WHERE EXISTS (SELECT * FROM album WHERE album.artists = artist.uri) OR EXISTS (SELECT * FROM track WHERE track.artists = artist.uri); CREATE VIEW composers AS SELECT uri, name, musicbrainz_id FROM artist WHERE EXISTS (SELECT * FROM track WHERE track.composers = artist.uri); CREATE VIEW performers AS SELECT uri, name, musicbrainz_id FROM artist WHERE EXISTS (SELECT * FROM track WHERE track.performers = artist.uri); PRAGMA user_version = 3; -- update schema version END TRANSACTION; Mopidy-Local-3.1.1/mopidy_local/sql/upgrade-v3.sql0000664000175000017500000000060213577631225022174 0ustar jodaljodal00000000000000-- Mopidy-Local-SQLite schema upgrade v3 -> v4 BEGIN EXCLUSIVE TRANSACTION; CREATE INDEX album_date_index ON album (date); CREATE INDEX track_track_no_index ON track (track_no); CREATE INDEX track_date_index ON track (date); DROP VIEW artists; DROP VIEW composers; DROP VIEW performers; PRAGMA user_version = 4; -- update schema version END TRANSACTION; Mopidy-Local-3.1.1/mopidy_local/sql/upgrade-v4.sql0000664000175000017500000000032613577631225022200 0ustar jodaljodal00000000000000-- Mopidy-Local-SQLite schema upgrade v4 -> v5 BEGIN EXCLUSIVE TRANSACTION; CREATE INDEX track_last_modified_index ON track (last_modified); PRAGMA user_version = 5; -- update schema version END TRANSACTION; Mopidy-Local-3.1.1/mopidy_local/sql/upgrade-v5.sql0000664000175000017500000000643213577631225022205 0ustar jodaljodal00000000000000-- Mopidy-Local-SQLite schema upgrade v5 -> v6 BEGIN EXCLUSIVE TRANSACTION; ALTER TABLE artist ADD COLUMN sortname TEXT; DROP VIEW albums; DROP VIEW tracks; CREATE VIEW albums AS SELECT album.uri AS uri, album.name AS name, artist.uri AS artist_uri, artist.name AS artist_name, artist.sortname AS artist_sortname, artist.musicbrainz_id AS artist_musicbrainz_id, album.num_tracks AS num_tracks, album.num_discs AS num_discs, album.date AS date, album.musicbrainz_id AS musicbrainz_id, album.images AS images FROM album LEFT OUTER JOIN artist ON album.artists = artist.uri; CREATE VIEW tracks AS SELECT track.rowid AS docid, track.uri AS uri, track.name AS name, track.genre AS genre, track.track_no AS track_no, track.disc_no AS disc_no, track.date AS date, track.length AS length, track.bitrate AS bitrate, track.comment AS comment, track.musicbrainz_id AS musicbrainz_id, track.last_modified AS last_modified, album.uri AS album_uri, album.name AS album_name, album.num_tracks AS album_num_tracks, album.num_discs AS album_num_discs, album.date AS album_date, album.musicbrainz_id AS album_musicbrainz_id, album.images AS album_images, artist.uri AS artist_uri, artist.name AS artist_name, artist.sortname AS artist_sortname, artist.musicbrainz_id AS artist_musicbrainz_id, composer.uri AS composer_uri, composer.name AS composer_name, composer.sortname AS composer_sortname, composer.musicbrainz_id AS composer_musicbrainz_id, performer.uri AS performer_uri, performer.name AS performer_name, performer.sortname AS performer_sortname, performer.musicbrainz_id AS performer_musicbrainz_id, albumartist.uri AS albumartist_uri, albumartist.name AS albumartist_name, albumartist.sortname AS albumartist_sortname, albumartist.musicbrainz_id AS albumartist_musicbrainz_id FROM track LEFT OUTER JOIN album ON track.album = album.uri LEFT OUTER JOIN artist ON track.artists = artist.uri LEFT OUTER JOIN artist AS composer ON track.composers = composer.uri LEFT OUTER JOIN artist AS performer ON track.performers = performer.uri LEFT OUTER JOIN artist AS albumartist ON album.artists = albumartist.uri; PRAGMA user_version = 6; -- update schema version END TRANSACTION; Mopidy-Local-3.1.1/mopidy_local/storage.py0000664000175000017500000002000413614770065020711 0ustar jodaljodal00000000000000import hashlib import imghdr import logging import pathlib import shutil import sqlite3 import struct import uritools from . import Extension, schema, translator logger = logging.getLogger(__name__) def check_dirs_and_files(config): if not pathlib.Path(config["local"]["media_dir"]).is_dir(): logger.warning( "Local media dir %s does not exist or we lack permissions to the " "directory or one of its parents" % config["local"]["media_dir"] ) # would be nice to have these in imghdr... def get_image_size_png(data): return struct.unpack(">ii", data[16:24]) def get_image_size_gif(data): return struct.unpack("H", data[index : index + 2])[0] - 2 index += 2 index += 1 # skip precision byte height, width = struct.unpack(">HH", data[index : index + 4]) return width, height class LocalStorageProvider: def __init__(self, config): self._config = ext_config = config[Extension.ext_name] self._media_dir = pathlib.Path(ext_config["media_dir"]) self._data_dir = Extension.get_data_dir(config) self._image_dir = Extension.get_image_dir(config) self._base_uri = "/" + Extension.ext_name + "/" self._patterns = list(map(str, ext_config["album_art_files"])) self._dbpath = self._data_dir / "library.db" self._connection = None def load(self): with self._connect() as connection: version = schema.load(connection) logger.debug("Using SQLite database schema v%s", version) return schema.count_tracks(connection) def begin(self): return schema.tracks(self._connect()) def add(self, track, tags=None, duration=None): logger.debug("Adding track: %s", track) images = None if track.album and track.album.name: # FIXME: album required uri = translator.local_uri_to_file_uri(track.uri, self._media_dir) try: images = self._extract_images(track.uri, tags) logger.debug("%s images: %s", track.uri, images) except Exception as e: logger.warning("Error extracting images for %s: %s", uri, e) try: track = self._validate_track(track) schema.insert_track(self._connect(), track, images) except Exception as e: logger.warning("Skipped %s: %s", track.uri, e) def remove(self, uri): schema.delete_track(self._connect(), uri) def flush(self): if not self._connection: return False self._connection.commit() return True def close(self): if self._connection: schema.cleanup(self._connection) self._connection.commit() self._connection.close() self._connection = None else: logger.error("Attempting to close while not connected") self._cleanup_images() def clear(self): logger.info("Clearing image directory") try: shutil.rmtree(self._image_dir) self._image_dir.mkdir() except IOError as e: logger.warning("Error clearing image directory: %s", e) logger.info("Clearing SQLite database") try: schema.clear(self._connect()) return True except sqlite3.Error as e: logger.error("Error clearing SQLite database: %s", e) return False def _connect(self): if not self._connection: self._connection = sqlite3.connect( self._dbpath, factory=schema.Connection, timeout=self._config["timeout"], check_same_thread=False, ) return self._connection def _validate_artist(self, model): if not model.name: raise ValueError("Empty artist name") if not model.uri: model = model.replace(uri=model_uri("artist", model)) return model def _validate_album(self, model): if not model.name: raise ValueError("Empty album name") if not model.uri: model = model.replace(uri=model_uri("album", model)) return model.replace(artists=list(map(self._validate_artist, model.artists))) def _validate_track(self, model): if not model.uri: raise ValueError("Empty track URI") if model.name: name = model.name else: name = translator.local_uri_to_path(model.uri, "").name if model.album and model.album.name: album = self._validate_album(model.album) else: album = None return model.replace( name=name, album=album, artists=list(map(self._validate_artist, model.artists)), composers=list(map(self._validate_artist, model.composers)), performers=list(map(self._validate_artist, model.performers)), ) def _cleanup_images(self): logger.info("Cleaning up image directory") with self._connect() as c: uris = set(schema.get_image_uris(c)) for image_path in self._image_dir.glob("**/*"): if uritools.urijoin(self._base_uri, image_path.name) not in uris: logger.info(f"Deleting file {image_path.as_uri()}") image_path.unlink() def _extract_images(self, uri, tags): images = set() # filter duplicate images, e.g. embedded/external for image in tags.get("image", []) + tags.get("preview-image", []): try: # FIXME: gst.Buffer or plain str/bytes type? data = getattr(image, "data", image) images.add(self._get_or_create_image_file(None, data)) except Exception as e: logger.warning("Error extracting images for %r: %r", uri, e) # look for external album art track_path = translator.local_uri_to_path(uri, self._media_dir) dir_path = track_path.parent for pattern in self._patterns: for match_path in dir_path.glob(pattern): try: images.add(self._get_or_create_image_file(match_path)) except Exception as e: logger.warning( f"Cannot read image file {match_path.as_uri()}: {e!r}" ) return images def _get_or_create_image_file(self, path, data=None): what = imghdr.what(path, data) if not what: raise ValueError("Unknown image type") if not data: with open(path, "rb") as f: data = f.read() digest, width, height = hashlib.md5(data).hexdigest(), None, None try: if what == "png": width, height = get_image_size_png(data) elif what == "gif": width, height = get_image_size_gif(data) elif what == "jpeg": width, height = get_image_size_jpeg(data) except Exception as e: logger.error("Error getting image size for %r: %r", path, e) if width and height: name = "%s-%dx%d.%s" % (digest, width, height, what) else: name = f"{digest}.{what}" image_path = self._image_dir / name if not image_path.is_file(): logger.info(f"Creating file {image_path.as_uri()}") image_path.write_bytes(data) return uritools.urijoin(self._base_uri, name) Mopidy-Local-3.1.1/mopidy_local/translator.py0000664000175000017500000000250213614770065021441 0ustar jodaljodal00000000000000from __future__ import annotations import logging import os import urllib from pathlib import Path from typing import Union logger = logging.getLogger(__name__) def local_uri_to_file_uri(local_uri: str, media_dir: Path) -> str: """Convert local track or directory URI to file URI.""" path = local_uri_to_path(local_uri, media_dir) return path.as_uri() def local_uri_to_path(local_uri: str, media_dir: Path) -> Path: """Convert local track or directory URI to absolute path.""" if not local_uri.startswith(("local:directory:", "local:track:")): raise ValueError("Invalid URI.") uri_path = urllib.parse.urlsplit(local_uri.split(":", 2)[2]).path file_bytes = urllib.parse.unquote_to_bytes(uri_path) file_path = Path(os.fsdecode(file_bytes)) return media_dir / file_path def path_to_file_uri(path: Union[str, bytes, Path]) -> str: """Convert absolute path to file URI.""" ppath = Path(os.fsdecode(path)) assert ppath.is_absolute() return ppath.as_uri() def path_to_local_track_uri(path: Union[str, bytes, Path], media_dir: Path) -> str: """Convert path to local track URI.""" ppath = Path(os.fsdecode(path)) if ppath.is_absolute(): ppath = ppath.relative_to(media_dir) quoted_path = urllib.parse.quote(bytes(ppath)) return f"local:track:{quoted_path}" Mopidy-Local-3.1.1/mopidy_local/web.py0000664000175000017500000000113013577631225020023 0ustar jodaljodal00000000000000import logging import os import pathlib import tornado.web logger = logging.getLogger(__name__) class ImageHandler(tornado.web.StaticFileHandler): def get_cache_time(self, *args): return self.CACHE_MAX_AGE class IndexHandler(tornado.web.RequestHandler): def initialize(self, root): self.root = root def get(self, path): return self.render("index.html", images=self.uris()) def get_template_path(self): return pathlib.Path(__file__).parent / "www" def uris(self): for _, _, files in os.walk(self.root): yield from files Mopidy-Local-3.1.1/mopidy_local/www/0000775000175000017500000000000013614772521017522 5ustar jodaljodal00000000000000Mopidy-Local-3.1.1/mopidy_local/www/index.html0000664000175000017500000000157213577631225021527 0ustar jodaljodal00000000000000 Mopidy-Local

Mopidy-Local

This Web client is used to serve album art extracted from local media files by the Mopidy-Local extension.

{% for image in images %} {% end %}
Mopidy-Local-3.1.1/setup.cfg0000664000175000017500000000257113614772521016051 0ustar jodaljodal00000000000000[metadata] name = Mopidy-Local version = 3.1.1 url = https://github.com/mopidy/mopidy-local author = Stein Magnus Jodal author_email = stein.magnus@jodal.no license = Apache License, Version 2.0 license_file = LICENSE description = Mopidy extension for playing music from your local music archive 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 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 uritools >= 1.0 [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 = local = mopidy_local:Extension [flake8] application-import-names = mopidy_local, tests max-line-length = 80 exclude = .git, .tox, build select = C, E, F, W B B950 N ignore = E203 E501 W503 [egg_info] tag_build = tag_date = 0 Mopidy-Local-3.1.1/setup.py0000664000175000017500000000004613577631225015740 0ustar jodaljodal00000000000000from setuptools import setup setup() Mopidy-Local-3.1.1/tests/0000775000175000017500000000000013614772521015365 5ustar jodaljodal00000000000000Mopidy-Local-3.1.1/tests/__init__.py0000664000175000017500000000160413577631225017502 0ustar jodaljodal00000000000000import pathlib from mopidy.internal import deprecation def path_to_data_dir(name): path = pathlib.Path(__file__).parent / "data" / name return path.resolve() def generate_song(i): return "local:track:song%s.wav" % i def populate_tracklist(func): def wrapper(self): with deprecation.ignore("core.tracklist.add:tracks_arg"): self.tl_tracks = self.core.tracklist.add(self.tracks) return func(self) wrapper.__name__ = func.__name__ wrapper.__doc__ = func.__doc__ return wrapper class 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) Mopidy-Local-3.1.1/tests/data/0000775000175000017500000000000013614772521016276 5ustar jodaljodal00000000000000Mopidy-Local-3.1.1/tests/data/local/0000775000175000017500000000000013614772521017370 5ustar jodaljodal00000000000000Mopidy-Local-3.1.1/tests/data/local/library.json.gz0000664000175000017500000000063313577631225022353 0ustar jodaljodal00000000000000رRlibrary.jsonŕn } Xnﰻ1LwRE?c;UzZG%2o>TF#eY%֬2#룜V ؉0B.8]_q;cP| ZʟM+i]7 hR\rOnQ2; jS3.;R'{{[SmS1B "O|qx;_<oC%d$YrYlr#ّo6UŸn MrM.rzwdGrGFx$xy$= 3, "Need at least three tracks to run tests." assert ( self.tracks[0].length >= 2000 ), "First song needs to be at least 2000 miliseconds" def tearDown(self): # noqa: N802 pykka.ActorRegistry.stop_all() def assert_state_is(self, state): assert self.playback.get_state().get() == state def assert_current_track_is(self, track): assert self.playback.get_current_track().get() == track def assert_current_track_is_not(self, track): assert self.playback.get_current_track().get() != track def assert_current_track_index_is(self, index): assert self.tracklist.index().get() == index def assert_next_tl_track_is(self, tl_track): next_tlid = self.tracklist.get_next_tlid().get() assert next_tlid == (tl_track and tl_track.tlid) def assert_next_tl_track_is_not(self, tl_track): next_tlid = self.tracklist.get_next_tlid().get() assert next_tlid != (tl_track and tl_track.tlid) def assert_previous_tl_track_is(self, tl_track): previous_tlid = self.tracklist.get_previous_tlid().get() assert previous_tlid == (tl_track and tl_track.tlid) def assert_eot_tl_track_is(self, tl_track): eot_tlid = self.tracklist.get_eot_tlid().get() assert eot_tlid == (tl_track and tl_track.tlid) def assert_eot_tl_track_is_not(self, tl_track): eot_tlid = self.tracklist.get_eot_tlid().get() assert eot_tlid != (tl_track and tl_track.tlid) def test_uri_scheme(self): assert "file" not in self.core.get_uri_schemes().get() assert "local" in self.core.get_uri_schemes().get() def test_play_mp3(self): self.add_track("local:track:blank.mp3") self.playback.play().get() self.assert_state_is(PlaybackState.PLAYING) def test_play_ogg(self): self.add_track("local:track:blank.ogg") self.playback.play().get() self.assert_state_is(PlaybackState.PLAYING) def test_play_flac(self): self.add_track("local:track:blank.flac") self.playback.play().get() self.assert_state_is(PlaybackState.PLAYING) def test_play_uri_with_non_ascii_bytes(self): # Regression test: If trying to do .split(u':') on a bytestring, the # string will be decoded from ASCII to Unicode, which will crash on # non-ASCII strings, like the bytestring the following URI decodes to. self.add_track("local:track:12%20Doin%E2%80%99%20It%20Right.flac") self.playback.play().get() self.assert_state_is(PlaybackState.PLAYING) def test_initial_state_is_stopped(self): self.assert_state_is(PlaybackState.STOPPED) def test_play_with_empty_playlist(self): self.assert_state_is(PlaybackState.STOPPED) self.playback.play().get() self.assert_state_is(PlaybackState.STOPPED) def test_play_with_empty_playlist_return_value(self): assert self.playback.play().get() is None @populate_tracklist def test_play_state(self): self.assert_state_is(PlaybackState.STOPPED) self.playback.play().get() self.assert_state_is(PlaybackState.PLAYING) @populate_tracklist def test_play_return_value(self): assert self.playback.play().get() is None @populate_tracklist def test_play_track_state(self): self.assert_state_is(PlaybackState.STOPPED) self.playback.play(self.tl_tracks.get()[-1]).get() self.assert_state_is(PlaybackState.PLAYING) @populate_tracklist def test_play_track_return_value(self): assert self.playback.play(self.tl_tracks.get()[(-1)]).get() is None @populate_tracklist def test_play_when_playing(self): self.playback.play().get() track = self.playback.get_current_track().get() self.playback.play().get() self.assert_current_track_is(track) @populate_tracklist def test_play_when_paused(self): self.playback.play().get() track = self.playback.get_current_track().get() self.playback.pause().get() self.playback.play().get() self.assert_state_is(PlaybackState.PLAYING) self.assert_current_track_is(track) @populate_tracklist def test_play_when_paused_after_next(self): self.playback.play().get() self.playback.next().get() self.playback.next().get() track = self.playback.get_current_track().get() self.playback.pause().get() self.playback.play().get() self.assert_state_is(PlaybackState.PLAYING) self.assert_current_track_is(track) @populate_tracklist def test_play_sets_current_track(self): self.playback.play().get() self.assert_current_track_is(self.tracks[0]) @populate_tracklist def test_play_track_sets_current_track(self): self.playback.play(self.tl_tracks.get()[-1]).get() self.assert_current_track_is(self.tracks[-1]) @populate_tracklist def test_play_skips_to_next_track_on_failure(self): # If backend's play() returns False, it is a failure. uri = self.backend.playback.translate_uri(self.tracks[0].uri).get() self.audio.trigger_fake_playback_failure(uri) self.playback.play().get() self.assert_current_track_is_not(self.tracks[0]) self.assert_current_track_is(self.tracks[1]) @populate_tracklist def test_current_track_after_completed_playlist(self): self.playback.play(self.tl_tracks.get()[-1]).get() self.trigger_about_to_finish() # EOS should have triggered self.assert_state_is(PlaybackState.STOPPED) self.assert_current_track_is(None) self.playback.play(self.tl_tracks.get()[-1]).get() self.playback.next().get() self.assert_state_is(PlaybackState.STOPPED) self.assert_current_track_is(None) @populate_tracklist def test_previous(self): self.playback.play().get() self.playback.next().get() self.playback.previous().get() self.assert_current_track_is(self.tracks[0]) @populate_tracklist def test_previous_more(self): self.playback.play().get() # At track 0 self.playback.next().get() # At track 1 self.playback.next().get() # At track 2 self.playback.previous().get() # At track 1 self.assert_current_track_is(self.tracks[1]) @populate_tracklist def test_previous_return_value(self): self.playback.play().get() self.playback.next().get() assert self.playback.previous().get() is None @populate_tracklist def test_previous_does_not_trigger_playback(self): self.playback.play().get() self.playback.next().get() self.playback.stop() self.playback.previous().get() self.assert_state_is(PlaybackState.STOPPED) @populate_tracklist def test_previous_at_start_of_playlist(self): self.playback.previous().get() self.assert_state_is(PlaybackState.STOPPED) self.assert_current_track_is(None) def test_previous_for_empty_playlist(self): self.playback.previous().get() self.assert_state_is(PlaybackState.STOPPED) self.assert_current_track_is(None) @populate_tracklist def test_previous_skips_to_previous_track_on_failure(self): # If backend's play() returns False, it is a failure. uri = self.backend.playback.translate_uri(self.tracks[1].uri).get() self.audio.trigger_fake_playback_failure(uri) self.playback.play(self.tl_tracks.get()[2]).get() self.assert_current_track_is(self.tracks[2]) self.playback.previous().get() self.assert_current_track_is_not(self.tracks[1]) self.assert_current_track_is(self.tracks[0]) @populate_tracklist def test_next(self): self.playback.play().get() old_track = self.playback.get_current_track().get() old_position = self.tracklist.index().get() self.playback.next().get() assert self.tracklist.index().get() == (old_position + 1) self.assert_current_track_is_not(old_track) @populate_tracklist def test_next_return_value(self): self.playback.play().get() assert self.playback.next().get() is None @populate_tracklist def test_next_does_not_trigger_playback(self): self.playback.next().get() self.assert_state_is(PlaybackState.STOPPED) @populate_tracklist def test_next_at_end_of_playlist(self): self.playback.play().get() for i, track in enumerate(self.tracks): self.assert_state_is(PlaybackState.PLAYING) self.assert_current_track_is(track) assert self.tracklist.index().get() == i self.playback.next() # noqa: B305 self.assert_state_is(PlaybackState.STOPPED) @populate_tracklist def test_next_until_end_of_playlist_and_play_from_start(self): self.playback.play().get() for _ in self.tracks: self.playback.next().get() self.assert_current_track_is(None) self.assert_state_is(PlaybackState.STOPPED) self.playback.play().get() self.assert_state_is(PlaybackState.PLAYING) self.assert_current_track_is(self.tracks[0]) def test_next_for_empty_playlist(self): self.playback.next().get() self.assert_state_is(PlaybackState.STOPPED) @populate_tracklist def test_next_skips_to_next_track_on_failure(self): # If backend's play() returns False, it is a failure. uri = self.backend.playback.translate_uri(self.tracks[1].uri).get() self.audio.trigger_fake_playback_failure(uri) self.playback.play().get() self.assert_current_track_is(self.tracks[0]) self.playback.next().get() self.assert_current_track_is_not(self.tracks[1]) self.assert_current_track_is(self.tracks[2]) @populate_tracklist def test_next_track_before_play(self): self.assert_next_tl_track_is(self.tl_tracks.get()[0]) @populate_tracklist def test_next_track_during_play(self): self.playback.play().get() self.assert_next_tl_track_is(self.tl_tracks.get()[1]) @populate_tracklist def test_next_track_after_previous(self): self.playback.play().get() self.playback.next().get() self.playback.previous().get() self.assert_next_tl_track_is(self.tl_tracks.get()[1]) def test_next_track_empty_playlist(self): self.assert_next_tl_track_is(None) @populate_tracklist def test_next_track_at_end_of_playlist(self): self.playback.play().get() for _ in self.tl_tracks.get()[1:]: self.playback.next().get() self.assert_next_tl_track_is(None) @populate_tracklist def test_next_track_at_end_of_playlist_with_repeat(self): self.tracklist.set_repeat(True) self.playback.play().get() for _ in self.tracks[1:]: self.playback.next().get() self.assert_next_tl_track_is(self.tl_tracks.get()[0]) @populate_tracklist @mock.patch("random.shuffle") def test_next_track_with_random(self, shuffle_mock): shuffle_mock.side_effect = lambda tracks: tracks.reverse() self.tracklist.set_random(True) self.assert_next_tl_track_is(self.tl_tracks.get()[-1]) @populate_tracklist def test_next_with_consume(self): self.tracklist.set_consume(True) self.playback.play().get() self.playback.next().get() assert self.tracks[0] not in self.tracklist.get_tracks().get() @populate_tracklist def test_next_with_single_and_repeat(self): self.tracklist.set_single(True) self.tracklist.set_repeat(True) self.playback.play().get() self.assert_current_track_is(self.tracks[0]) self.playback.next().get() self.assert_current_track_is(self.tracks[1]) @populate_tracklist @mock.patch("random.shuffle") def test_next_with_random(self, shuffle_mock): shuffle_mock.side_effect = lambda tracks: tracks.reverse() self.tracklist.set_random(True) self.playback.play().get() self.assert_current_track_is(self.tracks[-1]) self.playback.next().get() self.assert_current_track_is(self.tracks[-2]) @populate_tracklist @mock.patch("random.shuffle") def test_next_track_with_random_after_append_playlist(self, shuffle_mock): shuffle_mock.side_effect = lambda tracks: tracks.reverse() self.tracklist.set_random(True) expected_tl_track = self.tl_tracks.get()[-1] next_tlid = self.tracklist.get_next_tlid().get() # Baseline checking that first next_track is last tl track per our fake # shuffle. assert next_tlid == expected_tl_track.tlid self.tracklist.add(self.tracks[:1]) old_next_tlid = next_tlid expected_tl_track = self.tracklist.get_tl_tracks().get()[-1] next_tlid = self.tracklist.get_next_tlid().get() # Verify that first next track has changed since we added to the # playlist. assert next_tlid == expected_tl_track.tlid assert next_tlid != old_next_tlid @populate_tracklist def test_end_of_track(self): self.playback.play().get() old_track = self.playback.get_current_track().get() old_position = self.tracklist.index().get() self.trigger_about_to_finish() new_track = self.playback.get_current_track().get() assert self.tracklist.index().get() == (old_position + 1) assert new_track.uri != old_track.uri @populate_tracklist def test_end_of_track_return_value(self): self.playback.play().get() assert self.trigger_about_to_finish() is None @populate_tracklist def test_end_of_track_does_not_trigger_playback(self): self.trigger_about_to_finish() self.assert_state_is(PlaybackState.STOPPED) @populate_tracklist def test_end_of_track_at_end_of_playlist(self): self.playback.play().get() for i, track in enumerate(self.tracks): self.assert_state_is(PlaybackState.PLAYING) self.assert_current_track_is(track) assert self.tracklist.index().get() == i self.trigger_about_to_finish() self.assert_state_is(PlaybackState.STOPPED) @populate_tracklist def test_end_of_track_until_end_of_playlist_and_play_from_start(self): self.playback.play().get() for _ in self.tracks: self.trigger_about_to_finish() assert self.playback.get_current_track().get() is None self.assert_state_is(PlaybackState.STOPPED) self.playback.play().get() self.assert_state_is(PlaybackState.PLAYING) self.assert_current_track_is(self.tracks[0]) def test_end_of_track_for_empty_playlist(self): self.trigger_about_to_finish() self.assert_state_is(PlaybackState.STOPPED) # TODO: On about to finish does not handle skipping to next track yet. @unittest.expectedFailure @populate_tracklist def test_end_of_track_skips_to_next_track_on_failure(self): # If backend's play() returns False, it is a failure. return_values = [True, False, True] self.backend.playback.play = lambda: return_values.pop() self.playback.play().get() self.assert_current_track_is(self.tracks[0]) self.trigger_about_to_finish() self.assert_current_track_is_not(self.tracks[1]) self.assert_current_track_is(self.tracks[2]) @populate_tracklist def test_end_of_track_track_before_play(self): self.assert_next_tl_track_is(self.tl_tracks.get()[0]) @populate_tracklist def test_end_of_track_track_during_play(self): self.playback.play().get() self.assert_next_tl_track_is(self.tl_tracks.get()[1]) @populate_tracklist def test_about_to_finish_after_previous(self): self.playback.play().get() self.trigger_about_to_finish() self.playback.previous().get() self.assert_next_tl_track_is(self.tl_tracks.get()[1]) def test_end_of_track_track_empty_playlist(self): self.assert_next_tl_track_is(None) @populate_tracklist def test_end_of_track_track_at_end_of_playlist(self): self.playback.play().get() for _ in self.tracks[1:]: self.trigger_about_to_finish() self.assert_next_tl_track_is(None) @populate_tracklist def test_end_of_track_track_at_end_of_playlist_with_repeat(self): self.tracklist.set_repeat(True) self.playback.play().get() for _ in self.tracks[1:]: self.trigger_about_to_finish() self.assert_next_tl_track_is(self.tl_tracks.get()[0]) @populate_tracklist @mock.patch("random.shuffle") def test_end_of_track_track_with_random(self, shuffle_mock): shuffle_mock.side_effect = lambda tracks: tracks.reverse() self.tracklist.set_random(True) self.assert_next_tl_track_is(self.tl_tracks.get()[-1]) @populate_tracklist def test_end_of_track_with_consume(self): self.tracklist.set_consume(True) self.playback.play().get() self.trigger_about_to_finish() assert self.tracks[0] not in self.tracklist.get_tracks().get() @populate_tracklist @mock.patch("random.shuffle") def test_end_of_track_with_random(self, shuffle_mock): shuffle_mock.side_effect = lambda tracks: tracks.reverse() self.tracklist.set_random(True) self.playback.play().get() self.assert_current_track_is(self.tracks[-1]) self.trigger_about_to_finish() self.assert_current_track_is(self.tracks[-2]) @populate_tracklist @mock.patch("random.shuffle") def test_end_of_track_track_with_random_after_append_playlist(self, shuffle_mock): shuffle_mock.side_effect = lambda tracks: tracks.reverse() self.tracklist.set_random(True) expected_tl_track = self.tracklist.get_tl_tracks().get()[-1] eot_tlid = self.tracklist.get_eot_tlid().get() # Baseline checking that first eot_track is last tl track per our fake # shuffle. assert eot_tlid == expected_tl_track.tlid self.tracklist.add(self.tracks[:1]) old_eot_tlid = eot_tlid expected_tl_track = self.tracklist.get_tl_tracks().get()[-1] eot_tlid = self.tracklist.get_eot_tlid().get() # Verify that first next track has changed since we added to the # playlist. assert eot_tlid == expected_tl_track.tlid assert eot_tlid != old_eot_tlid @populate_tracklist def test_previous_track_before_play(self): self.assert_previous_tl_track_is(None) @populate_tracklist def test_previous_track_after_play(self): self.playback.play().get() self.assert_previous_tl_track_is(None) @populate_tracklist def test_previous_track_after_next(self): self.playback.play().get() self.playback.next().get() self.assert_previous_tl_track_is(self.tl_tracks.get()[0]) @populate_tracklist def test_previous_track_after_previous(self): self.playback.play().get() # At track 0 self.playback.next().get() # At track 1 self.playback.next().get() # At track 2 self.playback.previous().get() # At track 1 self.assert_previous_tl_track_is(self.tl_tracks.get()[0]) def test_previous_track_empty_playlist(self): self.assert_previous_tl_track_is(None) @populate_tracklist def test_previous_track_with_consume(self): self.tracklist.set_consume(True) for _ in self.tracks: self.playback.next() # noqa: B305 current = self.playback.get_current_tl_track().get() self.assert_previous_tl_track_is(current) @populate_tracklist def test_previous_track_with_random(self): self.tracklist.set_random(True) for _ in self.tracks: self.playback.next() # noqa: B305 current = self.playback.get_current_tl_track().get() self.assert_previous_tl_track_is(current) @populate_tracklist def test_initial_current_track(self): self.assert_current_track_is(None) @populate_tracklist def test_current_track_during_play(self): self.playback.play().get() self.assert_current_track_is(self.tracks[0]) @populate_tracklist def test_current_track_after_next(self): self.playback.play() self.playback.next().get() self.assert_current_track_is(self.tracks[1]) @populate_tracklist def test_initial_tracklist_position(self): assert self.tracklist.index().get() is None @populate_tracklist def test_tracklist_position_during_play(self): self.playback.play().get() self.assert_current_track_index_is(0) @populate_tracklist def test_tracklist_position_after_next(self): self.playback.play().get() self.playback.next().get() self.assert_current_track_index_is(1) @populate_tracklist def test_tracklist_position_at_end_of_playlist(self): self.playback.play(self.tl_tracks.get()[-1]).get() self.trigger_about_to_finish() # EOS should have triggered self.assert_current_track_index_is(None) @mock.patch("mopidy.core.playback.PlaybackController._on_tracklist_change") def test_on_tracklist_change_gets_called(self, change_mock): self.tracklist.add([Track()]).get() change_mock.assert_called_once_with() @populate_tracklist def test_on_tracklist_change_when_playing(self): self.playback.play().get() current_track = self.playback.get_current_track().get() self.tracklist.add([self.tracks[2]]) self.assert_state_is(PlaybackState.PLAYING) self.assert_current_track_is(current_track) @populate_tracklist def test_on_tracklist_change_when_stopped(self): self.tracklist.add([self.tracks[2]]) self.assert_state_is(PlaybackState.STOPPED) self.assert_current_track_is(None) @populate_tracklist def test_on_tracklist_change_when_paused(self): self.playback.play().get() self.playback.pause() current_track = self.playback.get_current_track().get() self.tracklist.add([self.tracks[2]]) self.assert_state_is(PlaybackState.PAUSED) self.assert_current_track_is(current_track) @populate_tracklist def test_pause_when_stopped(self): self.playback.pause() self.assert_state_is(PlaybackState.PAUSED) @populate_tracklist def test_pause_when_playing(self): self.playback.play().get() self.playback.pause() self.assert_state_is(PlaybackState.PAUSED) @populate_tracklist def test_pause_when_paused(self): self.playback.play().get() self.playback.pause() self.playback.pause() self.assert_state_is(PlaybackState.PAUSED) @populate_tracklist def test_pause_return_value(self): self.playback.play().get() assert self.playback.pause().get() is None @populate_tracklist def test_resume_when_stopped(self): self.playback.resume() self.assert_state_is(PlaybackState.STOPPED) @populate_tracklist def test_resume_when_playing(self): self.playback.play().get() self.playback.resume() self.assert_state_is(PlaybackState.PLAYING) @populate_tracklist def test_resume_when_paused(self): self.playback.play().get() self.playback.pause() self.playback.resume() self.assert_state_is(PlaybackState.PLAYING) @populate_tracklist def test_resume_return_value(self): self.playback.play().get() self.playback.pause() assert self.playback.resume().get() is None @unittest.SkipTest # Uses sleep and might not work with LocalBackend @populate_tracklist def test_resume_continues_from_right_position(self): self.playback.play().get() time.sleep(0.2) self.playback.pause() self.playback.resume() assert self.playback.get_time_position() != 0 @populate_tracklist def test_seek_when_stopped(self): result = self.playback.seek(1000) assert result @unittest.SkipTest # tkem doesn't know what's going on here @populate_tracklist def test_seek_when_stopped_updates_position(self): self.playback.seek(1000).get() position = self.playback.get_time_position() assert position >= 990 def test_seek_on_empty_playlist(self): assert not self.playback.seek(0).get() def test_seek_on_empty_playlist_updates_position(self): self.playback.seek(0).get() self.assert_state_is(PlaybackState.STOPPED) @populate_tracklist def test_seek_when_stopped_triggers_play(self): self.playback.seek(0).get() self.assert_state_is(PlaybackState.PLAYING) @populate_tracklist def test_seek_when_playing(self): self.playback.play().get() result = self.playback.seek(self.tracks[0].length - 1000) assert result @populate_tracklist def test_seek_when_playing_updates_position(self): length = self.tracks[0].length self.playback.play().get() self.playback.seek(length - 1000).get() position = self.playback.get_time_position().get() assert position >= (length - 1010) @populate_tracklist def test_seek_when_paused(self): self.playback.play().get() self.playback.pause() result = self.playback.seek(self.tracks[0].length - 1000) assert result self.assert_state_is(PlaybackState.PAUSED) @populate_tracklist def test_seek_when_paused_updates_position(self): length = self.tracks[0].length self.playback.play().get() self.playback.pause() self.playback.seek(length - 1000) position = self.playback.get_time_position().get() assert position >= (length - 1010) @unittest.SkipTest @populate_tracklist def test_seek_beyond_end_of_song(self): # FIXME need to decide return value self.playback.play().get() result = self.playback.seek(self.tracks[0].length * 100) assert not result @populate_tracklist def test_seek_beyond_end_of_song_jumps_to_next_song(self): self.playback.play().get() self.playback.seek(self.tracks[0].length * 100).get() self.assert_current_track_is(self.tracks[1]) @populate_tracklist def test_seek_beyond_end_of_song_for_last_track(self): self.playback.play(self.tl_tracks.get()[-1]).get() self.playback.seek(self.tracks[-1].length * 100) self.assert_state_is(PlaybackState.STOPPED) @populate_tracklist def test_stop_when_stopped(self): self.playback.stop() self.assert_state_is(PlaybackState.STOPPED) @populate_tracklist def test_stop_when_playing(self): self.playback.play().get() self.playback.stop() self.assert_state_is(PlaybackState.STOPPED) @populate_tracklist def test_stop_when_paused(self): self.playback.play().get() self.playback.pause() self.playback.stop() self.assert_state_is(PlaybackState.STOPPED) def test_stop_return_value(self): self.playback.play().get() assert self.playback.stop().get() is None def test_time_position_when_stopped(self): assert self.playback.get_time_position().get() == 0 @populate_tracklist def test_time_position_when_stopped_with_playlist(self): assert self.playback.get_time_position().get() == 0 @unittest.SkipTest # Uses sleep and does might not work with LocalBackend @populate_tracklist def test_time_position_when_playing(self): self.playback.play().get() first = self.playback.get_time_position().get() time.sleep(1) second = self.playback.get_time_position().get() assert second > first @populate_tracklist def test_time_position_when_paused(self): self.playback.play().get() self.playback.pause().get() first = self.playback.get_time_position().get() second = self.playback.get_time_position().get() assert first == second @populate_tracklist def test_play_with_consume(self): self.tracklist.set_consume(True) self.playback.play().get() self.assert_current_track_is(self.tracks[0]) @populate_tracklist def test_playlist_is_empty_after_all_tracks_are_played_with_consume(self): self.tracklist.set_consume(True) self.playback.play().get() for _ in self.tracks: self.trigger_about_to_finish() # EOS should have trigger assert len(self.tracklist.get_tracks().get()) == 0 @populate_tracklist @mock.patch("random.shuffle") def test_play_with_random(self, shuffle_mock): shuffle_mock.side_effect = lambda tracks: tracks.reverse() self.tracklist.set_random(True) self.playback.play().get() self.assert_current_track_is(self.tracks[-1]) @populate_tracklist @mock.patch("random.shuffle") def test_previous_with_random(self, shuffle_mock): shuffle_mock.side_effect = lambda tracks: tracks.reverse() self.tracklist.set_random(True) self.playback.play().get() self.playback.next().get() current_track = self.playback.get_current_track().get() self.playback.previous() self.assert_current_track_is(current_track) @populate_tracklist def test_end_of_song_starts_next_track(self): self.playback.play().get() self.trigger_about_to_finish() self.assert_current_track_is(self.tracks[1]) @populate_tracklist def test_end_of_song_with_single_and_repeat_starts_same(self): self.tracklist.set_single(True) self.tracklist.set_repeat(True) self.playback.play().get() self.assert_current_track_is(self.tracks[0]) self.trigger_about_to_finish() self.assert_current_track_is(self.tracks[0]) @populate_tracklist def test_end_of_song_with_single_random_and_repeat_starts_same(self): self.tracklist.set_single(True) self.tracklist.set_repeat(True) self.tracklist.set_random(True) self.playback.play().get() current_track = self.playback.get_current_track().get() self.trigger_about_to_finish() self.assert_current_track_is(current_track) @populate_tracklist def test_end_of_song_with_single_stops(self): self.tracklist.set_single(True) self.playback.play().get() self.assert_current_track_is(self.tracks[0]) self.trigger_about_to_finish() self.assert_current_track_is(None) # EOS should have triggered self.assert_state_is(PlaybackState.STOPPED) @populate_tracklist def test_end_of_song_with_single_and_random_stops(self): self.tracklist.set_single(True) self.tracklist.set_random(True) self.playback.play().get() self.trigger_about_to_finish() # EOS should have triggered self.assert_current_track_is(None) self.assert_state_is(PlaybackState.STOPPED) @populate_tracklist def test_end_of_playlist_stops(self): self.playback.play(self.tl_tracks.get()[-1]).get() self.trigger_about_to_finish() # EOS should have triggered self.assert_state_is(PlaybackState.STOPPED) def test_repeat_off_by_default(self): assert self.tracklist.get_repeat().get() is False def test_random_off_by_default(self): assert self.tracklist.get_random().get() is False def test_consume_off_by_default(self): assert self.tracklist.get_consume().get() is False @populate_tracklist def test_random_until_end_of_playlist(self): self.tracklist.set_random(True) self.playback.play().get() for _ in self.tracks[1:]: self.playback.next().get() self.assert_next_tl_track_is(None) @populate_tracklist def test_random_with_eot_until_end_of_playlist(self): self.tracklist.set_random(True) self.playback.play().get() for _ in self.tracks[1:]: self.trigger_about_to_finish() self.assert_eot_tl_track_is(None) @populate_tracklist def test_random_until_end_of_playlist_and_play_from_start(self): self.tracklist.set_random(True) self.playback.play().get() for _ in self.tracks: self.playback.next().get() self.assert_next_tl_track_is_not(None) self.assert_state_is(PlaybackState.STOPPED) self.playback.play().get() self.assert_state_is(PlaybackState.PLAYING) @populate_tracklist def test_random_with_eot_until_end_of_playlist_and_play_from_start(self): self.tracklist.set_random(True) self.playback.play().get() for _ in self.tracks: self.trigger_about_to_finish() # EOS should have triggered self.assert_eot_tl_track_is_not(None) self.assert_state_is(PlaybackState.STOPPED) self.playback.play().get() self.assert_state_is(PlaybackState.PLAYING) @populate_tracklist def test_random_until_end_of_playlist_with_repeat(self): self.tracklist.set_repeat(True) self.tracklist.set_random(True) self.playback.play().get() for _ in self.tracks[1:]: self.playback.next() # noqa: B305 self.assert_next_tl_track_is_not(None) @populate_tracklist def test_played_track_during_random_not_played_again(self): self.tracklist.set_random(True) self.playback.play().get() played = [] for _ in self.tracks: track = self.playback.get_current_track().get() assert track not in played played.append(track) self.playback.next().get() @populate_tracklist @mock.patch("random.shuffle") def test_play_track_then_enable_random(self, shuffle_mock): # Covers underlying issue IssueGH17RegressionTest tests for. shuffle_mock.side_effect = lambda tracks: tracks.reverse() expected = self.tl_tracks.get()[::-1] + [None] actual = [] self.playback.play().get() self.tracklist.set_random(True) while self.playback.get_state().get() != PlaybackState.STOPPED: self.playback.next().get() actual.append(self.playback.get_current_tl_track().get()) if len(actual) > len(expected): break assert actual == expected @populate_tracklist def test_playing_track_that_isnt_in_playlist(self): with self.assertRaises(AssertionError): self.playback.play(TlTrack(17, Track())).get() Mopidy-Local-3.1.1/tests/test_schema.py0000664000175000017500000002125113577631225020242 0ustar jodaljodal00000000000000import sqlite3 import unittest from mopidy.models import Album, Artist, Ref, Track from mopidy_local import schema DBPATH = ":memory:" class SchemaTest(unittest.TestCase): artists = [ Artist(uri="local:artist:0", name="artist #0"), Artist(uri="local:artist:1", name="artist #1"), ] albums = [ Album(uri="local:album:0", name="album #0"), Album(uri="local:album:1", name="album #1", artists=[artists[0]]), Album(uri="local:album:2", name="album #2", artists=[artists[1]]), ] tracks = [ Track(uri="local:track:0", name="track #0", date="2015-03-15", genre="Rock"), Track(uri="local:track:1", name="track #1", artists=[artists[0]]), Track(uri="local:track:2", name="track #2", album=albums[0]), Track(uri="local:track:3", name="track #3", album=albums[1]), Track( uri="local:track:4", name="track #4", album=albums[2], composers=[artists[0]], performers=[artists[0]], ), ] def setUp(self): self.connection = sqlite3.connect(DBPATH, factory=schema.Connection) schema.load(self.connection) for track in self.tracks: schema.insert_track(self.connection, track) def tearDown(self): self.connection.close() self.connection = None def test_create(self): count = schema.count_tracks(self.connection) assert len(self.tracks) == count tracks = list(schema.tracks(self.connection)) assert len(self.tracks) == len(tracks) def test_list_distinct(self): self.assertCountEqual( [album.name for album in self.albums], schema.list_distinct(self.connection, "album"), ) self.assertCountEqual( [artist.name for artist in self.artists[0:2]], schema.list_distinct(self.connection, "albumartist"), ) self.assertCountEqual( [artist.name for artist in self.artists[0:1]], schema.list_distinct(self.connection, "artist"), ) self.assertCountEqual( [artist.name for artist in self.artists[0:1]], schema.list_distinct(self.connection, "composer"), ) self.assertCountEqual( [artist.name for artist in self.artists[0:1]], schema.list_distinct(self.connection, "performer"), ) self.assertCountEqual( [self.tracks[0].date], schema.list_distinct(self.connection, "date") ) self.assertCountEqual( [self.tracks[0].genre], schema.list_distinct(self.connection, "genre") ) def test_lookup_track(self): with self.connection as c: for track in self.tracks: result = schema.lookup(c, Ref.TRACK, track.uri) assert [track] == list(result) def test_lookup_album(self): with self.connection as c: result = schema.lookup(c, Ref.ALBUM, self.albums[0].uri) assert [self.tracks[2]] == list(result) result = schema.lookup(c, Ref.ALBUM, self.albums[1].uri) assert [self.tracks[3]] == list(result) result = schema.lookup(c, Ref.ALBUM, self.albums[2].uri) assert [self.tracks[4]] == list(result) def test_lookup_artist(self): with self.connection as c: result = schema.lookup(c, Ref.ARTIST, self.artists[0].uri) assert [self.tracks[1], self.tracks[3]] == list(result) result = schema.lookup(c, Ref.ARTIST, self.artists[1].uri) assert [self.tracks[4]] == list(result) @unittest.SkipTest # TODO: check indexed search def test_indexed_search(self): for results, query, filters in [ (map(lambda t: t.uri, self.tracks), [], []), ([], [("any", "none")], []), ( [self.tracks[1].uri, self.tracks[3].uri, self.tracks[4].uri], [("any", self.artists[0].name)], [], ), ( [self.tracks[3].uri], [("any", self.artists[0].name)], [{"album": self.albums[1].uri}], ), ([self.tracks[2].uri], [("album", self.tracks[2].album.name)], [],), ( [self.tracks[1].uri], [("artist", next(iter(self.tracks[1].artists)).name)], [], ), ([self.tracks[0].uri], [("track_name", self.tracks[0].name)], []), ]: for exact in (True, False): with self.connection as c: tracks = schema.search_tracks(c, query, 10, 0, exact, filters) self.assertCountEqual(results, map(lambda t: t.uri, tracks)) def test_fulltext_search(self): for results, query, filters in [ (map(lambda t: t.uri, self.tracks), [("track_name", "track")], []), ( [self.tracks[1].uri, self.tracks[3].uri], [("track_name", "track")], [ {"artist": self.artists[0].uri}, {"albumartist": self.artists[0].uri}, ], ), ]: with self.connection as c: tracks = schema.search_tracks(c, query, 10, 0, False, filters) self.assertCountEqual(results, map(lambda t: t.uri, tracks)) def test_browse_artists(self): def ref(artist): return Ref.artist(name=artist.name, uri=artist.uri) with self.connection as c: assert list(map(ref, self.artists)) == schema.browse(c, Ref.ARTIST) assert list(map(ref, self.artists)) == schema.browse( c, Ref.ARTIST, role=["artist", "albumartist"] ) assert list(map(ref, self.artists[0:1])) == schema.browse( c, Ref.ARTIST, role="artist" ) assert list(map(ref, self.artists[0:1])) == schema.browse( c, Ref.ARTIST, role="composer" ) assert list(map(ref, self.artists[0:1])) == schema.browse( c, Ref.ARTIST, role="performer" ) assert list(map(ref, self.artists)) == schema.browse( c, Ref.ARTIST, role="albumartist" ) def test_browse_albums(self): def ref(album): return Ref.album(name=album.name, uri=album.uri) with self.connection as c: assert list(map(ref, self.albums)) == schema.browse(c, Ref.ALBUM) assert list(map(ref, [])) == schema.browse( c, Ref.ALBUM, artist=self.artists[0].uri ) assert list(map(ref, self.albums[1:2])) == schema.browse( c, Ref.ALBUM, albumartist=self.artists[0].uri ) def test_browse_tracks(self): def ref(track): return Ref.track(name=track.name, uri=track.uri) with self.connection as c: assert list(map(ref, self.tracks)) == schema.browse(c, Ref.TRACK) assert list(map(ref, self.tracks[1:2])) == schema.browse( c, Ref.TRACK, artist=self.artists[0].uri ) assert list(map(ref, self.tracks[2:3])) == schema.browse( c, Ref.TRACK, album=self.albums[0].uri ) assert list(map(ref, self.tracks[3:4])) == schema.browse( c, Ref.TRACK, albumartist=self.artists[0].uri ) assert list(map(ref, self.tracks[4:5])) == schema.browse( c, Ref.TRACK, composer=self.artists[0].uri, performer=self.artists[0].uri, ) def test_delete(self): c = self.connection schema.delete_track(c, self.tracks[0].uri) schema.cleanup(c) assert 3 == len(c.execute("SELECT * FROM album").fetchall()) assert 2 == len(c.execute("SELECT * FROM artist").fetchall()) schema.delete_track(c, self.tracks[1].uri) schema.cleanup(c) assert 3 == len(c.execute("SELECT * FROM album").fetchall()) assert 2 == len(c.execute("SELECT * FROM artist").fetchall()) schema.delete_track(c, self.tracks[2].uri) schema.cleanup(c) assert 2 == len(c.execute("SELECT * FROM album").fetchall()) assert 2 == len(c.execute("SELECT * FROM artist").fetchall()) schema.delete_track(c, self.tracks[3].uri) schema.cleanup(c) assert 1 == len(c.execute("SELECT * FROM album").fetchall()) assert 2 == len(c.execute("SELECT * FROM artist").fetchall()) schema.delete_track(c, self.tracks[4].uri) schema.cleanup(c) assert 0 == len(c.execute("SELECT * FROM album").fetchall()) assert 0 == len(c.execute("SELECT * FROM artist").fetchall()) Mopidy-Local-3.1.1/tests/test_tracklist.py0000664000175000017500000003030413577631225021001 0ustar jodaljodal00000000000000import random import unittest import pykka from mopidy import core from mopidy.core import PlaybackState from mopidy.models import Playlist, Track from mopidy_local import actor from tests import dummy_audio, generate_song, path_to_data_dir, populate_tracklist class LocalTracklistProviderTest(unittest.TestCase): config = { "core": {"data_dir": path_to_data_dir(""), "max_tracklist_length": 10000}, "local": { "media_dir": path_to_data_dir(""), "directories": [], "timeout": 10, "use_artist_sortname": False, "album_art_files": [], }, } tracks = [Track(uri=generate_song(i), length=4464) for i in range(1, 4)] def setUp(self): # noqa: N802 self.audio = dummy_audio.create_proxy() self.backend = actor.LocalBackend.start( config=self.config, audio=self.audio ).proxy() self.core = core.Core.start( audio=self.audio, backends=[self.backend], config=self.config ).proxy() self.controller = self.core.tracklist self.playback = self.core.playback assert len(self.tracks) == 3, "Need three tracks to run tests." def tearDown(self): # noqa: N802 pykka.ActorRegistry.stop_all() def assert_state_is(self, state): assert self.playback.get_state().get() == state def assert_current_track_is(self, track): assert self.playback.get_current_track().get() == track def test_length(self): assert 0 == len(self.controller.get_tl_tracks().get()) assert 0 == self.controller.get_length().get() self.controller.add(self.tracks) assert 3 == len(self.controller.get_tl_tracks().get()) assert 3 == self.controller.get_length().get() def test_add(self): for track in self.tracks: added = self.controller.add([track]).get() tracks = self.controller.get_tracks().get() tl_tracks = self.controller.get_tl_tracks().get() assert track == tracks[(-1)] assert added[0] == tl_tracks[(-1)] assert track == added[0].track def test_add_at_position(self): for track in self.tracks[:-1]: added = self.controller.add([track], 0).get() tracks = self.controller.get_tracks().get() tl_tracks = self.controller.get_tl_tracks().get() assert track == tracks[0] assert added[0] == tl_tracks[0] assert track == added[0].track @populate_tracklist def test_add_at_position_outside_of_playlist(self): for track in self.tracks: added = self.controller.add([track], len(self.tracks) + 2).get() tracks = self.controller.get_tracks().get() tl_tracks = self.controller.get_tl_tracks().get() assert track == tracks[(-1)] assert added[0] == tl_tracks[(-1)] assert track == added[0].track @populate_tracklist def test_filter_by_tlid(self): tl_track = self.controller.get_tl_tracks().get()[1] result = self.controller.filter({"tlid": [tl_track.tlid]}).get() assert [tl_track] == result @populate_tracklist def test_filter_by_uri(self): tl_track = self.controller.get_tl_tracks().get()[1] result = self.controller.filter({"uri": [tl_track.track.uri]}).get() assert [tl_track] == result @populate_tracklist def test_filter_by_uri_returns_nothing_for_invalid_uri(self): assert [] == self.controller.filter({"uri": ["foobar"]}).get() def test_filter_by_uri_returns_single_match(self): t = Track(uri="a") self.controller.add([Track(uri="z"), t, Track(uri="y")]) result = self.controller.filter({"uri": ["a"]}).get() assert t == result[0].track def test_filter_by_uri_returns_multiple_matches(self): track = Track(uri="a") self.controller.add([Track(uri="z"), track, track]) tl_tracks = self.controller.filter({"uri": ["a"]}).get() assert track == tl_tracks[0].track assert track == tl_tracks[1].track def test_filter_by_uri_returns_nothing_if_no_match(self): self.controller.playlist = Playlist(tracks=[Track(uri="z"), Track(uri="y")]) assert [] == self.controller.filter({"uri": ["a"]}).get() def test_filter_by_multiple_criteria_returns_elements_matching_all(self): t1 = Track(uri="a", name="x") t2 = Track(uri="b", name="x") t3 = Track(uri="b", name="y") self.controller.add([t1, t2, t3]) result1 = self.controller.filter({"uri": ["a"], "name": ["x"]}).get() assert t1 == result1[0].track result2 = self.controller.filter({"uri": ["b"], "name": ["x"]}).get() assert t2 == result2[0].track result3 = self.controller.filter({"uri": ["b"], "name": ["y"]}).get() assert t3 == result3[0].track def test_filter_by_criteria_that_is_not_present_in_all_elements(self): track1 = Track() track2 = Track(uri="b") track3 = Track() self.controller.add([track1, track2, track3]) result = self.controller.filter({"uri": ["b"]}).get() assert track2 == result[0].track @populate_tracklist def test_clear(self): self.controller.clear().get() assert len(self.controller.get_tracks().get()) == 0 def test_clear_empty_playlist(self): self.controller.clear().get() assert len(self.controller.get_tracks().get()) == 0 @populate_tracklist def test_clear_when_playing(self): self.playback.play().get() self.assert_state_is(PlaybackState.PLAYING) self.controller.clear().get() self.assert_state_is(PlaybackState.STOPPED) def test_add_appends_to_the_tracklist(self): self.controller.add([Track(uri="a"), Track(uri="b")]) tracks = self.controller.get_tracks().get() assert len(tracks) == 2 self.controller.add([Track(uri="c"), Track(uri="d")]) tracks = self.controller.get_tracks().get() assert len(tracks) == 4 assert tracks[0].uri == "a" assert tracks[1].uri == "b" assert tracks[2].uri == "c" assert tracks[3].uri == "d" def test_add_does_not_reset_version(self): version = self.controller.get_version().get() self.controller.add([]) assert self.controller.get_version().get() == version @populate_tracklist def test_add_preserves_playing_state(self): self.playback.play().get() track = self.playback.get_current_track().get() tracks = self.controller.get_tracks().get() self.controller.add(tracks[1:2]).get() self.assert_state_is(PlaybackState.PLAYING) self.assert_current_track_is(track) @populate_tracklist def test_add_preserves_stopped_state(self): tracks = self.controller.get_tracks().get() self.controller.add(tracks[1:2]).get() self.assert_state_is(PlaybackState.STOPPED) self.assert_current_track_is(None) @populate_tracklist def test_add_returns_the_tl_tracks_that_was_added(self): tracks = self.controller.get_tracks().get() added = self.controller.add(tracks[1:2]).get() tracks = self.controller.get_tracks().get() assert added[0].track == tracks[1] @populate_tracklist def test_move_single(self): self.controller.move(0, 0, 2) tracks = self.controller.get_tracks().get() assert tracks[2] == self.tracks[0] @populate_tracklist def test_move_group(self): self.controller.move(0, 2, 1) tracks = self.controller.get_tracks().get() assert tracks[1] == self.tracks[0] assert tracks[2] == self.tracks[1] @populate_tracklist def test_moving_track_outside_of_playlist(self): num_tracks = len(self.controller.get_tracks().get()) with self.assertRaises(AssertionError): self.controller.move(0, 0, num_tracks + 5).get() @populate_tracklist def test_move_group_outside_of_playlist(self): num_tracks = len(self.controller.get_tracks().get()) with self.assertRaises(AssertionError): self.controller.move(0, 2, num_tracks + 5).get() @populate_tracklist def test_move_group_out_of_range(self): num_tracks = len(self.controller.get_tracks().get()) with self.assertRaises(AssertionError): self.controller.move(num_tracks + 2, num_tracks + 3, 0).get() @populate_tracklist def test_move_group_invalid_group(self): with self.assertRaises(AssertionError): self.controller.move(2, 1, 0).get() def test_tracks_attribute_is_immutable(self): tracks1 = self.controller.get_tracks().get() tracks2 = self.controller.get_tracks().get() assert id(tracks1) != id(tracks2) @populate_tracklist def test_remove(self): track1 = self.controller.get_tracks().get()[1] track2 = self.controller.get_tracks().get()[2] version = self.controller.get_version().get() self.controller.remove({"uri": [track1.uri]}) assert version < self.controller.get_version().get() assert track1 not in self.controller.get_tracks().get() assert track2 == self.controller.get_tracks().get()[1] @populate_tracklist def test_removing_track_that_does_not_exist_does_nothing(self): self.controller.remove({"uri": ["/nonexistant"]}).get() def test_removing_from_empty_playlist_does_nothing(self): self.controller.remove({"uri": ["/nonexistant"]}).get() @populate_tracklist def test_remove_lists(self): version = self.controller.get_version().get() tracks = self.controller.get_tracks().get() track0 = tracks[0] track1 = tracks[1] track2 = tracks[2] self.controller.remove({"uri": [track0.uri, track2.uri]}) tracks = self.controller.get_tracks().get() assert version < self.controller.get_version().get() assert track0 not in tracks assert track2 not in tracks assert track1 == tracks[0] @populate_tracklist def test_shuffle(self): random.seed(1) self.controller.shuffle() shuffled_tracks = self.controller.get_tracks().get() assert self.tracks != shuffled_tracks assert set(self.tracks) == set(shuffled_tracks) @populate_tracklist def test_shuffle_subset(self): random.seed(1) self.controller.shuffle(1, 3) shuffled_tracks = self.controller.get_tracks().get() assert self.tracks != shuffled_tracks assert self.tracks[0] == shuffled_tracks[0] assert set(self.tracks) == set(shuffled_tracks) @populate_tracklist def test_shuffle_invalid_subset(self): with self.assertRaises(AssertionError): self.controller.shuffle(3, 1).get() @populate_tracklist def test_shuffle_superset(self): num_tracks = len(self.controller.get_tracks().get()) with self.assertRaises(AssertionError): self.controller.shuffle(1, num_tracks + 5).get() @populate_tracklist def test_shuffle_open_subset(self): random.seed(1) self.controller.shuffle(1) shuffled_tracks = self.controller.get_tracks().get() assert self.tracks != shuffled_tracks assert self.tracks[0] == shuffled_tracks[0] assert set(self.tracks) == set(shuffled_tracks) @populate_tracklist def test_slice_returns_a_subset_of_tracks(self): track_slice = self.controller.slice(1, 3).get() assert 2 == len(track_slice) assert self.tracks[1] == track_slice[0].track assert self.tracks[2] == track_slice[1].track @populate_tracklist def test_slice_returns_empty_list_if_indexes_outside_tracks_list(self): assert 0 == len(self.controller.slice(7, 8).get()) assert 0 == len(self.controller.slice((-1), 1).get()) def test_version_does_not_change_when_adding_nothing(self): version = self.controller.get_version().get() self.controller.add([]) assert version == self.controller.get_version().get() def test_version_increases_when_adding_something(self): version = self.controller.get_version().get() self.controller.add([Track()]) assert version < self.controller.get_version().get() Mopidy-Local-3.1.1/tests/test_translator.py0000664000175000017500000000641313614770065021174 0ustar jodaljodal00000000000000import os import pathlib import pytest from mopidy_local import translator @pytest.mark.parametrize( "local_uri,file_uri", [ ("local:directory:A/B", "file:///home/alice/Music/A/B"), ("local:directory:A%20B", "file:///home/alice/Music/A%20B"), ("local:directory:A+B", "file:///home/alice/Music/A%2BB"), ( "local:directory:%C3%A6%C3%B8%C3%A5", "file:///home/alice/Music/%C3%A6%C3%B8%C3%A5", ), ("local:track:A/B.mp3", "file:///home/alice/Music/A/B.mp3"), ("local:track:A%20B.mp3", "file:///home/alice/Music/A%20B.mp3"), ("local:track:A+B.mp3", "file:///home/alice/Music/A%2BB.mp3"), ( "local:track:%C3%A6%C3%B8%C3%A5.mp3", "file:///home/alice/Music/%C3%A6%C3%B8%C3%A5.mp3", ), ], ) def test_local_uri_to_file_uri(local_uri, file_uri): media_dir = pathlib.Path("/home/alice/Music") assert translator.local_uri_to_file_uri(local_uri, media_dir) == file_uri @pytest.mark.parametrize("uri", ["A/B", "local:foo:A/B"]) def test_local_uri_to_file_uri_errors(uri): media_dir = pathlib.Path("/home/alice/Music") with pytest.raises(ValueError): translator.local_uri_to_file_uri(uri, media_dir) @pytest.mark.parametrize( "uri,path", [ ("local:directory:A/B", b"/home/alice/Music/A/B"), ("local:directory:A%20B", b"/home/alice/Music/A B"), ("local:directory:A+B", b"/home/alice/Music/A+B"), ( "local:directory:%C3%A6%C3%B8%C3%A5", b"/home/alice/Music/\xc3\xa6\xc3\xb8\xc3\xa5", ), ("local:track:A/B.mp3", b"/home/alice/Music/A/B.mp3"), ("local:track:A%20B.mp3", b"/home/alice/Music/A B.mp3"), ("local:track:A+B.mp3", b"/home/alice/Music/A+B.mp3"), ( "local:track:%C3%A6%C3%B8%C3%A5.mp3", b"/home/alice/Music/\xc3\xa6\xc3\xb8\xc3\xa5.mp3", ), ], ) def test_local_uri_to_path(uri, path): media_dir = pathlib.Path("/home/alice/Music") result = translator.local_uri_to_path(uri, media_dir) assert isinstance(result, pathlib.Path) assert bytes(result) == path @pytest.mark.parametrize("uri", ["A/B", "local:foo:A/B"]) def test_local_uri_to_path_errors(uri): media_dir = pathlib.Path("/home/alice/Music") with pytest.raises(ValueError): translator.local_uri_to_path(uri, media_dir) @pytest.mark.parametrize( "path,uri", [ ("/foo", "file:///foo"), (b"/foo", "file:///foo"), ("/æøå", "file:///%C3%A6%C3%B8%C3%A5"), (b"/\x00\x01\x02", "file:///%00%01%02"), (pathlib.Path("/æøå"), "file:///%C3%A6%C3%B8%C3%A5"), ], ) def test_path_to_file_uri(path, uri): assert translator.path_to_file_uri(path) == uri @pytest.mark.parametrize( "path,uri", [ (pathlib.Path("foo"), "local:track:foo"), (pathlib.Path("/home/alice/Music/foo"), "local:track:foo"), (pathlib.Path("æøå"), "local:track:%C3%A6%C3%B8%C3%A5"), (pathlib.Path(os.fsdecode(b"\x00\x01\x02")), "local:track:%00%01%02"), ], ) def test_path_to_local_track_uri(path, uri): media_dir = pathlib.Path("/home/alice/Music") result = translator.path_to_local_track_uri(path, media_dir) assert isinstance(result, str) assert result == uri Mopidy-Local-3.1.1/tox.ini0000664000175000017500000000073613577631225015547 0ustar jodaljodal00000000000000[tox] envlist = py37, py38, black, check-manifest, flake8 [testenv] sitepackages = true deps = .[test] commands = python -m pytest \ --basetemp={envtmpdir} \ --cov=mopidy_local --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