././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1787172809.7922263 mopidy_local-4.0.1/0000755000175100017510000000000015241413712013635 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1787172803.034055 mopidy_local-4.0.1/.copier-answers.yml0000644000175100017510000000042615241413703017401 0ustar00runnerrunner_commit: v2.4.0 _src_path: gh:mopidy/mopidy-ext-template author_email: stein.magnus@jodal.no author_full_name: Stein Magnus Jodal dist_name: mopidy-local ext_name: local github_username: mopidy short_description: Mopidy extension for playing music from your local music archive ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1787172809.7831173 mopidy_local-4.0.1/.github/0000755000175100017510000000000015241413712015175 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1787172809.7856874 mopidy_local-4.0.1/.github/workflows/0000755000175100017510000000000015241413712017232 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1787172803.0350504 mopidy_local-4.0.1/.github/workflows/ci.yml0000644000175100017510000000460615241413703020356 0ustar00runnerrunnername: CI on: pull_request: push: branches: - main workflow_dispatch: # No permissions by default; each job requests what it needs. permissions: {} concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: build: name: Build runs-on: ubuntu-24.04 permissions: contents: read # Check out the repository. steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - uses: hynek/build-and-inspect-python-package@2abe76da66d0a6a4a227101f9348ee855797cfa5 # v3.0.1 main: strategy: fail-fast: false matrix: include: - name: "pytest (3.13)" python: "3.13" tox: "3.13" - name: "pytest (3.14)" python: "3.14" tox: "3.14" coverage: true - name: "pyright" python: "3.14" tox: "pyright" - name: "ruff check" python: "3.14" tox: "ruff-check" - name: "ruff format" python: "3.14" tox: "ruff-format" name: ${{ matrix.name }} runs-on: ubuntu-24.04 # Deliberately unpinned: this is our own image, and we always want the # latest version of it. container: ghcr.io/mopidy/ci:latest # zizmor: ignore[unpinned-images] permissions: contents: read # Check out the repository. steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Fix home dir permissions to enable pip caching run: chown -R root /github/home - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: ${{ matrix.python }} cache: pip allow-prereleases: true - run: python -m pip install tox - env: TOX_ENV: ${{ matrix.tox }} run: python -m tox -e "$TOX_ENV" if: ${{ ! matrix.coverage }} - env: TOX_ENV: ${{ matrix.tox }} run: python -m tox -e "$TOX_ENV" -- --cov-report=xml if: ${{ matrix.coverage }} - uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 if: ${{ matrix.coverage }} with: token: ${{ secrets.CODECOV_TOKEN }} ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1787172803.0351396 mopidy_local-4.0.1/.github/workflows/release.yml0000644000175100017510000000167415241413703021405 0ustar00runnerrunnername: Release on: release: types: [published] # No permissions by default; each job requests what it needs. permissions: {} jobs: release: name: Release runs-on: ubuntu-24.04 environment: name: pypi url: https://pypi.org/project/mopidy-local/ permissions: contents: read # Check out the repository. id-token: write # Authenticate with PyPI using trusted publishing. steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - uses: hynek/build-and-inspect-python-package@2abe76da66d0a6a4a227101f9348ee855797cfa5 # v3.0.1 id: build - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: ${{ steps.build.outputs.artifact-name }} path: dist - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1787172803.0351396 mopidy_local-4.0.1/.gitignore0000644000175100017510000000012515241413703015623 0ustar00runnerrunner*.egg-info/ /*.lock /.*_cache/ /.coverage /.tox/ /.venv/ /build/ /dist/ __pycache__/ ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1787172803.0351396 mopidy_local-4.0.1/LICENSE0000644000175100017510000002613615241413703014652 0ustar00runnerrunner Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1787172809.792189 mopidy_local-4.0.1/PKG-INFO0000644000175100017510000003103515241413712014734 0ustar00runnerrunnerMetadata-Version: 2.4 Name: mopidy-local Version: 4.0.1 Summary: Mopidy extension for playing music from your local music archive Author-email: Stein Magnus Jodal License: Apache-2.0 Project-URL: Homepage, https://github.com/mopidy/mopidy-local Classifier: Environment :: No Input/Output (Daemon) Classifier: Intended Audience :: End Users/Desktop Classifier: License :: OSI Approved :: Apache Software License Classifier: Operating System :: OS Independent Classifier: Topic :: Multimedia :: Sound/Audio :: Players Requires-Python: >=3.13 Description-Content-Type: text/markdown License-File: LICENSE Requires-Dist: mopidy>=4.0.2 Requires-Dist: pykka>=4.1 Requires-Dist: uritools>=4.0.3 Dynamic: license-file # mopidy-local [![Latest PyPI version](https://img.shields.io/pypi/v/mopidy-local)](https://pypi.org/p/mopidy-local) [![CI build status](https://img.shields.io/github/actions/workflow/status/mopidy/mopidy-local/ci.yml)](https://github.com/mopidy/mopidy-local/actions/workflows/ci.yml) [![Test coverage](https://img.shields.io/codecov/c/gh/mopidy/mopidy-local)](https://codecov.io/gh/mopidy/mopidy-local) [Mopidy](https://mopidy.com/) extension for playing music from your local music archive. ## Table of contents - [Maintainer wanted](#maintainer-wanted) - [Installation](#installation) - [Configuration](#configuration) - [Usage](#usage) - [Generating a library](#generating-a-library) - [Updating the library](#updating-the-library) - [Clearing the library](#clearing-the-library) - [Library layout](#library-layout) - [Project resources](#project-resources) - [Development](#development) - [Credits](#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: ```sh 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: ```ini [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/max_search_results`: Number of search results that should be returned. Default is 100. - `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. See below. - `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: 1. Ensure that the `local/media_dir` config value points to where your music is located. Check the current setting by running: mopidy config 2. Scan your media library.: mopidy local scan 3. 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: ```sh mopidy local scan ``` Options can be specified to control the behavior of the scan command: - `--force`: Force rescan of all media files - `--limit `: Maximum number of tracks to scan Example: ```sh mopidy local scan --limit 50 ``` ## Clearing the library To delete your local images and clear your local library: ```sh mopidy local clear ``` A prompt will ask you to confirm this irreversible operation. ## Library layout The exposed library has a root directory and nine top-level directories defined under the root directory: - Albums - Artists - Composers - Genres - Performers - Release Years - Tracks - Last Week's Updates - Last Month's Updates This can be configured through the `directories` setting. It's expected to be a list of space separated name and URI supported for browsing, eg: ```ini [local] # ... other config... directories = Albums local:directory?type=album Artists local:directory?type=artist Composers local:directory?type=artist&role=composer Tracks local:directory?type=track Last Week's Updates local:directory?max-age=604800 ``` ### URIs supported for browsing _Remember that URIs are opaque values that neither Mopidy's core layer or Mopidy frontends should attempt to derive any meaning from._ That said, it's necessary to have a sufficient knowledge of Mopidy-Local URIs to customize the `directories` setting properly: - Browsing URIs starting with `local:artist` returns references to albums and tracks with the given artist. - Browsing URIs starting with `local:album` returns references to the album tracks. - Browsing URIs starting with `local:track` is not supported. Other URIs supported for browsing start with `local:directory`. The returned references are specified through "query parameters": - `local:directory`: References to the top levels directories. - `local:directory?type=tracks`: References all tracks. Multiple parameters can be added to filter the referenced tracks: `album`, `albumartist`, `artist`, `composer`, `date`, `genre`, `performer`, and `max-age`. - `local:directory?type=date`: References to directories grouping tracks by date and album. Dates are transformed according to the optional parameter `FORMAT` which default to `%Y-%m-%d`. The URIs of the references start with `local:directory?date=`. - `local:directory?type=genre`: References to directories named after genres found among all tracks. Their URIs start with `local:directory?genre=`. - `local:directory?type=album`: References to all albums. - `local:directory?type=album&PARAM=VALUE`: References to directories grouping tracks matching the given criteria. `PARAM` must be one of `albumartist`, `artist`, `composer`, `date`, `genre`, `performer`, `max-age`. The referenced directories group the selected tracks by album; Their URIs start with `local:directory?PARAM=VALUE&type=track&album=local:album:`. - `local:directory?type=artist`: References to all artists. - `local:directory?type=artist&role=ROLE`: References to directories with URIs `local:directory?ROLE=URI` where `URI` varies among all URIs starting with `local:artist` build from all tracks tag corresponding to `ROLE`. `ROLE` is one of `albumartist`, `artist`, `composer`, or `performer`. - `local:directory?album=URI`: A reference to a directory grouping the tracks of the album with given URI. Its URI starts with `local:directory?album=URI&type=track`. - `local:directory?albumartist=URI`: References to directories grouping tracks whose albumartist tag has given URI. The referenced directories group the selected tracks by album; Their URIs start with `local:directory?albumartist=URI&type=track&album=local:album:`. - `local:directory?artist=URI`: References to directories grouping tracks whose artist has given URI. The referenced directories group the selected tracks by album; Their URIs start with `local:directory?artist=URI&type=track&album=local:album:`. - `local:directory?composer=URI`: References to directories grouping tracks whose composer has given URI. The referenced directories group the selected tracks by album; Their URIs start with `local:directory?composer=URI&type=track&album=local:album:`. - `local:directory?date=DATE`: References to directories grouping tracks whose date match DATE. The referenced directories group the selected tracks by album; Their URIs start with `local:directory?date=DATE&type=track&album=local:album:`. The match is to be interpreted as in a `LIKE` SQL statement. - `local:directory?genre=GENRE`: References to directories grouping tracks whose genre is GENRE. The referenced directories group the selected tracks by album; Their URIs start with `local:directory?genre=GENRE&type=track&album=local:album:`. - `local:directory?performer=URI`: References to directories grouping tracks whose performer has given URI. The referenced directories group the selected tracks by album; Their URIs start with `local:directory?performer=URI&type=track&album=local:album:`. - `local:directory?max-age=SECONDS`: References to directories grouping tracks whose \"last modified\" date is newer than SECONDS seconds. The referenced directories group the selected tracks by album; Their URIs start with `local:directory?max-age=SECONDS&type=track&album=local:album:`. ## Project resources - [Source code](https://github.com/mopidy/mopidy-local) - [Issues](https://github.com/mopidy/mopidy-local/issues) - [Releases](https://github.com/mopidy/mopidy-local/releases) ## Development ### Set up development environment Clone the repo using, e.g. using [gh](https://cli.github.com/): ```sh gh repo clone mopidy/mopidy-local ``` Enter the directory, and install dependencies using [uv](https://docs.astral.sh/uv/): ```sh cd mopidy-local/ uv sync ``` ### Running tests To run all tests and linters in isolated environments, use [tox](https://tox.wiki/): ```sh tox ``` To only run tests, use [pytest](https://pytest.org/): ```sh pytest ``` To format the code, use [ruff](https://docs.astral.sh/ruff/): ```sh ruff format . ``` To check for lints with ruff, run: ```sh ruff check . ``` To check for type errors, use [pyright](https://microsoft.github.io/pyright/): ```sh pyright . ``` ### Making a release To make a release to PyPI, go to the project's [GitHub releases page](https://github.com/mopidy/mopidy-local/releases) and click the "Draft a new release" button. In the "choose a tag" dropdown, select the tag you want to release or create a new tag, e.g. `v0.1.0`. Add a title, e.g. `v0.1.0`, and a description of the changes. Decide if the release is a pre-release (alpha, beta, or release candidate) or should be marked as the latest release, and click "Publish release". Once the release is created, the `release.yml` GitHub Action will automatically build and publish the release to [PyPI](https://pypi.org/project/mopidy-local/). ## Credits - Original authors: [Stein Magnus Jodal](https://github.com/jodal) and [Thomas Adamcik](https://github.com/adamcik) for the Mopidy-Local extension in Mopidy core. [Thomas Kemmer](https://github.com/tkem) for the SQLite storage and support for embedded album art. - Current maintainer: None. Maintainer wanted, see section above. - [Contributors](https://github.com/mopidy/mopidy-local/graphs/contributors) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1787172803.0351396 mopidy_local-4.0.1/README.md0000644000175100017510000002747615241413703015134 0ustar00runnerrunner# mopidy-local [![Latest PyPI version](https://img.shields.io/pypi/v/mopidy-local)](https://pypi.org/p/mopidy-local) [![CI build status](https://img.shields.io/github/actions/workflow/status/mopidy/mopidy-local/ci.yml)](https://github.com/mopidy/mopidy-local/actions/workflows/ci.yml) [![Test coverage](https://img.shields.io/codecov/c/gh/mopidy/mopidy-local)](https://codecov.io/gh/mopidy/mopidy-local) [Mopidy](https://mopidy.com/) extension for playing music from your local music archive. ## Table of contents - [Maintainer wanted](#maintainer-wanted) - [Installation](#installation) - [Configuration](#configuration) - [Usage](#usage) - [Generating a library](#generating-a-library) - [Updating the library](#updating-the-library) - [Clearing the library](#clearing-the-library) - [Library layout](#library-layout) - [Project resources](#project-resources) - [Development](#development) - [Credits](#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: ```sh 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: ```ini [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/max_search_results`: Number of search results that should be returned. Default is 100. - `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. See below. - `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: 1. Ensure that the `local/media_dir` config value points to where your music is located. Check the current setting by running: mopidy config 2. Scan your media library.: mopidy local scan 3. 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: ```sh mopidy local scan ``` Options can be specified to control the behavior of the scan command: - `--force`: Force rescan of all media files - `--limit `: Maximum number of tracks to scan Example: ```sh mopidy local scan --limit 50 ``` ## Clearing the library To delete your local images and clear your local library: ```sh mopidy local clear ``` A prompt will ask you to confirm this irreversible operation. ## Library layout The exposed library has a root directory and nine top-level directories defined under the root directory: - Albums - Artists - Composers - Genres - Performers - Release Years - Tracks - Last Week's Updates - Last Month's Updates This can be configured through the `directories` setting. It's expected to be a list of space separated name and URI supported for browsing, eg: ```ini [local] # ... other config... directories = Albums local:directory?type=album Artists local:directory?type=artist Composers local:directory?type=artist&role=composer Tracks local:directory?type=track Last Week's Updates local:directory?max-age=604800 ``` ### URIs supported for browsing _Remember that URIs are opaque values that neither Mopidy's core layer or Mopidy frontends should attempt to derive any meaning from._ That said, it's necessary to have a sufficient knowledge of Mopidy-Local URIs to customize the `directories` setting properly: - Browsing URIs starting with `local:artist` returns references to albums and tracks with the given artist. - Browsing URIs starting with `local:album` returns references to the album tracks. - Browsing URIs starting with `local:track` is not supported. Other URIs supported for browsing start with `local:directory`. The returned references are specified through "query parameters": - `local:directory`: References to the top levels directories. - `local:directory?type=tracks`: References all tracks. Multiple parameters can be added to filter the referenced tracks: `album`, `albumartist`, `artist`, `composer`, `date`, `genre`, `performer`, and `max-age`. - `local:directory?type=date`: References to directories grouping tracks by date and album. Dates are transformed according to the optional parameter `FORMAT` which default to `%Y-%m-%d`. The URIs of the references start with `local:directory?date=`. - `local:directory?type=genre`: References to directories named after genres found among all tracks. Their URIs start with `local:directory?genre=`. - `local:directory?type=album`: References to all albums. - `local:directory?type=album&PARAM=VALUE`: References to directories grouping tracks matching the given criteria. `PARAM` must be one of `albumartist`, `artist`, `composer`, `date`, `genre`, `performer`, `max-age`. The referenced directories group the selected tracks by album; Their URIs start with `local:directory?PARAM=VALUE&type=track&album=local:album:`. - `local:directory?type=artist`: References to all artists. - `local:directory?type=artist&role=ROLE`: References to directories with URIs `local:directory?ROLE=URI` where `URI` varies among all URIs starting with `local:artist` build from all tracks tag corresponding to `ROLE`. `ROLE` is one of `albumartist`, `artist`, `composer`, or `performer`. - `local:directory?album=URI`: A reference to a directory grouping the tracks of the album with given URI. Its URI starts with `local:directory?album=URI&type=track`. - `local:directory?albumartist=URI`: References to directories grouping tracks whose albumartist tag has given URI. The referenced directories group the selected tracks by album; Their URIs start with `local:directory?albumartist=URI&type=track&album=local:album:`. - `local:directory?artist=URI`: References to directories grouping tracks whose artist has given URI. The referenced directories group the selected tracks by album; Their URIs start with `local:directory?artist=URI&type=track&album=local:album:`. - `local:directory?composer=URI`: References to directories grouping tracks whose composer has given URI. The referenced directories group the selected tracks by album; Their URIs start with `local:directory?composer=URI&type=track&album=local:album:`. - `local:directory?date=DATE`: References to directories grouping tracks whose date match DATE. The referenced directories group the selected tracks by album; Their URIs start with `local:directory?date=DATE&type=track&album=local:album:`. The match is to be interpreted as in a `LIKE` SQL statement. - `local:directory?genre=GENRE`: References to directories grouping tracks whose genre is GENRE. The referenced directories group the selected tracks by album; Their URIs start with `local:directory?genre=GENRE&type=track&album=local:album:`. - `local:directory?performer=URI`: References to directories grouping tracks whose performer has given URI. The referenced directories group the selected tracks by album; Their URIs start with `local:directory?performer=URI&type=track&album=local:album:`. - `local:directory?max-age=SECONDS`: References to directories grouping tracks whose \"last modified\" date is newer than SECONDS seconds. The referenced directories group the selected tracks by album; Their URIs start with `local:directory?max-age=SECONDS&type=track&album=local:album:`. ## Project resources - [Source code](https://github.com/mopidy/mopidy-local) - [Issues](https://github.com/mopidy/mopidy-local/issues) - [Releases](https://github.com/mopidy/mopidy-local/releases) ## Development ### Set up development environment Clone the repo using, e.g. using [gh](https://cli.github.com/): ```sh gh repo clone mopidy/mopidy-local ``` Enter the directory, and install dependencies using [uv](https://docs.astral.sh/uv/): ```sh cd mopidy-local/ uv sync ``` ### Running tests To run all tests and linters in isolated environments, use [tox](https://tox.wiki/): ```sh tox ``` To only run tests, use [pytest](https://pytest.org/): ```sh pytest ``` To format the code, use [ruff](https://docs.astral.sh/ruff/): ```sh ruff format . ``` To check for lints with ruff, run: ```sh ruff check . ``` To check for type errors, use [pyright](https://microsoft.github.io/pyright/): ```sh pyright . ``` ### Making a release To make a release to PyPI, go to the project's [GitHub releases page](https://github.com/mopidy/mopidy-local/releases) and click the "Draft a new release" button. In the "choose a tag" dropdown, select the tag you want to release or create a new tag, e.g. `v0.1.0`. Add a title, e.g. `v0.1.0`, and a description of the changes. Decide if the release is a pre-release (alpha, beta, or release candidate) or should be marked as the latest release, and click "Publish release". Once the release is created, the `release.yml` GitHub Action will automatically build and publish the release to [PyPI](https://pypi.org/project/mopidy-local/). ## Credits - Original authors: [Stein Magnus Jodal](https://github.com/jodal) and [Thomas Adamcik](https://github.com/adamcik) for the Mopidy-Local extension in Mopidy core. [Thomas Kemmer](https://github.com/tkem) for the SQLite storage and support for embedded album art. - Current maintainer: None. Maintainer wanted, see section above. - [Contributors](https://github.com/mopidy/mopidy-local/graphs/contributors) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1787172803.0351396 mopidy_local-4.0.1/pyproject.toml0000644000175100017510000000734415241413703016561 0ustar00runnerrunner[project] name = "mopidy-local" description = "Mopidy extension for playing music from your local music archive" readme = "README.md" requires-python = ">= 3.13" license = { text = "Apache-2.0" } authors = [{ name = "Stein Magnus Jodal", email = "stein.magnus@jodal.no" }] classifiers = [ "Environment :: No Input/Output (Daemon)", "Intended Audience :: End Users/Desktop", "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", "Topic :: Multimedia :: Sound/Audio :: Players", ] dynamic = ["version"] dependencies = [ "mopidy >= 4.0.2", "pykka >= 4.1", "uritools >= 4.0.3", ] [project.urls] Homepage = "https://github.com/mopidy/mopidy-local" [project.entry-points."mopidy.ext"] local = "mopidy_local:Extension" [build-system] requires = ["setuptools >= 78", "setuptools-scm >= 8.2"] build-backend = "setuptools.build_meta" [dependency-groups] dev = [ "tox", { include-group = "ruff" }, { include-group = "tests" }, { include-group = "typing" }, ] ruff = ["ruff"] tests = ["pytest", "pytest-cov"] typing = ["pyright"] [tool.coverage.paths] source = ["src/", "*/site-packages/"] [tool.coverage.run] source_pkgs = ["mopidy_local"] [tool.coverage.report] show_missing = true [tool.pyright] pythonVersion = "3.13" typeCheckingMode = "standard" # Not all dependencies have type hints: reportMissingTypeStubs = false # Already covered by ruff's flake8-self checks: reportPrivateImportUsage = false [tool.pytest.ini_options] filterwarnings = [ # By default, fail tests on warnings from our own code "error:::mopidy_local", # # Add any warnings you want to ignore here ] [tool.ruff] target-version = "py313" [tool.ruff.lint] select = ["ALL"] ignore = [ "ANN", # flake8-annotations # TODO "ANN401", # any-type "BLE001", # blind-except # TODO "CPY", # flake8-copyright "D", # pydocstyle "FIX001", # line-contains-fixme "FIX002", # line-contains-todo "G002", # logging-percent-format "G004", # logging-f-string "ISC001", # single-line-implicit-string-concatenation "TD002", # missing-todo-author "TD003", # missing-todo-link "TRY003", # raise-vanilla-args "TRY400", # error-instead-of-exception # # Conflicting with `ruff format` "COM812", # missing-trailing-comma "ISC001", # single-line-implicit-string-concatenation ] [tool.ruff.lint.per-file-ignores] "tests/*" = [ "ANN", # flake8-annotations "ARG", # flake8-unused-arguments "D", # pydocstyle "FBT", # flake8-boolean-trap "PLR0913", # too-many-arguments "PLR2004", # magic-value-comparison "PT011", # pytest-raises-too-broad # TODO "PT027", # pytest-unittest-raises-assertion "RUF012", # mutable-class-default # TODO "S101", # assert "SLF001", # private-member-access "TRY002", # raise-vanilla-class ] [tool.setuptools.package-data] "*" = ["*.conf", "www/*", "sql/*"] [tool.setuptools_scm] # This section, even if empty, must be present for setuptools_scm to work [tool.tox] env_list = [ "3.13", "3.14", "pyright", "ruff-check", "ruff-format", ] [tool.tox.env_run_base] package = "wheel" wheel_build_env = ".pkg" dependency_groups = ["tests"] commands = [ [ "pytest", "--cov", "--basetemp={envtmpdir}", { replace = "posargs", extend = true }, ], ] [tool.tox.env.pyright] dependency_groups = ["typing"] commands = [["pyright", "{posargs:src}"]] [tool.tox.env.ruff-check] skip_install = true dependency_groups = ["ruff"] commands = [["ruff", "check", "{posargs:.}"]] [tool.tox.env.ruff-format] skip_install = true dependency_groups = ["ruff"] commands = [["ruff", "format", "--check", "--diff", "{posargs:.}"]] ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1787172809.7925224 mopidy_local-4.0.1/setup.cfg0000644000175100017510000000004615241413712015456 0ustar00runnerrunner[egg_info] tag_build = tag_date = 0 ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1787172809.7831173 mopidy_local-4.0.1/src/0000755000175100017510000000000015241413712014424 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1787172809.7875054 mopidy_local-4.0.1/src/mopidy_local/0000755000175100017510000000000015241413712017077 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1787172803.0355947 mopidy_local-4.0.1/src/mopidy_local/__init__.py0000644000175100017510000000522315241413703021212 0ustar00runnerrunnerfrom __future__ import annotations import pathlib from importlib.metadata import version from typing import TYPE_CHECKING, Any from mopidy import config, ext if TYPE_CHECKING: import cyclopts from mopidy.config import Config from mopidy.core import CoreProxy __version__ = version("Mopidy-Local") class Extension(ext.Extension): dist_name = "mopidy-local" ext_name = "local" version = __version__ def get_default_config(self) -> str: return config.read(pathlib.Path(__file__).parent / "ext.conf") def get_config_schema(self) -> config.ConfigSchema: schema = super().get_config_schema() schema["library"] = config.Deprecated() schema["max_search_results"] = config.Integer(minimum=0) 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) -> None: from .actor import LocalBackend # noqa: PLC0415 registry.add("backend", LocalBackend) registry.add("http:app", {"name": self.ext_name, "factory": self.webapp}) def get_command(self) -> cyclopts.App: from .commands import app # noqa: PLC0415 return app def webapp(self, config: Config, core: CoreProxy) -> list[Any]: # noqa: ARG002 from .web import ImageHandler, IndexHandler # noqa: PLC0415 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: Config, *paths: str) -> pathlib.Path: 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: Config) -> pathlib.Path: return cls.get_data_subdir(config, "images") ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1787172803.0356803 mopidy_local-4.0.1/src/mopidy_local/actor.py0000644000175100017510000000131615241413703020562 0ustar00runnerrunnerimport logging from typing import ClassVar import pykka from mopidy import backend from mopidy.types import UriScheme 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: ClassVar[list[UriScheme]] = [UriScheme("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) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1787172803.0356803 mopidy_local-4.0.1/src/mopidy_local/commands.py0000644000175100017510000002216315241413703021256 0ustar00runnerrunnerimport logging import pathlib import time from typing import Annotated import cyclopts from mopidy.audio.scan import Scanner from mopidy.audio.tags import convert_tags_to_track from mopidy.config import Config from mopidy.exceptions import ScannerError from mopidy.models import Track from mopidy_local import mtimes, storage, translator logger = logging.getLogger(__name__) MIN_DURATION_MS = 100 # Shortest length of track to include. app = cyclopts.App(help="Local extension commands.") @app.command(help="Clear local media files from the local library.") def clear() -> int: config = Config.get_global() 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") # noqa: T201 return 0 if library.clear(): print("Library successfully cleared") # noqa: T201 return 0 print("Unable to clear library") # noqa: T201 return 1 @app.command(help="Scan local media files and populate the local library.") def scan( *, tracks_limit: Annotated[ int | None, cyclopts.Parameter( name="--limit", help="Maximum number of tracks to scan.", ), ] = None, force_rescan: Annotated[ bool, cyclopts.Parameter( name="--force", help="Force rescan of all media files.", negative="", ), ] = False, ) -> int: config = Config.get_global() media_dir = pathlib.Path(config["local"]["media_dir"]).resolve() library = storage.LocalStorageProvider(config) file_mtimes = _find_files( media_dir=media_dir, follow_symlinks=config["local"]["scan_follow_symlinks"], ) files_to_update, files_in_library = _check_tracks_in_library( media_dir=media_dir, file_mtimes=file_mtimes, library=library, force_rescan=force_rescan, ) files_to_update.update( _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"] ], ), ) _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"], tracks_limit=tracks_limit, ) library.close() return 0 def _find_files(*, 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( *, 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( *, 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 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 logger.debug( f"Skipped {file_uri}: File extension not on included list", ) return False if relative_path.suffix.lower() in excluded_file_exts: logger.debug(f"Skipped {file_uri}: File extension on excluded list") return False 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( # noqa: PLR0913 *, media_dir, file_mtimes, files, library, timeout, flush_threshold, tracks_limit, ): logger.info("Scanning...") files = sorted(files)[:tracks_limit] scanner = Scanner(timeout) progress = _ScanProgress(batch_size=flush_threshold, total=len(files)) for absolute_path in files: file_uri = absolute_path.as_uri() try: 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) try: track = convert_tags_to_track( result.tags, uri=local_uri, length=result.duration, last_modified=mtime, ) except ScannerError as error: # Index the file without its tags rather than hiding it # from the library entirely. logger.warning(f"Ignoring invalid tags on {file_uri}: {error}") track = Track( uri=local_uri, name=absolute_path.name, length=result.duration, last_modified=mtime, ) library.add(track, result.tags, result.duration) logger.debug(f"Added {track.uri}") except Exception 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", ) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1787172803.0356803 mopidy_local-4.0.1/src/mopidy_local/ext.conf0000644000175100017510000000253115241413703020547 0ustar00runnerrunner[local] enabled = true library = sqlite max_search_results = 100 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 ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1787172803.0356803 mopidy_local-4.0.1/src/mopidy_local/library.py0000644000175100017510000002002215241413703021111 0ustar00runnerrunnerimport logging import operator import sqlite3 from collections.abc import Mapping import uritools from mopidy import backend, models from mopidy.models import ModelType, Ref, SearchResult from mopidy.types import Uri from . import Extension, schema logger = logging.getLogger(__name__) def directory_uri(base: Mapping[str, object] | None = None, /, **query: object) -> Uri: # Like `dict()`, keywords override keys of the same name in `base`. fields = {**(base or {}), **query} # `uricompose()` accepts a query as an iterable of key/value pairs, which, # unlike a mapping, doesn't require an invariant `str | bytes` key type. return Uri(uritools.uricompose("local", None, "directory", list(fields.items()))) def date_ref(date): return Ref.directory(uri=directory_uri(date=date), name=date) def genre_ref(genre): return Ref.directory(uri=directory_uri(genre=genre), name=genre) class LocalLibraryProvider(backend.LibraryProvider): ROOT_DIRECTORY_URI = 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(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(), ModelType.ALBUM, uri)) if uri.startswith("local:artist"): return list(schema.lookup(self._connect(), ModelType.ARTIST, uri)) if uri.startswith("local:track"): return list(schema.lookup(self._connect(), ModelType.TRACK, uri)) msg = "Invalid lookup URI" raise ValueError(msg) # noqa: TRY301 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 if uri.startswith("local:directory"): return self._browse_directory(uri) if uri.startswith("local:artist"): return self._browse_artist(uri) if uri.startswith("local:album"): return self._browse_album(uri) msg = "Invalid browse URI" raise ValueError(msg) # noqa: TRY301 except Exception as e: logger.error("Error browsing %s: %s", uri, e) return [] def search( self, query=None, uris=None, exact=False, # noqa: FBT002 *, limit=100, offset=0, ): limit = self._config["max_search_results"] 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 = Uri(uritools.uricompose("local", path="search", query=q)) return SearchResult(uri=uri, tracks=tuple(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) # Gracefully handle both old and new field values for this API. compat_field = {"track": "track_name"}.get(field, field) return set(schema.list_distinct(self._connect(), compat_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(), ModelType.TRACK, order, album=uri) def _browse_artist(self, uri, order=("type", "name COLLATE NOCASE")): with self._connect() as c: albums = schema.browse(c, ModelType.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 == ModelType.ALBUM and ref.uri not in album_uris: albums.append( Ref.directory( uri=directory_uri( type=ModelType.TRACK, album=ref.uri, artist=uri, ), name=ref.name, ), ) elif ref.type == ModelType.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") or "%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_ == ModelType.TRACK and "album" in query: order = ("disc_no", "track_no", "name") if type_ == ModelType.ARTIST and self._config["use_artist_sortname"]: order = ("coalesce(sortname, name) COLLATE NOCASE",) roles = role or ("artist", "albumartist") # TODO: re-think 'roles'... refs = [] for ref in schema.browse(self._connect(), type_, order, role=roles, **query): if ref.type == ModelType.TRACK or (not query and not role): refs.append(ref) elif ref.type == ModelType.ALBUM: refs.append( Ref.directory( uri=directory_uri(query, type=ModelType.TRACK, album=ref.uri), name=ref.name, ), ) elif ref.type == ModelType.ARTIST: refs.append( Ref.directory( uri=directory_uri({**query, role or "artist": 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())] if uri.startswith("local:artist"): return [{"artist": uri}, {"albumartist": uri}] if uri.startswith("local:album"): return [{"album": uri}] return [] ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1787172803.0356803 mopidy_local-4.0.1/src/mopidy_local/mtimes.py0000644000175100017510000000611415241413703020751 0ustar00runnerrunnerimport pathlib import queue import stat import threading from mopidy import exceptions class FindError(exceptions.MopidyException): def __init__(self, message, errno=None): super().__init__(message) 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): # noqa: PLR0913, PLR0917 """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 path = entry.relative_to(root) if root else entry try: st = entry.stat() if follow else 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() ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1787172803.0356803 mopidy_local-4.0.1/src/mopidy_local/playback.py0000644000175100017510000000076715241413703021251 0ustar00runnerrunnerfrom __future__ import annotations from typing import TYPE_CHECKING from mopidy import backend from mopidy_local import translator if TYPE_CHECKING: from mopidy.types import Uri from mopidy_local.actor import LocalBackend class LocalPlaybackProvider(backend.PlaybackProvider): backend: LocalBackend def translate_uri(self, uri: Uri) -> Uri | None: return translator.local_uri_to_file_uri( uri, self.backend.config["local"]["media_dir"], ) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1787172803.0356803 mopidy_local-4.0.1/src/mopidy_local/schema.py0000644000175100017510000004077615241413703020727 0ustar00runnerrunnerimport logging import operator import pathlib import re import sqlite3 from mopidy.models import Album, Artist, Image, ModelType, 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: f""" SELECT CASE WHEN album.uri IS NULL THEN '{ModelType.TRACK}' ELSE '{ModelType.ALBUM}' 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 """, # noqa: S608 ModelType.ALBUM: f""" SELECT '{ModelType.ALBUM}' AS type, uri AS uri, name AS name FROM album WHERE %s ORDER BY %s """, # noqa: S608 ModelType.ARTIST: f""" SELECT '{ModelType.ARTIST}' AS type, uri AS uri, name AS name FROM artist WHERE %s ORDER BY %s """, # noqa: S608 ModelType.TRACK: f""" SELECT '{ModelType.TRACK}' AS type, uri AS uri, name AS name FROM track WHERE %s ORDER BY %s """, # noqa: S608 } _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", }, ModelType.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 )""", }, }, ModelType.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 )""", }, ModelType.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 = { ModelType.ALBUM: """ SELECT * FROM tracks WHERE album_uri = ? """, ModelType.ARTIST: """ SELECT * FROM tracks WHERE ? IN (artist_uri, albumartist_uri) """, ModelType.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", "disc_no", "date", "comment", "musicbrainz_trackid", "musicbrainz_albumid", "musicbrainz_artistid", } schema_version = 7 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 = f"upgrade-v{user_version}.sql" else: logger.info("Creating SQLite database schema v%s", schema_version) filename = "schema.sql" with (sql_dir / filename).open() as fh: c.executescript(fh.read()) new_version = c.execute("PRAGMA user_version").fetchone()[0] if new_version == user_version: msg = "Database schema upgrade failed" raise AssertionError(msg) user_version = new_version return user_version def tracks(c): return _tracks(c.execute("SELECT * FROM tracks")) def list_distinct(c, field, query=()): if field not in _SEARCH_FIELDS: msg = f"Invalid search field: {field}" raise LookupError(msg) sql = f""" SELECT DISTINCT {field} AS field FROM search WHERE field IS NOT NULL """ # noqa: S608 terms = [] params = [] for key, value in query: if key == "any": terms.append("? IN ({})".format(",".join(_SEARCH_FIELDS))) elif key in _SEARCH_FIELDS: terms.append(f"{key} = ?") else: msg = f"Invalid query field: {key}" raise LookupError(msg) 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"): # noqa: A002 return list( map( operator.itemgetter(0), c.execute( """ SELECT DISTINCT(strftime(?, substr(date || '-01-01', 1, 10))) AS date FROM track WHERE date IS NOT NULL ORDER BY date """, [format], ), ), ) def lookup(c, type, uri): # noqa: A002 return _tracks(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): # noqa: A002 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=()): # noqa: PLR0913, PLR0917 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("({})".format(" AND ".join(f))) params.extend(p) else: logger.debug("Skipped SQLite search filter %r", kwargs) if clauses: sql += " AND ({})".format(" OR ".join(clauses)) sql += " LIMIT ? OFFSET ?" params += [limit, offset] logger.debug("SQLite search query %r: %s", params, sql) rows = c.execute(sql, params) return _tracks(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": ( str(artist.musicbrainz_id) if artist.musicbrainz_id else None ), }, ) 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": ( str(album.musicbrainz_id) if album.musicbrainz_id else None ), "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": ( str(track.musicbrainz_id) if track.musicbrainz_id else None ), "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( # noqa: S608 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 ({})".format(",".join(_SEARCH_FIELDS))) elif field in _SEARCH_FIELDS: terms.append(f"{field} = ?") else: msg = f"Invalid search field: {field}" raise LookupError(msg) 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", f"{field} MATCH ?")) else: msg = f"Invalid search field: {field}" raise LookupError(msg) params.append(value) return (" INTERSECT ".join(terms), params) def _tracks(rows): """Convert rows to tracks, skipping any row we can't convert. Databases created by older versions of Mopidy predate model validation, and can contain values we no longer accept, e.g. dates in other formats than `YYYY`, `YYYY-MM`, or `YYYY-MM-DD`. Such rows are skipped rather than repaired on the fly. A skipped track is not part of the library as far as `mopidy local scan` is concerned, so the file is rescanned and its row replaced with valid data. Repairing the row here would instead hide the bad data from the scan, leaving it in the database forever. """ tracks = [] for row in rows: try: tracks.append(_track(row)) except ValueError as error: logger.warning("Skipping %s with invalid data: %s", row.uri, error) return tracks 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 = [] kwargs["album"] = Album( uri=row.album_uri, name=row.album_name, artists=frozenset(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 ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1787172809.7899191 mopidy_local-4.0.1/src/mopidy_local/sql/0000755000175100017510000000000015241413712017676 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1787172803.0356803 mopidy_local-4.0.1/src/mopidy_local/sql/schema.sql0000644000175100017510000002132015241413703021655 0ustar00runnerrunner-- Mopidy-Local-SQLite schema BEGIN EXCLUSIVE TRANSACTION; PRAGMA user_version = 7; -- 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_disc_no_index ON track (disc_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); CREATE INDEX album_musicbrainz_id_index ON album (musicbrainz_id); CREATE INDEX artist_musicbrainz_id_index ON artist (musicbrainz_id); CREATE INDEX track_musicbrainz_id_index ON track (musicbrainz_id); -- 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, disc_no AS disc_no, coalesce(date, album_date) AS date, comment AS comment, musicbrainz_id AS musicbrainz_trackid, album_musicbrainz_id AS musicbrainz_albumid, artist_musicbrainz_id AS musicbrainz_artistid 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, disc_no, date, comment, musicbrainz_trackid, musicbrainz_albumid, musicbrainz_artistid ); 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, disc_no, date, comment, musicbrainz_trackid, musicbrainz_albumid, musicbrainz_artistid ) 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, disc_no, date, comment, musicbrainz_trackid, musicbrainz_albumid, musicbrainz_artistid ) 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; ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1787172803.036298 mopidy_local-4.0.1/src/mopidy_local/sql/upgrade-v1.sql0000644000175100017510000001143215241413703022373 0ustar00runnerrunner-- 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; ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1787172803.036298 mopidy_local-4.0.1/src/mopidy_local/sql/upgrade-v2.sql0000644000175100017510000000242115241413703022372 0ustar00runnerrunner-- 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; ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1787172803.036298 mopidy_local-4.0.1/src/mopidy_local/sql/upgrade-v3.sql0000644000175100017510000000060215241413703022372 0ustar00runnerrunner-- 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; ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1787172803.036298 mopidy_local-4.0.1/src/mopidy_local/sql/upgrade-v4.sql0000644000175100017510000000032615241413703022376 0ustar00runnerrunner-- 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; ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1787172803.036298 mopidy_local-4.0.1/src/mopidy_local/sql/upgrade-v5.sql0000644000175100017510000000643215241413703022403 0ustar00runnerrunner-- 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; ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1787172803.036298 mopidy_local-4.0.1/src/mopidy_local/sql/upgrade-v6.sql0000644000175100017510000000550615241413703022405 0ustar00runnerrunner-- Mopidy-Local-SQLite schema upgrade v6 -> v7 BEGIN EXCLUSIVE TRANSACTION; CREATE INDEX track_disc_no_index ON track (disc_no); CREATE INDEX album_musicbrainz_id_index ON album (musicbrainz_id); CREATE INDEX artist_musicbrainz_id_index ON artist (musicbrainz_id); CREATE INDEX track_musicbrainz_id_index ON track (musicbrainz_id); DROP VIEW search; 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, disc_no AS disc_no, coalesce(date, album_date) AS date, comment AS comment, musicbrainz_id AS musicbrainz_trackid, album_musicbrainz_id AS musicbrainz_albumid, artist_musicbrainz_id AS musicbrainz_artistid FROM tracks; DROP TABLE fts; CREATE VIRTUAL TABLE fts USING fts3 ( uri, track_name, album, artist, composer, performer, albumartist, genre, track_no, disc_no, date, comment, musicbrainz_trackid, musicbrainz_albumid, musicbrainz_artistid ); DROP TRIGGER track_after_insert; DROP TRIGGER track_after_update; 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, disc_no, date, comment, musicbrainz_trackid, musicbrainz_albumid, musicbrainz_artistid ) 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, disc_no, date, comment, musicbrainz_trackid, musicbrainz_albumid, musicbrainz_artistid ) SELECT * FROM search WHERE docid = new.rowid; END; -- update date INSERT INTO fts ( docid, uri, track_name, album, artist, composer, performer, albumartist, genre, track_no, disc_no, date, comment, musicbrainz_trackid, musicbrainz_albumid, musicbrainz_artistid ) SELECT * FROM search; PRAGMA user_version = 7; -- update schema version END TRANSACTION; ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1787172803.036298 mopidy_local-4.0.1/src/mopidy_local/storage.py0000644000175100017510000002204415241413703021117 0ustar00runnerrunnerimport hashlib 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( f"Local media dir {config['local']['media_dir']} does not exist or " "we lack permissions to the directory or one of its parents" ) 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 MIN_BYTES_FOR_IMAGE_TYPE = 8 def get_image_type_from_header(header: bytes) -> str: # original source: https://github.com/sphinx-doc/sphinx/commit/a502e7 if len(header) < MIN_BYTES_FOR_IMAGE_TYPE: msg = "Unknown image type" raise ValueError(msg) if header.startswith(b"\x89PNG\r\n\x1a\n"): return "png" if header.startswith((b"GIF87a", b"GIF89a")): return "gif" if header.startswith(b"\xff\xd8"): return "jpeg" msg = "Unknown image type" raise ValueError(msg) 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): # noqa: ARG002 logger.debug("Adding track: %s", track) images = None if track.album and track.album.name: # TODO: 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 OSError as e: logger.warning("Error clearing image directory: %s", e) logger.info("Clearing SQLite database") try: schema.clear(self._connect()) except sqlite3.Error as e: logger.error("Error clearing SQLite database: %s", e) return False else: return True 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: msg = "Empty artist name" raise ValueError(msg) if not model.uri: model = model.replace(uri=model_uri("artist", model)) return model def _validate_album(self, model): if not model.name: msg = "Empty album name" raise ValueError(msg) 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: msg = "Empty track URI" raise ValueError(msg) if model.name: name = model.name else: name = translator.local_uri_to_path(model.uri, pathlib.Path()).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: # TODO: Is this a gst.Buffer or plain str/bytes type? data = getattr(image, "data", image) images.add(self._image_from_embedded_data(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._image_from_path(match_path)) except Exception as e: logger.warning( f"Cannot read image file {match_path.as_uri()}: {e!r}", ) return images def _image_from_path(self, path: pathlib.Path): with path.open("rb") as f: header = f.read(MIN_BYTES_FOR_IMAGE_TYPE) data = header + f.read() return self._save_image( data=data, source=path.as_uri(), what=get_image_type_from_header(header), ) def _image_from_embedded_data(self, data: bytes): header = data[:MIN_BYTES_FOR_IMAGE_TYPE] return self._save_image( data=data, source="embedded image", what=get_image_type_from_header(header), ) def _save_image(self, *, data: bytes, source: str, what: str): digest = hashlib.md5(data).hexdigest() # noqa: S324 width, height = 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", source, e) if width and height: name = f"{digest}-{width}x{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()} from {source}") image_path.write_bytes(data) return uritools.urijoin(self._base_uri, name) ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1787172803.036298 mopidy_local-4.0.1/src/mopidy_local/translator.py0000644000175100017510000000265115241413703021646 0ustar00runnerrunnerfrom __future__ import annotations import logging import os import urllib.parse from pathlib import Path from mopidy.types import Uri logger = logging.getLogger(__name__) def local_uri_to_file_uri(local_uri: str, media_dir: Path) -> Uri: """Convert local track or directory URI to file URI.""" path = local_uri_to_path(local_uri, media_dir) return Uri(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:")): msg = "Invalid URI." raise ValueError(msg) 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: str | bytes | Path) -> Uri: """Convert absolute path to file URI.""" ppath = Path(os.fsdecode(path)) if not ppath.is_absolute(): msg = "Path must be absolute" raise ValueError(msg) return Uri(ppath.as_uri()) def path_to_local_track_uri(path: str | bytes | Path, media_dir: Path) -> Uri: """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 Uri(f"local:track:{quoted_path}") ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1787172803.036298 mopidy_local-4.0.1/src/mopidy_local/web.py0000644000175100017510000000117015241413703020225 0ustar00runnerrunnerimport logging import os import pathlib import tornado.web logger = logging.getLogger(__name__) class ImageHandler(tornado.web.StaticFileHandler): def get_cache_time(self, *_args, **_kwargs): 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) -> str | None: return str(pathlib.Path(__file__).parent / "www") def uris(self): for _, _, files in os.walk(self.root): yield from files ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1787172809.7900908 mopidy_local-4.0.1/src/mopidy_local/www/0000755000175100017510000000000015241413712017723 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1787172803.036298 mopidy_local-4.0.1/src/mopidy_local/www/index.html0000644000175100017510000000227115241413703021722 0ustar00runnerrunner Mopidy-Local

Mopidy-Local

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

Image cache

Images extracted from local media files will automatically appear here.

{% for image in images %} Album art thumbnail {% end %}
././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1787172809.7887855 mopidy_local-4.0.1/src/mopidy_local.egg-info/0000755000175100017510000000000015241413712020571 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1787172809.7661173 mopidy_local-4.0.1/src/mopidy_local.egg-info/PKG-INFO0000644000175100017510000003103515241413712021670 0ustar00runnerrunnerMetadata-Version: 2.4 Name: mopidy-local Version: 4.0.1 Summary: Mopidy extension for playing music from your local music archive Author-email: Stein Magnus Jodal License: Apache-2.0 Project-URL: Homepage, https://github.com/mopidy/mopidy-local Classifier: Environment :: No Input/Output (Daemon) Classifier: Intended Audience :: End Users/Desktop Classifier: License :: OSI Approved :: Apache Software License Classifier: Operating System :: OS Independent Classifier: Topic :: Multimedia :: Sound/Audio :: Players Requires-Python: >=3.13 Description-Content-Type: text/markdown License-File: LICENSE Requires-Dist: mopidy>=4.0.2 Requires-Dist: pykka>=4.1 Requires-Dist: uritools>=4.0.3 Dynamic: license-file # mopidy-local [![Latest PyPI version](https://img.shields.io/pypi/v/mopidy-local)](https://pypi.org/p/mopidy-local) [![CI build status](https://img.shields.io/github/actions/workflow/status/mopidy/mopidy-local/ci.yml)](https://github.com/mopidy/mopidy-local/actions/workflows/ci.yml) [![Test coverage](https://img.shields.io/codecov/c/gh/mopidy/mopidy-local)](https://codecov.io/gh/mopidy/mopidy-local) [Mopidy](https://mopidy.com/) extension for playing music from your local music archive. ## Table of contents - [Maintainer wanted](#maintainer-wanted) - [Installation](#installation) - [Configuration](#configuration) - [Usage](#usage) - [Generating a library](#generating-a-library) - [Updating the library](#updating-the-library) - [Clearing the library](#clearing-the-library) - [Library layout](#library-layout) - [Project resources](#project-resources) - [Development](#development) - [Credits](#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: ```sh 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: ```ini [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/max_search_results`: Number of search results that should be returned. Default is 100. - `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. See below. - `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: 1. Ensure that the `local/media_dir` config value points to where your music is located. Check the current setting by running: mopidy config 2. Scan your media library.: mopidy local scan 3. 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: ```sh mopidy local scan ``` Options can be specified to control the behavior of the scan command: - `--force`: Force rescan of all media files - `--limit `: Maximum number of tracks to scan Example: ```sh mopidy local scan --limit 50 ``` ## Clearing the library To delete your local images and clear your local library: ```sh mopidy local clear ``` A prompt will ask you to confirm this irreversible operation. ## Library layout The exposed library has a root directory and nine top-level directories defined under the root directory: - Albums - Artists - Composers - Genres - Performers - Release Years - Tracks - Last Week's Updates - Last Month's Updates This can be configured through the `directories` setting. It's expected to be a list of space separated name and URI supported for browsing, eg: ```ini [local] # ... other config... directories = Albums local:directory?type=album Artists local:directory?type=artist Composers local:directory?type=artist&role=composer Tracks local:directory?type=track Last Week's Updates local:directory?max-age=604800 ``` ### URIs supported for browsing _Remember that URIs are opaque values that neither Mopidy's core layer or Mopidy frontends should attempt to derive any meaning from._ That said, it's necessary to have a sufficient knowledge of Mopidy-Local URIs to customize the `directories` setting properly: - Browsing URIs starting with `local:artist` returns references to albums and tracks with the given artist. - Browsing URIs starting with `local:album` returns references to the album tracks. - Browsing URIs starting with `local:track` is not supported. Other URIs supported for browsing start with `local:directory`. The returned references are specified through "query parameters": - `local:directory`: References to the top levels directories. - `local:directory?type=tracks`: References all tracks. Multiple parameters can be added to filter the referenced tracks: `album`, `albumartist`, `artist`, `composer`, `date`, `genre`, `performer`, and `max-age`. - `local:directory?type=date`: References to directories grouping tracks by date and album. Dates are transformed according to the optional parameter `FORMAT` which default to `%Y-%m-%d`. The URIs of the references start with `local:directory?date=`. - `local:directory?type=genre`: References to directories named after genres found among all tracks. Their URIs start with `local:directory?genre=`. - `local:directory?type=album`: References to all albums. - `local:directory?type=album&PARAM=VALUE`: References to directories grouping tracks matching the given criteria. `PARAM` must be one of `albumartist`, `artist`, `composer`, `date`, `genre`, `performer`, `max-age`. The referenced directories group the selected tracks by album; Their URIs start with `local:directory?PARAM=VALUE&type=track&album=local:album:`. - `local:directory?type=artist`: References to all artists. - `local:directory?type=artist&role=ROLE`: References to directories with URIs `local:directory?ROLE=URI` where `URI` varies among all URIs starting with `local:artist` build from all tracks tag corresponding to `ROLE`. `ROLE` is one of `albumartist`, `artist`, `composer`, or `performer`. - `local:directory?album=URI`: A reference to a directory grouping the tracks of the album with given URI. Its URI starts with `local:directory?album=URI&type=track`. - `local:directory?albumartist=URI`: References to directories grouping tracks whose albumartist tag has given URI. The referenced directories group the selected tracks by album; Their URIs start with `local:directory?albumartist=URI&type=track&album=local:album:`. - `local:directory?artist=URI`: References to directories grouping tracks whose artist has given URI. The referenced directories group the selected tracks by album; Their URIs start with `local:directory?artist=URI&type=track&album=local:album:`. - `local:directory?composer=URI`: References to directories grouping tracks whose composer has given URI. The referenced directories group the selected tracks by album; Their URIs start with `local:directory?composer=URI&type=track&album=local:album:`. - `local:directory?date=DATE`: References to directories grouping tracks whose date match DATE. The referenced directories group the selected tracks by album; Their URIs start with `local:directory?date=DATE&type=track&album=local:album:`. The match is to be interpreted as in a `LIKE` SQL statement. - `local:directory?genre=GENRE`: References to directories grouping tracks whose genre is GENRE. The referenced directories group the selected tracks by album; Their URIs start with `local:directory?genre=GENRE&type=track&album=local:album:`. - `local:directory?performer=URI`: References to directories grouping tracks whose performer has given URI. The referenced directories group the selected tracks by album; Their URIs start with `local:directory?performer=URI&type=track&album=local:album:`. - `local:directory?max-age=SECONDS`: References to directories grouping tracks whose \"last modified\" date is newer than SECONDS seconds. The referenced directories group the selected tracks by album; Their URIs start with `local:directory?max-age=SECONDS&type=track&album=local:album:`. ## Project resources - [Source code](https://github.com/mopidy/mopidy-local) - [Issues](https://github.com/mopidy/mopidy-local/issues) - [Releases](https://github.com/mopidy/mopidy-local/releases) ## Development ### Set up development environment Clone the repo using, e.g. using [gh](https://cli.github.com/): ```sh gh repo clone mopidy/mopidy-local ``` Enter the directory, and install dependencies using [uv](https://docs.astral.sh/uv/): ```sh cd mopidy-local/ uv sync ``` ### Running tests To run all tests and linters in isolated environments, use [tox](https://tox.wiki/): ```sh tox ``` To only run tests, use [pytest](https://pytest.org/): ```sh pytest ``` To format the code, use [ruff](https://docs.astral.sh/ruff/): ```sh ruff format . ``` To check for lints with ruff, run: ```sh ruff check . ``` To check for type errors, use [pyright](https://microsoft.github.io/pyright/): ```sh pyright . ``` ### Making a release To make a release to PyPI, go to the project's [GitHub releases page](https://github.com/mopidy/mopidy-local/releases) and click the "Draft a new release" button. In the "choose a tag" dropdown, select the tag you want to release or create a new tag, e.g. `v0.1.0`. Add a title, e.g. `v0.1.0`, and a description of the changes. Decide if the release is a pre-release (alpha, beta, or release candidate) or should be marked as the latest release, and click "Publish release". Once the release is created, the `release.yml` GitHub Action will automatically build and publish the release to [PyPI](https://pypi.org/project/mopidy-local/). ## Credits - Original authors: [Stein Magnus Jodal](https://github.com/jodal) and [Thomas Adamcik](https://github.com/adamcik) for the Mopidy-Local extension in Mopidy core. [Thomas Kemmer](https://github.com/tkem) for the SQLite storage and support for embedded album art. - Current maintainer: None. Maintainer wanted, see section above. - [Contributors](https://github.com/mopidy/mopidy-local/graphs/contributors) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1787172809.7798772 mopidy_local-4.0.1/src/mopidy_local.egg-info/SOURCES.txt0000644000175100017510000000234515241413712022461 0ustar00runnerrunner.copier-answers.yml .gitignore LICENSE README.md pyproject.toml .github/workflows/ci.yml .github/workflows/release.yml src/mopidy_local/__init__.py src/mopidy_local/actor.py src/mopidy_local/commands.py src/mopidy_local/ext.conf src/mopidy_local/library.py src/mopidy_local/mtimes.py src/mopidy_local/playback.py src/mopidy_local/schema.py src/mopidy_local/storage.py src/mopidy_local/translator.py src/mopidy_local/web.py src/mopidy_local.egg-info/PKG-INFO src/mopidy_local.egg-info/SOURCES.txt src/mopidy_local.egg-info/dependency_links.txt src/mopidy_local.egg-info/entry_points.txt src/mopidy_local.egg-info/requires.txt src/mopidy_local.egg-info/scm_file_list.json src/mopidy_local.egg-info/scm_version.json src/mopidy_local.egg-info/top_level.txt src/mopidy_local/sql/schema.sql src/mopidy_local/sql/upgrade-v1.sql src/mopidy_local/sql/upgrade-v2.sql src/mopidy_local/sql/upgrade-v3.sql src/mopidy_local/sql/upgrade-v4.sql src/mopidy_local/sql/upgrade-v5.sql src/mopidy_local/sql/upgrade-v6.sql src/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_storage.py tests/test_tracklist.py tests/test_translator.py././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1787172809.7667048 mopidy_local-4.0.1/src/mopidy_local.egg-info/dependency_links.txt0000644000175100017510000000000115241413712024637 0ustar00runnerrunner ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1787172809.7670405 mopidy_local-4.0.1/src/mopidy_local.egg-info/entry_points.txt0000644000175100017510000000005415241413712024066 0ustar00runnerrunner[mopidy.ext] local = mopidy_local:Extension ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1787172809.7672753 mopidy_local-4.0.1/src/mopidy_local.egg-info/requires.txt0000644000175100017510000000005115241413712023165 0ustar00runnerrunnermopidy>=4.0.2 pykka>=4.1 uritools>=4.0.3 ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1787172809.7426834 mopidy_local-4.0.1/src/mopidy_local.egg-info/scm_file_list.json0000644000175100017510000000225415241413712024303 0ustar00runnerrunner{ "files": [ ".copier-answers.yml", ".github/workflows/ci.yml", ".github/workflows/release.yml", ".gitignore", "LICENSE", "README.md", "pyproject.toml", "src/mopidy_local/__init__.py", "src/mopidy_local/actor.py", "src/mopidy_local/commands.py", "src/mopidy_local/ext.conf", "src/mopidy_local/library.py", "src/mopidy_local/mtimes.py", "src/mopidy_local/playback.py", "src/mopidy_local/schema.py", "src/mopidy_local/sql/schema.sql", "src/mopidy_local/sql/upgrade-v1.sql", "src/mopidy_local/sql/upgrade-v2.sql", "src/mopidy_local/sql/upgrade-v3.sql", "src/mopidy_local/sql/upgrade-v4.sql", "src/mopidy_local/sql/upgrade-v5.sql", "src/mopidy_local/sql/upgrade-v6.sql", "src/mopidy_local/storage.py", "src/mopidy_local/translator.py", "src/mopidy_local/web.py", "src/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_storage.py", "tests/test_tracklist.py", "tests/test_translator.py" ] } ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1787172809.7351172 mopidy_local-4.0.1/src/mopidy_local.egg-info/scm_version.json0000644000175100017510000000024015241413712024007 0ustar00runnerrunner{ "tag": "4.0.1", "distance": 0, "node": "g64fed3c2ab69a38c294b552dc577ac479615cce7", "dirty": false, "branch": "HEAD", "node_date": "2026-08-19" } ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1787172809.7674778 mopidy_local-4.0.1/src/mopidy_local.egg-info/top_level.txt0000644000175100017510000000001515241413712023317 0ustar00runnerrunnermopidy_local ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1787172809.791732 mopidy_local-4.0.1/tests/0000755000175100017510000000000015241413712014777 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1787172803.036298 mopidy_local-4.0.1/tests/__init__.py0000644000175100017510000000166615241413703017121 0ustar00runnerrunnerimport functools import pathlib import warnings from mopidy.types import Uri def path_to_data_dir(name) -> pathlib.Path: path = pathlib.Path(__file__).parent / "data" / name return path.resolve() def generate_song(i) -> Uri: return Uri(f"local:track:song{i}.wav") def populate_tracklist(func): @functools.wraps(func) def wrapper(self): with warnings.catch_warnings(): warnings.simplefilter("ignore", DeprecationWarning) self.tl_tracks = self.core.tracklist.add(self.tracks) return func(self) return wrapper class IsA: # noqa: PLW1641 def __init__(self, klass): self.klass = klass def __eq__(self, rhs): try: return isinstance(rhs, self.klass) except TypeError: return type(rhs) is type(self.klass) def __ne__(self, rhs): return not self.__eq__(rhs) def __repr__(self): return str(self.klass) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1787172803.0370274 mopidy_local-4.0.1/tests/dummy_audio.py0000644000175100017510000000757515241413703017703 0ustar00runnerrunner"""A dummy audio actor for use in tests. This class implements the audio API in the simplest way possible. It is used in tests of the core and backends. """ from typing import override import pykka from mopidy import audio from mopidy.types import DurationMs, PlaybackState def create_proxy(config=None, mixer=None): return DummyAudio.start(config, mixer).proxy() # TODO: reset position on track change? class DummyAudio(audio.Audio, pykka.ThreadingActor): def __init__(self, config=None, mixer=None): super().__init__() self.state = PlaybackState.STOPPED self._position = DurationMs(0) self._source_setup_callback = None self._about_to_finish_callback = None self._uri = None self._stream_changed = False self._live_stream = False self._tags = {} self._bad_uris = set() @override def set_uri(self, uri, live_stream=False, download=False): assert self._uri is None, "prepare change not called before set" self._position = DurationMs(0) self._uri = uri self._stream_changed = True self._live_stream = live_stream self._tags = {} @override def set_source_setup_callback(self, callback): self._source_setup_callback = callback @override def set_about_to_finish_callback(self, callback): self._about_to_finish_callback = callback @override def get_position(self): return self._position @override def set_position(self, position): self._position = position audio.AudioListener.send("position_changed", position=position) return True @override def start_playback(self): return self._change_state(PlaybackState.PLAYING) @override def pause_playback(self): return self._change_state(PlaybackState.PAUSED) @override def prepare_change(self): self._uri = None self._source_setup_callback = None return True @override def stop_playback(self): return self._change_state(PlaybackState.STOPPED) @override def get_current_tags(self): return self._tags def _change_state(self, new_state): if not self._uri: return False if new_state == PlaybackState.STOPPED and self._uri: self._stream_changed = True self._uri = None if self._stream_changed: self._stream_changed = False audio.AudioListener.send("stream_changed", uri=self._uri) if self._uri is not None: audio.AudioListener.send("position_changed", position=0) old_state, self.state = self.state, new_state audio.AudioListener.send( "state_changed", old_state=old_state, new_state=new_state, target_state=None, ) if new_state == PlaybackState.PLAYING: self._tags["audio-codec"] = ["fake info..."] audio.AudioListener.send("tags_changed", tags=["audio-codec"]) return self._uri not in self._bad_uris def trigger_fake_playback_failure(self, uri): self._bad_uris.add(uri) def trigger_fake_tags_changed(self, tags): self._tags.update(tags) audio.AudioListener.send("tags_changed", tags=self._tags.keys()) def get_about_to_finish_callback(self): # This needs to be called from outside the actor or we lock up. def wrapper(): if self._about_to_finish_callback: self.prepare_change() self._about_to_finish_callback() if not self._uri or not self._about_to_finish_callback: self._tags = {} audio.AudioListener.send("reached_end_of_stream") else: audio.AudioListener.send("position_changed", position=0) audio.AudioListener.send("stream_changed", uri=self._uri) return wrapper ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1787172803.0370274 mopidy_local-4.0.1/tests/test_extension.py0000644000175100017510000000161515241413703020427 0ustar00runnerrunnerfrom mopidy_local import Extension def test_get_default_config() -> None: ext = Extension() config = ext.get_default_config() assert "[local]" in config assert "enabled = true" in config def test_get_config_schema() -> None: ext = Extension() schema = ext.get_config_schema() assert "library" in schema assert "media_dir" in schema assert "data_dir" in schema assert "playlists_dir" in schema assert "tag_cache_file" in schema assert "scan_timeout" in schema assert "scan_flush_threshold" in schema assert "scan_follow_symlinks" in schema assert "included_file_extensions" in schema assert "excluded_file_extensions" in schema # from mopidy-local-sqlite assert "directories" in schema assert "timeout" in schema assert "use_artist_sortname" in schema # from mopidy-local-images assert "album_art_files" in schema ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1787172803.0370274 mopidy_local-4.0.1/tests/test_library.py0000644000175100017510000000722315241413703020060 0ustar00runnerrunnerimport contextlib import pathlib import unittest from typing import cast from unittest import mock import pykka from mopidy import backend, core from mopidy.models import Album, SearchResult, Track from mopidy_local import actor, storage, translator from tests import dummy_audio, path_to_data_dir class LocalLibraryProviderTest(unittest.TestCase): config = { "core": { "data_dir": path_to_data_dir(""), "max_tracklist_length": 10000, }, "local": { "media_dir": path_to_data_dir(""), "directories": [], "timeout": 10, "max_search_results": 100, "use_artist_sortname": False, "album_art_files": [], }, } def setUp(self): self.audio = dummy_audio.create_proxy() self.backend = cast( "backend.BackendProxy", actor.LocalBackend.start( config=self.config, audio=self.audio, ).proxy(), ) self.core = cast( "core.CoreProxy", core.Core.start( audio=self.audio, backends=[self.backend], config=self.config, ).proxy(), ) self.library = self.backend.library self.storage = storage.LocalStorageProvider(self.config) self.storage.load() def tearDown(self): pykka.ActorRegistry.stop_all() with contextlib.suppress(OSError): path_to_data_dir("local/library.db").unlink() def test_add_noname_ascii(self): name = "Test.mp3" uri = translator.path_to_local_track_uri(name, pathlib.Path("/media/dir")) track = Track(name=name, uri=uri) self.storage.begin() self.storage.add(track) self.storage.close() assert [track] == self.library.lookup(uri).get() def test_add_noname_utf8(self): name = "Mi\xf0vikudags.mp3" uri = translator.path_to_local_track_uri( name.encode(), pathlib.Path("/media/dir"), ) track = Track(name=name, uri=uri) self.storage.begin() self.storage.add(track) self.storage.close() assert [track] == self.library.lookup(uri).get() def test_clear(self): self.storage.begin() self.storage.add(Track(uri="local:track:track.mp3")) self.storage.close() self.storage.clear() assert self.storage.load() == 0 def test_search_uri(self): lib = self.library empty = SearchResult(uri="local:search?") assert empty == lib.search(uris=None).get() assert empty == lib.search(uris=[]).get() assert empty == lib.search(uris=["local:"]).get() assert empty == lib.search(uris=["local:directory"]).get() assert empty == lib.search(uris=["local:directory:"]).get() assert empty == lib.search(uris=["foobar:"]).get() def test_browse_directory_overrides_album_already_in_query(self): album = Album(uri="local:album:0", name="album #0") self.storage.begin() self.storage.add(Track(uri="local:track:0", name="track #0", album=album)) self.storage.close() refs = self.library.browse("local:directory?album=local:album:0").get() assert [ref.uri for ref in refs] == [ "local:directory?album=local:album:0&type=track", ] @mock.patch("mopidy_local.schema.list_distinct") def test_distinct_field_track_uses_track_name(self, distinct_mock): distinct_mock.return_value = [] assert self.library.get_distinct("track").get() == set() distinct_mock.assert_called_once_with(mock.ANY, "track_name", []) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1787172803.0370274 mopidy_local-4.0.1/tests/test_mtimes.py0000644000175100017510000001372515241413703017716 0ustar00runnerrunnerimport os import pathlib import shutil import pytest from mopidy import exceptions import tests from mopidy_local import mtimes @pytest.fixture def tmp_dir_path(tmp_path): yield tmp_path if tmp_path.is_dir(): shutil.rmtree(str(tmp_path)) def test_find_error_is_a_mopidy_exception(): assert issubclass(mtimes.FindError, exceptions.MopidyException) def test_find_error_can_store_an_errno(): exc = mtimes.FindError("msg", errno=1234) assert str(exc) == "msg" assert exc.errno == 1234 def test_names_are_pathlib_objects(): result, errors = mtimes.find_mtimes(str(tests.path_to_data_dir(""))) for name in list(result.keys()) + list(errors.keys()): assert isinstance(name, pathlib.Path) def test_nonexistent_dir_is_an_error(tmp_dir_path): missing_path = tmp_dir_path / "does-not-exist" result, errors = mtimes.find_mtimes(missing_path) assert result == {} assert errors == {missing_path: tests.IsA(mtimes.FindError)} def test_empty_dirs_are_not_in_the_result(tmp_dir_path): """Empty directories should not show up in results""" dir_path = tmp_dir_path / "empty" dir_path.mkdir() result, errors = mtimes.find_mtimes(dir_path) assert result == {} assert errors == {} def test_file_as_the_root_just_returns_the_file(tmp_dir_path): file_path = tmp_dir_path / "single" file_path.touch() result, errors = mtimes.find_mtimes(file_path) assert result == {file_path: tests.IsA(int)} assert errors == {} def test_nested_directories(tmp_dir_path): # Setup foo/bar and baz directories foo_path = tmp_dir_path / "foo" / "file" foo_path.parent.mkdir() foo_path.touch() foo_bar_path = tmp_dir_path / "foo" / "bar" / "filee" foo_bar_path.parent.mkdir() foo_bar_path.touch() baz_path = tmp_dir_path / "baz" / "file" baz_path.parent.mkdir() baz_path.touch() result, errors = mtimes.find_mtimes(tmp_dir_path) assert result == { foo_path: tests.IsA(int), foo_bar_path: tests.IsA(int), baz_path: tests.IsA(int), } assert errors == {} def test_missing_permission_to_file_is_not_an_error(tmp_dir_path): """Missing permissions to a file is not a search error""" file_path = tmp_dir_path / "file" file_path.touch(mode=0o000) result, errors = mtimes.find_mtimes(tmp_dir_path) assert result == {file_path: tests.IsA(int)} assert errors == {} file_path.chmod(0o644) def test_missing_permission_to_directory_is_an_error(tmp_dir_path): dir_path = tmp_dir_path / "dir" dir_path.mkdir(mode=0o000) result, errors = mtimes.find_mtimes(tmp_dir_path) if os.getuid() == 0: # Test is run as root, nothing is off limits. assert errors == {} else: assert result == {} assert errors == {dir_path: tests.IsA(mtimes.FindError)} dir_path.chmod(0o755) def test_symlinks_are_by_default_an_error(tmp_dir_path): """By default symlinks should be treated as an error""" file_path = tmp_dir_path / "file" file_path.touch() link_path = tmp_dir_path / "link" link_path.symlink_to(file_path) result, errors = mtimes.find_mtimes(tmp_dir_path) assert result == {file_path: tests.IsA(int)} assert errors == {link_path: tests.IsA(mtimes.FindError)} def test_with_follow_symlink_to_file_as_root_is_followed(tmp_dir_path): file_path = tmp_dir_path / "file" file_path.touch() link_path = tmp_dir_path / "link" link_path.symlink_to(file_path) result, errors = mtimes.find_mtimes(link_path, follow=True) assert result == {file_path: tests.IsA(int)} assert errors == {} def test_symlink_to_directory_is_followed(tmp_dir_path): file_path = tmp_dir_path / "dir" / "file" file_path.parent.mkdir() file_path.touch() link_path = tmp_dir_path / "link" link_path.symlink_to(file_path.parent, target_is_directory=True) result, errors = mtimes.find_mtimes(link_path, follow=True) assert result == {file_path: tests.IsA(int)} assert errors == {} def test_symlink_pointing_at_itself_fails(tmp_dir_path): link_path = tmp_dir_path / "link" link_path.symlink_to(link_path) result, errors = mtimes.find_mtimes(tmp_dir_path, follow=True) assert result == {} assert errors == {link_path: tests.IsA(mtimes.FindError)} def test_symlink_pointing_at_parent_fails(tmp_dir_path): """We should detect a loop via the parent and give up on the branch""" link_path = tmp_dir_path / "link" link_path.symlink_to(tmp_dir_path, target_is_directory=True) result, errors = mtimes.find_mtimes(tmp_dir_path, follow=True) assert result == {} assert errors == {link_path: tests.IsA(Exception)} def test_indirect_symlink_loop(tmp_dir_path): """More indirect loops should also be detected""" # Setup tmpdir/directory/loop where loop points to tmpdir link_path = tmp_dir_path / "dir" / "link" link_path.parent.mkdir() link_path.symlink_to(tmp_dir_path, target_is_directory=True) result, errors = mtimes.find_mtimes(tmp_dir_path, follow=True) assert result == {} assert errors == {link_path: tests.IsA(Exception)} def test_symlink_branches_are_not_excluded(tmp_dir_path): """Using symlinks to make a file show up multiple times should work""" file_path = tmp_dir_path / "dir" / "file" file_path.parent.mkdir() file_path.touch() link1_path = tmp_dir_path / "link1" link1_path.symlink_to(file_path) link2_path = tmp_dir_path / "link2" link2_path.symlink_to(file_path) result, errors = mtimes.find_mtimes(tmp_dir_path, follow=True) assert result == { file_path: tests.IsA(int), link1_path: tests.IsA(int), link2_path: tests.IsA(int), } assert errors == {} def test_gives_mtime_in_milliseconds(tmp_dir_path): file_path = tmp_dir_path / "file" file_path.touch() os.utime(str(file_path), (1, 3.14159265)) result, errors = mtimes.find_mtimes(tmp_dir_path) assert result == {file_path: 3141} assert errors == {} ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1787172803.0370274 mopidy_local-4.0.1/tests/test_playback.py0000644000175100017510000011042615241413703020202 0ustar00runnerrunnerimport time import unittest from typing import cast from unittest import mock import pykka from mopidy import backend, core from mopidy.models import TlTrack, Track from mopidy.types import DurationMs, PlaybackState from mopidy_local import actor from tests import ( dummy_audio, generate_song, path_to_data_dir, populate_tracklist, ) # TODO: Test 'playlist repeat', e.g. repeat=1,single=0 class LocalPlaybackProviderTest(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": [], }, } # We need four tracks so that our shuffled track tests behave nicely with # reversed as a fake shuffle. Ensuring that shuffled order is [4,3,2,1] and # normal order [1,2,3,4] which means next_track != next_track_with_random tracks = [ Track(uri=generate_song(i), length=DurationMs(4464)) for i in (1, 2, 3, 4) ] tl_tracks: pykka.Future[list[TlTrack]] def add_track(self, uri): track = Track(uri=uri, length=DurationMs(4464)) self.tracklist.add([track]) def trigger_about_to_finish(self): # Flush any queued core calls. self.playback.get_current_tl_track().get() callback = self.audio.get_about_to_finish_callback().get() callback() def setUp(self): self.audio = dummy_audio.create_proxy() self.backend = cast( "backend.BackendProxy", actor.LocalBackend.start( config=self.config, audio=self.audio, ).proxy(), ) self.core = cast( "core.CoreProxy", core.Core.start( audio=self.audio, backends=[self.backend], config=self.config, ).proxy(), ) self.playback = self.core.playback self.tracklist = self.core.tracklist assert len(self.tracks) >= 3, "Need at least three tracks to run tests." assert self.tracks[0].length assert self.tracks[0].length >= 2000, ( "First song needs to be at least 2000 miliseconds" ) def tearDown(self): 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(tlid=self.tl_tracks.get()[-1].tlid).get() self.assert_state_is(PlaybackState.PLAYING) @populate_tracklist def test_play_track_return_value(self): assert self.playback.play(tlid=self.tl_tracks.get()[-1].tlid).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(tlid=self.tl_tracks.get()[-1].tlid).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(tlid=self.tl_tracks.get()[-1].tlid).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(tlid=self.tl_tracks.get()[-1].tlid).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(tlid=self.tl_tracks.get()[2].tlid).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() 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 = 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() 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() 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(tlid=self.tl_tracks.get()[-1].tlid).get() self.trigger_about_to_finish() # EOS should have triggered self.assert_current_track_index_is(None) @mock.patch.object(core.PlaybackController, "_on_tracklist_change") def test_on_tracklist_change_gets_called(self, change_mock): self.tracklist.add([Track(uri="a")]).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): # TODO: 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(tlid=self.tl_tracks.get()[-1].tlid).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(tlid=self.tl_tracks.get()[-1].tlid).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() 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 ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1787172803.037055 mopidy_local-4.0.1/tests/test_schema.py0000644000175100017510000003036315241413703017655 0ustar00runnerrunnerimport sqlite3 import unittest from mopidy.models import Album, Artist, ModelType, Ref, Track from mopidy_local import schema DBPATH = ":memory:" class SchemaTest(unittest.TestCase): artists = [ Artist( uri="local:artist:0", name="artist #0", musicbrainz_id="b5e8922b-5dee-44f2-85e1-7e78b69a7e1d", ), Artist(uri="local:artist:1", name="artist #1"), ] albums = [ Album( uri="local:album:0", name="album #0", musicbrainz_id="13b290bc-465d-4cb8-85df-9c18c0614a66", ), 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", date="2014", artists=[artists[0]], ), Track( uri="local:track:2", name="track #2", date="2020-09-01", album=albums[0], ), Track( uri="local:track:3", name="track #3", date="2020-10-01", album=albums[1], ), Track( uri="local:track:4", name="track #4", album=albums[2], composers=[artists[0]], performers=[artists[0]], musicbrainz_id="e6cea07e-9d2d-4cd2-912f-94fd11c99763", ), ] 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): assert [track.name for track in self.tracks] == schema.list_distinct( self.connection, "track_name", ) assert schema.list_distinct(self.connection, "track_no") == [] assert schema.list_distinct(self.connection, "disc_no") == [] assert [album.name for album in self.albums] == schema.list_distinct( self.connection, "album", ) assert [artist.name for artist in self.artists[0:2]] == schema.list_distinct( self.connection, "albumartist", ) assert [artist.name for artist in self.artists[0:1]] == schema.list_distinct( self.connection, "artist", ) assert [artist.name for artist in self.artists[0:1]] == schema.list_distinct( self.connection, "composer", ) assert [artist.name for artist in self.artists[0:1]] == schema.list_distinct( self.connection, "performer", ) assert [ track.date for track in self.tracks if track.date ] == schema.list_distinct(self.connection, "date") assert [self.tracks[0].genre] == schema.list_distinct(self.connection, "genre") assert [str(self.tracks[4].musicbrainz_id)] == schema.list_distinct( self.connection, "musicbrainz_trackid", ) assert [str(self.artists[0].musicbrainz_id)] == schema.list_distinct( self.connection, "musicbrainz_artistid", ) assert [str(self.albums[0].musicbrainz_id)] == schema.list_distinct( self.connection, "musicbrainz_albumid", ) def test_dates(self): with self.connection as c: results = schema.dates(c) assert results == ["2014-01-01", "2015-03-15", "2020-09-01", "2020-10-01"] results = schema.dates(c, format="%Y") assert results == ["2014", "2015", "2020"] def test_lookup_track(self): with self.connection as c: for track in self.tracks: result = schema.lookup(c, ModelType.TRACK, track.uri) assert [track] == list(result) def test_lookup_album(self): with self.connection as c: result = schema.lookup(c, ModelType.ALBUM, self.albums[0].uri) assert [self.tracks[2]] == list(result) result = schema.lookup(c, ModelType.ALBUM, self.albums[1].uri) assert [self.tracks[3]] == list(result) result = schema.lookup(c, ModelType.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, ModelType.ARTIST, self.artists[0].uri) assert [self.tracks[1], self.tracks[3]] == list(result) result = schema.lookup(c, ModelType.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 [ ((t.uri for t in 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) assert set(results) == {t.uri for t in tracks} def test_fulltext_search(self): for results, query, filters in [ ((t.uri for t in 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) assert set(results) == {t.uri for t in 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, ModelType.ARTIST) assert list(map(ref, self.artists)) == schema.browse( c, ModelType.ARTIST, role=["artist", "albumartist"], ) assert list(map(ref, self.artists[0:1])) == schema.browse( c, ModelType.ARTIST, role="artist", ) assert list(map(ref, self.artists[0:1])) == schema.browse( c, ModelType.ARTIST, role="composer", ) assert list(map(ref, self.artists[0:1])) == schema.browse( c, ModelType.ARTIST, role="performer", ) assert list(map(ref, self.artists)) == schema.browse( c, ModelType.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, ModelType.ALBUM) assert list(map(ref, [])) == schema.browse( c, ModelType.ALBUM, artist=self.artists[0].uri, ) assert list(map(ref, self.albums[1:2])) == schema.browse( c, ModelType.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, ModelType.TRACK) assert list(map(ref, self.tracks[1:2])) == schema.browse( c, ModelType.TRACK, artist=self.artists[0].uri, ) assert list(map(ref, self.tracks[2:3])) == schema.browse( c, ModelType.TRACK, album=self.albums[0].uri, ) assert list(map(ref, self.tracks[3:4])) == schema.browse( c, ModelType.TRACK, albumartist=self.artists[0].uri, ) assert list(map(ref, self.tracks[4:5])) == schema.browse( c, ModelType.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 len(c.execute("SELECT * FROM album").fetchall()) == 3 assert len(c.execute("SELECT * FROM artist").fetchall()) == 2 schema.delete_track(c, self.tracks[1].uri) schema.cleanup(c) assert len(c.execute("SELECT * FROM album").fetchall()) == 3 assert len(c.execute("SELECT * FROM artist").fetchall()) == 2 schema.delete_track(c, self.tracks[2].uri) schema.cleanup(c) assert len(c.execute("SELECT * FROM album").fetchall()) == 2 assert len(c.execute("SELECT * FROM artist").fetchall()) == 2 schema.delete_track(c, self.tracks[3].uri) schema.cleanup(c) assert len(c.execute("SELECT * FROM album").fetchall()) == 1 assert len(c.execute("SELECT * FROM artist").fetchall()) == 2 schema.delete_track(c, self.tracks[4].uri) schema.cleanup(c) assert len(c.execute("SELECT * FROM album").fetchall()) == 0 assert len(c.execute("SELECT * FROM artist").fetchall()) == 0 def test_tracks_skips_track_with_invalid_date(self): # Databases created before we validated our models can hold dates in # any format. See mopidy-local#92. self.connection.execute( "UPDATE track SET date = '31-12-2006' WHERE uri = ?", [self.tracks[0].uri], ) tracks = schema.tracks(self.connection) assert len(tracks) == len(self.tracks) - 1 assert self.tracks[0].uri not in [track.uri for track in tracks] def test_tracks_skips_track_with_invalid_album_date(self): self.connection.execute( "UPDATE album SET date = '12-1996' WHERE uri = ?", [self.albums[0].uri], ) tracks = schema.tracks(self.connection) # tracks[2] is the only track on albums[0]. assert len(tracks) == len(self.tracks) - 1 assert self.tracks[2].uri not in [track.uri for track in tracks] def test_lookup_skips_track_with_invalid_date(self): self.connection.execute( "UPDATE track SET date = '31-12-2006' WHERE uri = ?", [self.tracks[0].uri], ) with self.connection as c: assert schema.lookup(c, ModelType.TRACK, self.tracks[0].uri) == [] def test_search_skips_track_with_invalid_date(self): self.connection.execute( "UPDATE track SET date = '31-12-2006' WHERE uri = ?", [self.tracks[0].uri], ) with self.connection as c: result = schema.search_tracks(c, [], 10, 0, exact=False) assert len(result) == len(self.tracks) - 1 assert self.tracks[0].uri not in [track.uri for track in result] ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1787172803.037055 mopidy_local-4.0.1/tests/test_storage.py0000644000175100017510000000240615241413703020056 0ustar00runnerrunnerimport pytest from mopidy_local import storage def test_get_image_type_from_header_png(): data_bytes = b"\x89PNG\r\n\x1a\nffe000104a464" assert storage.get_image_type_from_header(data_bytes) == "png" @pytest.mark.parametrize( "data", [ pytest.param("474946383761ffe000104a46", id="GIF87a"), pytest.param("474946383961ffe000104a46", id="GIF89a"), ], ) def test_get_image_type_from_header_gif(data): data_bytes = bytes.fromhex(data) assert storage.get_image_type_from_header(data_bytes) == "gif" @pytest.mark.parametrize( "data", [ pytest.param("ffd8ffe000104a46494600", id="JFIF"), pytest.param("ffd8ffe100184578696600", id="Exif"), pytest.param("ffd8ffe1095068747470", id="XMP"), ], ) def test_get_image_type_from_header_jpeg(data): data_bytes = bytes.fromhex(data) assert storage.get_image_type_from_header(data_bytes) == "jpeg" def test_get_image_type_from_header_unknown_header(): data_bytes = b"PIF81affe000104a464" with pytest.raises(ValueError): storage.get_image_type_from_header(data_bytes) def test_get_image_type_from_header_too_short_header(): data_bytes = b"\xff" with pytest.raises(ValueError): storage.get_image_type_from_header(data_bytes) ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1787172803.037055 mopidy_local-4.0.1/tests/test_tracklist.py0000644000175100017510000003113715241413703020415 0ustar00runnerrunnerimport random import unittest from typing import cast import pykka from mopidy import core from mopidy.models import Playlist, Track from mopidy.types import PlaybackState 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): self.audio = dummy_audio.create_proxy() self.backend = actor.LocalBackend.start( config=self.config, audio=self.audio, ).proxy() self.core = cast( "core.CoreProxy", 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): 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 len(self.controller.get_tl_tracks().get()) == 0 assert self.controller.get_length().get() == 0 self.controller.add(self.tracks) assert len(self.controller.get_tl_tracks().get()) == 3 assert self.controller.get_length().get() == 3 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( tracks=[track], at_position=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( tracks=[track], at_position=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( uri="pl", 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(uri="a") track2 = Track(uri="b") track3 = Track(uri="c") 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 len(track_slice) == 2 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 len(self.controller.slice(7, 8).get()) == 0 assert len(self.controller.slice((-1), 1).get()) == 0 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(uri="a")]) assert version < self.controller.get_version().get() ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1787172803.037055 mopidy_local-4.0.1/tests/test_translator.py0000644000175100017510000000644015241413703020605 0ustar00runnerrunnerimport 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