././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1777128352.815312 mopidy_beets-5.0.0/0000755000175100017510000000000015173151641013651 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1777128348.0 mopidy_beets-5.0.0/.copier-answers.yml0000644000175100017510000000043415173151634017416 0ustar00runnerrunner_commit: v2.2.0 _src_path: gh:mopidy/mopidy-ext-template author_email: devel@sumpfralle.de author_full_name: Lars Kruse dist_name: mopidy-beets ext_name: beets github_username: mopidy short_description: Mopidy extension for playing music from a music collection managed via Beets ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1777128352.8089757 mopidy_beets-5.0.0/.github/0000755000175100017510000000000015173151641015211 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1777128352.8115711 mopidy_beets-5.0.0/.github/workflows/0000755000175100017510000000000015173151641017246 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1777128348.0 mopidy_beets-5.0.0/.github/workflows/ci.yml0000644000175100017510000000300215173151634020361 0ustar00runnerrunnername: CI on: pull_request: push: branches: - main workflow_dispatch: jobs: build: name: Build runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v6 - uses: hynek/build-and-inspect-python-package@v2 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 container: ghcr.io/mopidy/ci:latest steps: - uses: actions/checkout@v6 - name: Fix home dir permissions to enable pip caching run: chown -R root /github/home - uses: actions/setup-python@v6 with: python-version: ${{ matrix.python }} cache: pip allow-prereleases: true - run: python -m pip install tox - run: python -m tox -e ${{ matrix.tox }} if: ${{ ! matrix.coverage }} - run: python -m tox -e ${{ matrix.tox }} -- --cov-report=xml if: ${{ matrix.coverage }} - uses: codecov/codecov-action@v5 if: ${{ matrix.coverage }} with: token: ${{ secrets.CODECOV_TOKEN }} ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1777128348.0 mopidy_beets-5.0.0/.github/workflows/release.yml0000644000175100017510000000101715173151634021412 0ustar00runnerrunnername: Release on: release: types: [published] jobs: release: runs-on: ubuntu-24.04 environment: name: pypi url: https://pypi.org/project/mopidy-beets/ permissions: id-token: write steps: - uses: actions/checkout@v6 - uses: hynek/build-and-inspect-python-package@v2 id: build - uses: actions/download-artifact@v4 with: name: ${{ steps.build.outputs.artifact-name }} path: dist - uses: pypa/gh-action-pypi-publish@release/v1 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1777128348.0 mopidy_beets-5.0.0/.gitignore0000644000175100017510000000012515173151634015641 0ustar00runnerrunner*.egg-info/ /*.lock /.*_cache/ /.coverage /.tox/ /.venv/ /build/ /dist/ __pycache__/ ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1777128348.0 mopidy_beets-5.0.0/.mailmap0000644000175100017510000000015115173151634015271 0ustar00runnerrunnerJanez Troha Janez Troha ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1777128348.0 mopidy_beets-5.0.0/LICENSE0000644000175100017510000000205715173151634014664 0ustar00runnerrunnerCopyright (C) 2013 Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1777128352.8152826 mopidy_beets-5.0.0/PKG-INFO0000644000175100017510000001201515173151641014745 0ustar00runnerrunnerMetadata-Version: 2.4 Name: mopidy-beets Version: 5.0.0 Summary: Mopidy extension for playing music from a music collection managed via Beets Author-email: Lars Kruse License: MIT Project-URL: Homepage, https://github.com/mopidy/mopidy-beets Classifier: Environment :: No Input/Output (Daemon) Classifier: Intended Audience :: End Users/Desktop Classifier: License :: OSI Approved :: MIT 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.0 Requires-Dist: pykka>=4.1 Requires-Dist: requests>=2.32 Dynamic: license-file # mopidy-beets [![Latest PyPI version](https://img.shields.io/pypi/v/mopidy-beets)](https://pypi.org/p/mopidy-beets) [![CI build status](https://img.shields.io/github/actions/workflow/status/mopidy/mopidy-beets/ci.yml)](https://github.com/mopidy/mopidy-beets/actions/workflows/ci.yml) [![Test coverage](https://img.shields.io/codecov/c/gh/mopidy/mopidy-beets)](https://codecov.io/gh/mopidy/mopidy-beets) [Mopidy](https://mopidy.com/) extension for playing music from a music collection managed via [Beets](https://beets.io/). This extension uses the [Beets plugin "web"](https://beets.readthedocs.io/en/latest/plugins/web.html). ## Installation Install by running: ```sh python3 -m pip install mopidy-beets ``` See https://mopidy.com/ext/beets/ for alternative installation methods. ## Configuration 1. Setup the [Beets plugin "web"](https://beets.readthedocs.io/en/latest/plugins/web.html). 2. Tell Mopidy where to find the Beets web interface by adding the following to your `mopidy.conf`: ```ini [beets] hostname = 127.0.0.1 port = 8337 ``` 3. Restart Mopidy. The Beets library is now accessible in the "browser" section of your Mopidy client. Additional searches in Mopidy return results from your Beets library. ### Proxy configuration for OGG files (optional) In case you use a Beets version older than 1.6.1, you may need to configure an HTTP reverse-proxy server in front of the Beets web plugin (not Mopidy) because `it does not handle HTTP "Range" requests properly `_. If you don't apply this workaround, Mopidy may not be able to stream/play large audio files and/or does not allow you to seek. The is the case for OGG files in particular. The following Nginx configuration snippet is sufficient: ``` server { listen 127.0.0.1:8338; root /usr/share/beets/beetsplug/web; server_name beets.local; location / { proxy_pass http://localhost:8337; # this statement forces Nginx to emulate "Range" responses proxy_force_ranges on; # Hide Range header from beets/flask, preventing range handling proxy_set_header "Range" ""; } } ``` Now you should change the Mopidy configuration accordingly to point to the Nginx port above instead of the Beets port. Afterwards Mopidy will be able to play file formats that require seeking. ## Usage 1. Run `beet web` to start the Beets web interface. 2. Start Mopidy and access your Beets library via any Mopidy client: - Browse your collection by album - Search for tracks or albums - Let the music play! ## Project resources - [Source code](https://github.com/mopidy/mopidy-beets) - [Issues](https://github.com/mopidy/mopidy-beets/issues) - [Releases](https://github.com/mopidy/mopidy-beets/releases) ## Development ### Set up development environment Clone the repo using, e.g. using [gh](https://cli.github.com/): ```sh gh repo clone mopidy/mopidy-beets ``` Enter the directory, and install dependencies using [uv](https://docs.astral.sh/uv/): ```sh cd mopidy-beets/ 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-beets/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-beets/). ## Credits - Original author: [Janez Troha](https://github.com/dz0ny) - Current maintainer: [Lars Kruse](https://github.com/sumpfralle) - [Contributors](https://github.com/mopidy/mopidy-beets/graphs/contributors) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1777128348.0 mopidy_beets-5.0.0/README.md0000644000175100017510000001050015173151634015126 0ustar00runnerrunner# mopidy-beets [![Latest PyPI version](https://img.shields.io/pypi/v/mopidy-beets)](https://pypi.org/p/mopidy-beets) [![CI build status](https://img.shields.io/github/actions/workflow/status/mopidy/mopidy-beets/ci.yml)](https://github.com/mopidy/mopidy-beets/actions/workflows/ci.yml) [![Test coverage](https://img.shields.io/codecov/c/gh/mopidy/mopidy-beets)](https://codecov.io/gh/mopidy/mopidy-beets) [Mopidy](https://mopidy.com/) extension for playing music from a music collection managed via [Beets](https://beets.io/). This extension uses the [Beets plugin "web"](https://beets.readthedocs.io/en/latest/plugins/web.html). ## Installation Install by running: ```sh python3 -m pip install mopidy-beets ``` See https://mopidy.com/ext/beets/ for alternative installation methods. ## Configuration 1. Setup the [Beets plugin "web"](https://beets.readthedocs.io/en/latest/plugins/web.html). 2. Tell Mopidy where to find the Beets web interface by adding the following to your `mopidy.conf`: ```ini [beets] hostname = 127.0.0.1 port = 8337 ``` 3. Restart Mopidy. The Beets library is now accessible in the "browser" section of your Mopidy client. Additional searches in Mopidy return results from your Beets library. ### Proxy configuration for OGG files (optional) In case you use a Beets version older than 1.6.1, you may need to configure an HTTP reverse-proxy server in front of the Beets web plugin (not Mopidy) because `it does not handle HTTP "Range" requests properly `_. If you don't apply this workaround, Mopidy may not be able to stream/play large audio files and/or does not allow you to seek. The is the case for OGG files in particular. The following Nginx configuration snippet is sufficient: ``` server { listen 127.0.0.1:8338; root /usr/share/beets/beetsplug/web; server_name beets.local; location / { proxy_pass http://localhost:8337; # this statement forces Nginx to emulate "Range" responses proxy_force_ranges on; # Hide Range header from beets/flask, preventing range handling proxy_set_header "Range" ""; } } ``` Now you should change the Mopidy configuration accordingly to point to the Nginx port above instead of the Beets port. Afterwards Mopidy will be able to play file formats that require seeking. ## Usage 1. Run `beet web` to start the Beets web interface. 2. Start Mopidy and access your Beets library via any Mopidy client: - Browse your collection by album - Search for tracks or albums - Let the music play! ## Project resources - [Source code](https://github.com/mopidy/mopidy-beets) - [Issues](https://github.com/mopidy/mopidy-beets/issues) - [Releases](https://github.com/mopidy/mopidy-beets/releases) ## Development ### Set up development environment Clone the repo using, e.g. using [gh](https://cli.github.com/): ```sh gh repo clone mopidy/mopidy-beets ``` Enter the directory, and install dependencies using [uv](https://docs.astral.sh/uv/): ```sh cd mopidy-beets/ 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-beets/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-beets/). ## Credits - Original author: [Janez Troha](https://github.com/dz0ny) - Current maintainer: [Lars Kruse](https://github.com/sumpfralle) - [Contributors](https://github.com/mopidy/mopidy-beets/graphs/contributors) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1777128348.0 mopidy_beets-5.0.0/pyproject.toml0000644000175100017510000000640315173151634016572 0ustar00runnerrunner[project] name = "mopidy-beets" description = "Mopidy extension for playing music from a music collection managed via Beets" readme = "README.md" requires-python = ">= 3.13" license = { text = "MIT" } authors = [{ name = "Lars Kruse", email = "devel@sumpfralle.de" }] classifiers = [ "Environment :: No Input/Output (Daemon)", "Intended Audience :: End Users/Desktop", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Topic :: Multimedia :: Sound/Audio :: Players", ] dynamic = ["version"] dependencies = [ "mopidy >= 4.0.0", "pykka >= 4.1", "requests >= 2.32", ] [project.urls] Homepage = "https://github.com/mopidy/mopidy-beets" [project.entry-points."mopidy.ext"] beets = "mopidy_beets: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 = [ # We need the `beets.test` module, which is not part of a release, yet (2024-01) "beets@git+https://github.com/beetbox/beets.git@master", "flask", "pytest", "pytest-cov", "responses", "werkzeug", ] typing = ["pyright"] [tool.coverage.paths] source = ["src/", "*/site-packages/"] [tool.coverage.run] source_pkgs = ["mopidy_beets"] [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_beets", # # Add any warnings you want to ignore here ] [tool.ruff] target-version = "py313" [tool.ruff.lint] select = ["ALL"] ignore = [ # Add rules you want to ignore here "D", # pydocstyle "FIX002", # line-contains-todo # TODO "G004", # logging-f-string "TD002", # missing-todo-author "TD003", # missing-todo-link # # 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 "S101", # assert ] [tool.setuptools.package-data] "*" = ["*.conf"] [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:.}"]] ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1777128352.815546 mopidy_beets-5.0.0/setup.cfg0000644000175100017510000000004615173151641015472 0ustar00runnerrunner[egg_info] tag_build = tag_date = 0 ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1777128352.8089757 mopidy_beets-5.0.0/src/0000755000175100017510000000000015173151641014440 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1777128352.8125048 mopidy_beets-5.0.0/src/mopidy_beets/0000755000175100017510000000000015173151641017123 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1777128348.0 mopidy_beets-5.0.0/src/mopidy_beets/__init__.py0000644000175100017510000000133415173151634021237 0ustar00runnerrunnerimport pathlib from importlib.metadata import version from mopidy import config, ext __version__ = version("mopidy-beets") class Extension(ext.Extension): dist_name = "mopidy-beets" ext_name = "beets" 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["hostname"] = config.Hostname() schema["port"] = config.Port() return schema def setup(self, registry: ext.Registry) -> None: from mopidy_beets.actor import BeetsBackend # noqa: PLC0415 registry.add("backend", BeetsBackend) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1777128348.0 mopidy_beets-5.0.0/src/mopidy_beets/actor.py0000644000175100017510000000250515173151634020611 0ustar00runnerrunnerfrom __future__ import annotations import logging from typing import TYPE_CHECKING, ClassVar, override import pykka from mopidy import backend from mopidy.types import Uri, UriScheme from .client import BeetsRemoteClient from .library import BeetsLibraryProvider if TYPE_CHECKING: from mopidy.audio import AudioProxy from mopidy.config import Config logger = logging.getLogger(__name__) class BeetsBackend(pykka.ThreadingActor, backend.Backend): uri_schemes: ClassVar[list[UriScheme]] = [UriScheme("beets")] @override def __init__(self, *, config: Config, audio: AudioProxy) -> None: super().__init__(config=config, audio=audio) beets_endpoint = ( f"http://{config['beets']['hostname']}:{config['beets']['port']}" ) self.beets_api = BeetsRemoteClient(beets_endpoint, config["proxy"]) self.library = BeetsLibraryProvider(backend=self) self.playback = BeetsPlaybackProvider(audio=audio, backend=self) self.playlists = None class BeetsPlaybackProvider(backend.PlaybackProvider): backend: BeetsBackend @override def translate_uri(self, uri: Uri) -> Uri | None: track_id = uri.split(";")[1] logger.debug(f"Getting info for track {uri} with id {track_id}") return self.backend.beets_api.get_track_stream_url(track_id) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1777128352.8137102 mopidy_beets-5.0.0/src/mopidy_beets/browsers/0000755000175100017510000000000015173151641020771 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1777128348.0 mopidy_beets-5.0.0/src/mopidy_beets/browsers/__init__.py0000644000175100017510000000167015173151634023110 0ustar00runnerrunnerfrom __future__ import annotations from typing import TYPE_CHECKING if TYPE_CHECKING: from mopidy.models import Ref from mopidy_beets.client import BeetsRemoteClient class GenericBrowserBase: def __init__(self, ref: Ref, api: BeetsRemoteClient) -> None: self.ref = ref self.api = api def get_toplevel(self) -> list[Ref]: """deliver the top level directories or tracks for this browser The result is a list of ``mopidy.models.Ref`` objects. Usually this list contains entries like "genre" or other categories. """ raise NotImplementedError def get_directory(self, key: str) -> list[Ref]: """deliver the corresponding sub items for a given category key The result is a list of ``mopidy.models.Ref`` objects. Usually this list contains tracks or albums belonging to the given category 'key'. """ raise NotImplementedError ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1777128348.0 mopidy_beets-5.0.0/src/mopidy_beets/browsers/albums.py0000644000175100017510000000440315173151634022631 0ustar00runnerrunnerfrom __future__ import annotations from typing import ClassVar, override from mopidy.models import Album, Ref from mopidy_beets.browsers import GenericBrowserBase from mopidy_beets.translator import assemble_uri class AlbumsCategoryBrowser(GenericBrowserBase): field: ClassVar[str] sort_fields: ClassVar[tuple[str, ...]] def get_toplevel(self) -> list[Ref]: keys = self.api.get_sorted_unique_album_attributes(self.field) return [ Ref.directory( name=str(k), uri=assemble_uri(self.ref.uri, id_value=k), ) for k in sorted(keys) ] def get_directory(self, key: str) -> list[Ref]: albums = self.api.get_albums_by( [(self.field, key)], exact_text=True, sort_fields=self.sort_fields, ) return [ Ref.album( uri=a.uri, name=self._get_label(a), ) for a in albums if a.uri is not None ] def _get_label(self, album: Album) -> str | None: raise NotImplementedError class AlbumsByArtistBrowser(AlbumsCategoryBrowser): field = "albumartist" sort_fields = ("original_year+", "year+", "album+") @override def _get_label(self, album: Album) -> str | None: return album.name class AlbumsByGenreBrowser(AlbumsCategoryBrowser): field = "genre" sort_fields = ("albumartist", "original_year+", "year+", "album+") @override def _get_label(self, album: Album) -> str | None: artists = " / ".join([a.name for a in album.artists if a.name is not None]) if artists and album.date: return f"{artists} - {album.name} ({album.date.split('-')[0]})" if artists: return f"{artists} - {album.name}" return album.name class AlbumsByYearBrowser(AlbumsCategoryBrowser): field = "year" sort_fields = ( "original_month+", "original_day+", "month+", "day+", "album+", ) @override def _get_label(self, album: Album) -> str | None: artists = " / ".join([a.name for a in album.artists if a.name is not None]) if artists: return f"{artists} - {album.name}" return album.name ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1777128348.0 mopidy_beets-5.0.0/src/mopidy_beets/client.py0000644000175100017510000002763215173151634020767 0ustar00runnerrunnerfrom __future__ import annotations import logging import re import time import urllib.parse import urllib.request from http import HTTPStatus from typing import TYPE_CHECKING, Any import requests from mopidy import httpclient from mopidy.models import Album, Track from mopidy.types import DistinctField, Uri from requests.exceptions import RequestException import mopidy_beets from mopidy_beets.translator import parse_album, parse_track if TYPE_CHECKING: from collections.abc import Callable, Iterable from mopidy.config import ProxyConfig logger = logging.getLogger(__name__) class cache: # noqa: N801 def __init__( self, ctl: int = 8, ttl: int = 3600, ) -> None: self.cache: dict[tuple[Any, ...], tuple[Any, float]] = {} self.ctl = ctl self.ttl = ttl self._call_count = 1 self.func: Callable[..., Any] | None = None def __call__[**P, R](self, func: Callable[P, R]) -> Callable[P, R]: def _memoized(*args: P.args, **kwargs: P.kwargs) -> R: self.func = func key = (args, tuple(sorted(kwargs.items()))) now = time.time() try: value, last_update = self.cache[key] age = now - last_update if self._call_count >= self.ctl or age > self.ttl: self._call_count = 1 raise AttributeError # noqa: TRY301 self._call_count += 1 except (KeyError, AttributeError): value = func(*args, **kwargs) self.cache[key] = (value, now) return value except TypeError: return func(*args, **kwargs) else: return value return _memoized class BeetsRemoteClient: def __init__( self, endpoint: str, proxy_config: ProxyConfig, request_timeout: int = 4, ) -> None: super().__init__() self._request_timeout = request_timeout self.api = self._get_session(proxy_config) self.api_endpoint = endpoint logger.info("Configured for Beets remote library %s", endpoint) def _get_session(self, proxy_config: ProxyConfig) -> requests.Session: session = requests.Session() session.headers["user-agent"] = httpclient.format_user_agent( f"{mopidy_beets.Extension.dist_name}/{mopidy_beets.Extension.version}" ) if proxy := httpclient.format_proxy(proxy_config): session.proxies.update({"http": proxy, "https": proxy}) # pyright: ignore[reportCallIssue] return session @cache() def get_tracks(self) -> list[Track]: if (result := self._get("/item/")) is None: return [] track_ids = result.get("item_ids") or [] return [track for track_id in track_ids if (track := self.get_track(track_id))] @cache(ctl=16) def get_track(self, track_id: str | int) -> Track | None: if (result := self._get(f"/item/{track_id}")) is None: return None return parse_track(result, self) @cache(ctl=16) def get_album(self, album_id: str | int) -> Album | None: if (result := self._get(f"/album/{album_id}")) is None: return None return parse_album(result, self) @cache() def get_tracks_by( self, attributes: list[tuple[str, str | int]], *, exact_text: bool, sort_fields: Iterable[str], ) -> list[Track]: tracks = self._get_objects_by_attribute( "/item", attributes, exact_text=exact_text, sort_fields=sort_fields, ) return self._parse_multiple_tracks(tracks) @cache() def get_albums_by( self, attributes: list[tuple[str, str | int]], *, exact_text: bool, sort_fields: Iterable[str], ) -> list[Album]: albums = self._get_objects_by_attribute( "/album", attributes, exact_text=exact_text, sort_fields=sort_fields, ) return self._parse_multiple_albums(albums) def _get_objects_by_attribute( # noqa: C901, PLR0912 self, base_path: str, attributes: list[tuple[str, str | int]], *, exact_text: bool, sort_fields: Iterable[str], ) -> list[dict[str, Any]]: """The beets web-api accepts queries like: /item/query/album_id:183/track:2 /item/query/album:Foo /album/query/track_no:12/year+/month+ Text-based matches (e.g. 'album' or 'artist') are case-independent 'is in' matches. Thus we need to filter the result, since we want exact matches. @param attributes: attributes to be matched @type attribute: list of key/value pairs or strings @param exact_text: True for exact matches, False for case-insensitive 'is in' matches (only relevant for text values - not integers) @param sort_fields: fieldnames, each followed by '+' or '-' @rtype: list of json datasets describing tracks or albums """ # assemble the query string query_parts = [] # only used for 'exact_text' exact_query_list = [] def quote_and_encode(text: str | float) -> str: if isinstance(text, (int, float)): text = str(text) # Escape colons. The beets web API uses the colon to separate # field name and search term. text = text.replace(":", r"\:") # quoting for the query string return urllib.parse.quote(text) for attribute in attributes: if isinstance(attribute, str): query_parts.append(quote_and_encode(attribute)) exact_query_list.append((None, attribute)) else: # the beets API accepts upper and lower case, but always # returns lower case attributes key = attribute[0].lower() value = attribute[1] query_parts.append(f"{quote_and_encode(key)}:{quote_and_encode(value)}") # Try to add a simple regex filter, if we look for a string. # This will reduce the resource consumption of the query on # the server side (and for our 'exact' matching below). if exact_text and isinstance(value, str): regex_query = f"^{re.escape(value)}$" beets_query = ( f"{quote_and_encode(key)}::{quote_and_encode(regex_query)}" ) logger.debug(f"Beets - regular expression query: {beets_query}") query_parts.append(beets_query) else: # in all other cases: use non-regex matching (if requested) exact_query_list.append((key, value)) # add sorting fields for sort_field in sort_fields or []: if (len(sort_field) > 1) and (sort_field[-1] in ("-", "+")): query_parts.append(quote_and_encode(sort_field)) else: logger.info("Beets - invalid sorting field ignore: %s", sort_field) query_string = "/".join(query_parts) query_url = f"{base_path}/query/{query_string}" logger.debug("Beets query: %s", query_url) if (result := self._get(query_url)) is None: return [] items = result["results"] if exact_text: # verify that text attributes do not just test 'is in', but match # equality for key, value in exact_query_list: if key is None: # the value must match one of the item attributes items = [item for item in items if value in item.values()] # filtering is necessary only for text based attributes elif items and isinstance(items[0][key], str): items = [item for item in items if item[key] == value] return items @cache() def get_artists(self) -> list[str]: """returns all artists of one or more tracks""" if (result := self._get("/artist/")) is None: return [] return [name for name in sorted(result["artist_names"]) if name] def get_sorted_unique_track_attributes(self, field: DistinctField) -> set[str]: sort_field = {"albumartist": "albumartist_sort"}.get(field, field) return self._get_unique_attribute_values("/item", field, sort_field) def get_sorted_unique_album_attributes(self, field: str) -> set[str]: # Modern Beets exposes the multi-valued "genres" on albums (singular # "genre" was removed after the 2.x series); fall through to the # plural key so both /album/values/... and the legacy fallback work. field = {"genre": "genres"}.get(field, field) sort_field = {"albumartist": "albumartist_sort"}.get(field, field) return self._get_unique_attribute_values("/album", field, sort_field) @cache(ctl=32) def _get_unique_attribute_values( self, base_url: str, field: str, sort_field: str, ) -> set[str]: """Returns all artists, genres, ... of tracks or albums""" result = self._get( f"{base_url}/values/{field}?sort_key={sort_field}", raise_not_found=True, ) return set(result["values"]) if result else set() def get_track_stream_url(self, track_id: str) -> Uri: return Uri(f"{self.api_endpoint}/item/{track_id}/file") @cache(ctl=32) def get_album_art_url(self, album_id: str) -> Uri | None: # Sadly we cannot determine, if the Beets library really contains album # art. Thus we need to ask for it and check the status code. url = f"{self.api_endpoint}/album/{album_id}/art" try: request = urllib.request.urlopen(url) # noqa: S310 except OSError: # DNS problem or similar return None request.close() return Uri(url) if request.getcode() == HTTPStatus.OK else None def _get(self, url: str, *, raise_not_found: bool = False) -> dict[str, Any] | None: url = self.api_endpoint + url logger.debug(f"Beets - requesting {url}") try: req = self.api.get(url, timeout=self._request_timeout) except RequestException as e: logger.error(f"Beets - Request {url}, failed with error {e}") # noqa: TRY400 return None if req.status_code != HTTPStatus.OK: logger.error( "Beets - Request %s, failed with status code %s", url, req.status_code, ) if (req.status_code == HTTPStatus.NOT_FOUND) and raise_not_found: # sometimes we need to distinguish empty and 'not found' msg = f"URL not found: {url}" raise KeyError(msg) return None return req.json() def _parse_multiple_albums( self, album_datasets: Iterable[dict[str, Any]] | None, ) -> list[Album]: albums = list[Album]() for dataset in album_datasets or []: try: if album := parse_album(dataset, self): albums.append(album) except (ValueError, KeyError) as exc: logger.info(f"Beets - Failed to parse album data: {exc}") return [album for album in albums if album] def _parse_multiple_tracks( self, track_datasets: Iterable[dict[str, Any]] | None, ) -> list[Track]: tracks = list[Track]() for dataset in track_datasets or []: try: if track := parse_track(dataset, self): tracks.append(track) except (ValueError, KeyError) as exc: logger.info(f"Beets - Failed to parse track data: {exc}") return [track for track in tracks if track] ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1777128348.0 mopidy_beets-5.0.0/src/mopidy_beets/ext.conf0000644000175100017510000000007015173151634020571 0ustar00runnerrunner[beets] enabled = true hostname = 127.0.0.1 port = 8337 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1777128348.0 mopidy_beets-5.0.0/src/mopidy_beets/library.py0000644000175100017510000002045215173151634021146 0ustar00runnerrunnerfrom __future__ import annotations import logging import re from typing import TYPE_CHECKING, Any, ClassVar, override from mopidy import backend from mopidy.models import Ref, SearchResult, Track from mopidy.types import DistinctField, Query, SearchField, Uri from mopidy_beets.browsers.albums import ( AlbumsByArtistBrowser, AlbumsByGenreBrowser, AlbumsByYearBrowser, ) from mopidy_beets.translator import assemble_uri, parse_uri if TYPE_CHECKING: from mopidy_beets.actor import BeetsBackend from mopidy_beets.browsers import GenericBrowserBase from mopidy_beets.client import BeetsRemoteClient logger = logging.getLogger(__name__) # match dates of the following format: # YYYY, YYYY-MM, YYYY-MM-DD, YYYY/MM, YYYY/MM/DD DATE_REGEX = re.compile( r"^(?P\d{4})(?:[-/](?P\d{1,2})(?:[-/](?P\d{1,2}))?)?$" ) class BeetsLibraryProvider(backend.LibraryProvider): root_directory = Ref.directory( uri=Uri("beets:library"), name="Beets library", ) root_categorie_list: ClassVar[list[tuple[str, str, type[GenericBrowserBase]]]] = [ ("albums-by-artist", "Albums by Artist", AlbumsByArtistBrowser), ("albums-by-genre", "Albums by Genre", AlbumsByGenreBrowser), ("albums-by-year", "Albums by Year", AlbumsByYearBrowser), ] backend: BeetsBackend remote: BeetsRemoteClient @override def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) assert self.root_directory # noqa: S101 self.remote = self.backend.beets_api self.category_browsers = [] for key, label, browser_class in self.root_categorie_list: ref = Ref.directory( name=label, uri=assemble_uri(self.root_directory.uri, key), ) browser = browser_class(ref, self.remote) self.category_browsers.append(browser) @override def browse(self, uri: Uri) -> list[Ref]: # noqa: PLR0911 logger.debug("Browsing Beets at: %s", uri) assert self.root_directory # noqa: S101 path, item_id = parse_uri(uri, uri_prefix=self.root_directory.uri) if path is None: logger.error("Beets - failed to parse uri: %s", uri) return [] if uri == self.root_directory.uri: # top level - show the categories refs = [browser.ref for browser in self.category_browsers] refs.sort(key=lambda item: item.name) return refs if path == "album": # show an album if item_id is None: return [] try: album_id = int(item_id) except ValueError: logger.error(f"Beets - invalid album ID in URI: {uri}") # noqa: TRY400 return [] tracks = self.remote.get_tracks_by( [("album_id", album_id)], exact_text=True, sort_fields=["track+"], ) return [Ref.track(uri=t.uri, name=t.name) for t in tracks] # show a generic category directory for browser in self.category_browsers: if ( path == parse_uri(browser.ref.uri, uri_prefix=self.root_directory.uri)[0] ): if item_id is None: return browser.get_toplevel() return browser.get_directory(item_id) logger.error("Beets - Invalid browse URI: %s / %s", uri, path) return [] @override def search( # noqa: C901, PLR0912 self, query: Query[SearchField], uris: list[Uri] | None = None, exact: bool = False, ) -> SearchResult: # TODO: restrict the result to 'uris' logger.debug('Beets Query (exact=%s) within "%s": %s', exact, uris, query) self._validate_query(query) search_list = [] for field, values in query.items(): for val in values: # missing / unsupported fields: uri, performer if field == "any": search_list.append(val) elif field == "album": search_list.append(("album", val)) elif field == "artist": search_list.append(("artist", val)) elif field == "albumartist": search_list.append(("albumartist", val)) elif field == "track_name": search_list.append(("title", val)) elif field == "track_no": search_list.append(("track", val)) elif field == "composer": search_list.append(("composer", val)) elif field == "genre": search_list.append(("genre", val)) elif field == "comment": search_list.append(("comments", val)) elif field == "date": # supported date formats: YYYY, YYYY-MM, YYYY-MM-DD # Days and months may consist of one or two digits. # A slash (instead of a dash) is acceptable as a separator. match = DATE_REGEX.search(str(val)) if match: # remove None values for key, value in match.groupdict().items(): if value: search_list.append((key, int(value))) else: logger.info( "Beets search: ignoring unknown date format (%s). " 'It should be "YYYY", "YYYY-MM" or "YYYY-MM-DD".', val, ) else: logger.info("Beets: ignoring unknown query key: %s", field) break logger.debug("Beets search query: %s", search_list) tracks = self.remote.get_tracks_by( search_list, exact_text=exact, sort_fields=[], ) uri = "-".join( [ item if isinstance(item, str) else "=".join(map(str, item)) for item in search_list ] ) return SearchResult( uri=Uri(f"beets:search-{uri}"), tracks=tuple(tracks), ) @override def lookup(self, uri: Uri) -> list[Track]: logger.debug("Beets lookup: %s", uri) assert self.root_directory # noqa: S101 path, item_id = parse_uri(uri, uri_prefix=self.root_directory.uri) if item_id is None: logger.info(f"Unknown item ID in Beets lookup URI: {uri}") return [] if path == "track": tracks = [self.remote.get_track(item_id)] elif path == "album": tracks = self.remote.get_tracks_by( [("album_id", item_id)], exact_text=True, sort_fields=("disc+", "track+"), ) elif path == "artist": artist_tracks = self.remote.get_tracks_by( [("artist", item_id)], exact_text=True, sort_fields=[], ) composer_tracks = self.remote.get_tracks_by( [("composer", item_id)], exact_text=True, sort_fields=[], ) # Append composer tracks to the artist tracks (unique items). tracks = list(set(artist_tracks + composer_tracks)) tracks.sort(key=lambda t: (t.date or 0, t.disc_no or 0, t.track_no or 0)) else: logger.info("Unknown Beets lookup URI: %s", uri) tracks = [] # remove occourences of None return [t for t in tracks if t] @override def get_distinct( self, field: DistinctField, query: Query[SearchField] | None = None, ) -> set[str]: logger.debug("Beets distinct query: %s (uri=%s)", field, query) return self.remote.get_sorted_unique_track_attributes(field) def _validate_query(self, query: Query[SearchField]) -> None: for values in query.values(): if not values: msg = "Missing query" raise LookupError(msg) for value in values: if not value: msg = "Missing query" raise LookupError(msg) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1777128348.0 mopidy_beets-5.0.0/src/mopidy_beets/translator.py0000644000175100017510000001471315173151634021676 0ustar00runnerrunnerfrom __future__ import annotations import logging import urllib.parse from typing import TYPE_CHECKING, Any from mopidy.models import Album, Artist, Track from mopidy.types import Uri if TYPE_CHECKING: from collections.abc import Iterable from mopidy_beets.client import BeetsRemoteClient logger = logging.getLogger(__name__) def parse_date(data: dict[str, Any]) -> str | None: # use 'original' dates if possible if "original_year" in data: day = data.get("original_day") month = data.get("original_month") year = data.get("original_year") elif "year" in data: day = data.get("day") month = data.get("month") year = data.get("year") else: return None # mopidy accepts dates as 'YYYY' or 'YYYY-MM-DD' if day is not None and month is not None: return f"{year:04d}-{month:02d}-{day:02d}" return f"{year:04d}" def _apply_beets_mapping[T]( target_class: type[T], mapping: dict[str, str], data: dict[str, Any], ) -> T | None: """evaluate a mapping of target keys and their source keys or callables 'target_class' is the Mopidy model to be used for creating the item. 'mapping' is a dict of {'target': source}. Here 'source' could be one of the following types: * string: the key for the corresponding value in 'data' * callable: a function with a dict ('data') as its only parameter """ kwargs: dict[str, Any] = {} for key, map_value in mapping.items(): if map_value is None: value = None elif callable(map_value): value = map_value(data) else: value = data.get(map_value) # ignore None, empty strings or zeros (e.g. for length) if value: kwargs[key] = value return target_class(**kwargs) if kwargs else None def _filter_none[T](values: Iterable[T | None]) -> list[T]: return [value for value in values if value is not None] def parse_artist(data: dict[str, Any], name_keyword: str) -> Artist | None: # see https://docs.mopidy.com/en/latest/api/models/#mopidy.models.Artist mapping = { "uri": lambda d: assemble_uri("beets:library:artist", id_value=d[name_keyword]), "name": name_keyword, } if name_keyword == "artist": mapping["sortname"] = "artist_sort" mapping["musicbrainz_id"] = "mb_artistid" elif name_keyword == "albumartist": mapping["sortname"] = "albumartist_sort" mapping["musicbrainz_id"] = "mb_albumartistid" else: # others - e.g. composers pass return _apply_beets_mapping(Artist, mapping, data) def parse_album(data: dict[str, Any], _api: BeetsRemoteClient) -> Album | None: # see https://docs.mopidy.com/en/latest/api/models/#mopidy.models.Album # The order of items is based on the above documentation. # Attributes without corresponding Beets data are mapped to 'None'. mapping = { "uri": lambda d: assemble_uri("beets:library:album", id_value=d["id"]), "name": "album", "artists": lambda d: _filter_none([parse_artist(d, "albumartist")]), "num_tracks": "tracktotal", "num_discs": "disctotal", "date": parse_date, "musicbrainz_id": "mb_albumid", } return _apply_beets_mapping(Album, mapping, data) def parse_track(data: dict[str, Any], api: BeetsRemoteClient) -> Track | None: # see https://docs.mopidy.com/en/latest/api/models/#mopidy.models.Track # The order of items is based on the above documentation. # Attributes without corresponding Beets data are mapped to 'None'. mapping = { "uri": lambda d: f"beets:library:track;{d['id']}", "name": "title", "artists": lambda d: _filter_none([parse_artist(d, "artist")]), "album": lambda d, api=api: ( api.get_album(d["album_id"]) if d.get("album_id") else None ), "composers": lambda d: _filter_none([parse_artist(d, "composer")]), "performers": None, "genre": "genre", "track_no": "track", "disc_no": "disc", "date": parse_date, "length": lambda d: int(d.get("length", 0) * 1000), "bitrate": lambda d: int(d.get("bitrate", 0) / 1000), "comment": "comments", "musicbrainz_id": "mb_trackid", "last_modified": lambda d: int(d.get("mtime", 0)), } return _apply_beets_mapping(Track, mapping, data) def parse_uri( uri: Uri, uri_prefix: str | None = None, ) -> tuple[str | None, str | int | None]: """Split a URI into an optional prefix and a value. The format of a uri is similar to this: beets:library:album;Foo%20Bar (note the ampersand separating the value from the path) uri_prefix (optional): * remove the string from the beginning of uri * the match is valid only if the prefix is separated from the remainder of the URI with a colon, an ampersand or it is equal to the full URI * the function returns 'None' if the uri_prefix cannot be removed (you should consider this an error condition) The result of the function is a tuple of the uri and the id value. In case of an error the result is simply None. """ if ";" in uri: result_uri, id_string = uri.split(";", 1) else: result_uri, id_string = uri, None last_path_token = result_uri.split(":")[-1] if uri_prefix: if uri == uri_prefix: result_uri = "" elif result_uri.startswith(uri_prefix + ":"): result_uri = result_uri[len(uri_prefix) + 1 :] else: # this prefix cannot be split logger.info("Failed to remove URI prefix (%s): %s", uri_prefix, uri) return None, None if id_string is not None: id_value = urllib.parse.unquote(id_string) # convert track and album IDs to int if last_path_token in ("track", "album"): try: id_value = int(id_value) except ValueError: logger.info("Failed to parse integer ID from uri: %s", uri) return None, None else: id_value = None return result_uri, id_value def assemble_uri(*args: str, id_value: str | None = None) -> Uri: base_path = ":".join(args) if id_value is None: return Uri(base_path) # convert numbers and other non-strings if not isinstance(id_value, str): id_value = str(id_value) id_string = urllib.parse.quote(id_value) return Uri(f"{base_path};{id_string}") ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1777128352.8148892 mopidy_beets-5.0.0/src/mopidy_beets.egg-info/0000755000175100017510000000000015173151641020615 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1777128352.0 mopidy_beets-5.0.0/src/mopidy_beets.egg-info/PKG-INFO0000644000175100017510000001201515173151640021710 0ustar00runnerrunnerMetadata-Version: 2.4 Name: mopidy-beets Version: 5.0.0 Summary: Mopidy extension for playing music from a music collection managed via Beets Author-email: Lars Kruse License: MIT Project-URL: Homepage, https://github.com/mopidy/mopidy-beets Classifier: Environment :: No Input/Output (Daemon) Classifier: Intended Audience :: End Users/Desktop Classifier: License :: OSI Approved :: MIT 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.0 Requires-Dist: pykka>=4.1 Requires-Dist: requests>=2.32 Dynamic: license-file # mopidy-beets [![Latest PyPI version](https://img.shields.io/pypi/v/mopidy-beets)](https://pypi.org/p/mopidy-beets) [![CI build status](https://img.shields.io/github/actions/workflow/status/mopidy/mopidy-beets/ci.yml)](https://github.com/mopidy/mopidy-beets/actions/workflows/ci.yml) [![Test coverage](https://img.shields.io/codecov/c/gh/mopidy/mopidy-beets)](https://codecov.io/gh/mopidy/mopidy-beets) [Mopidy](https://mopidy.com/) extension for playing music from a music collection managed via [Beets](https://beets.io/). This extension uses the [Beets plugin "web"](https://beets.readthedocs.io/en/latest/plugins/web.html). ## Installation Install by running: ```sh python3 -m pip install mopidy-beets ``` See https://mopidy.com/ext/beets/ for alternative installation methods. ## Configuration 1. Setup the [Beets plugin "web"](https://beets.readthedocs.io/en/latest/plugins/web.html). 2. Tell Mopidy where to find the Beets web interface by adding the following to your `mopidy.conf`: ```ini [beets] hostname = 127.0.0.1 port = 8337 ``` 3. Restart Mopidy. The Beets library is now accessible in the "browser" section of your Mopidy client. Additional searches in Mopidy return results from your Beets library. ### Proxy configuration for OGG files (optional) In case you use a Beets version older than 1.6.1, you may need to configure an HTTP reverse-proxy server in front of the Beets web plugin (not Mopidy) because `it does not handle HTTP "Range" requests properly `_. If you don't apply this workaround, Mopidy may not be able to stream/play large audio files and/or does not allow you to seek. The is the case for OGG files in particular. The following Nginx configuration snippet is sufficient: ``` server { listen 127.0.0.1:8338; root /usr/share/beets/beetsplug/web; server_name beets.local; location / { proxy_pass http://localhost:8337; # this statement forces Nginx to emulate "Range" responses proxy_force_ranges on; # Hide Range header from beets/flask, preventing range handling proxy_set_header "Range" ""; } } ``` Now you should change the Mopidy configuration accordingly to point to the Nginx port above instead of the Beets port. Afterwards Mopidy will be able to play file formats that require seeking. ## Usage 1. Run `beet web` to start the Beets web interface. 2. Start Mopidy and access your Beets library via any Mopidy client: - Browse your collection by album - Search for tracks or albums - Let the music play! ## Project resources - [Source code](https://github.com/mopidy/mopidy-beets) - [Issues](https://github.com/mopidy/mopidy-beets/issues) - [Releases](https://github.com/mopidy/mopidy-beets/releases) ## Development ### Set up development environment Clone the repo using, e.g. using [gh](https://cli.github.com/): ```sh gh repo clone mopidy/mopidy-beets ``` Enter the directory, and install dependencies using [uv](https://docs.astral.sh/uv/): ```sh cd mopidy-beets/ 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-beets/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-beets/). ## Credits - Original author: [Janez Troha](https://github.com/dz0ny) - Current maintainer: [Lars Kruse](https://github.com/sumpfralle) - [Contributors](https://github.com/mopidy/mopidy-beets/graphs/contributors) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1777128352.0 mopidy_beets-5.0.0/src/mopidy_beets.egg-info/SOURCES.txt0000644000175100017510000000141615173151640022502 0ustar00runnerrunner.copier-answers.yml .gitignore .mailmap LICENSE README.md pyproject.toml .github/workflows/ci.yml .github/workflows/release.yml src/mopidy_beets/__init__.py src/mopidy_beets/actor.py src/mopidy_beets/client.py src/mopidy_beets/ext.conf src/mopidy_beets/library.py src/mopidy_beets/translator.py src/mopidy_beets.egg-info/PKG-INFO src/mopidy_beets.egg-info/SOURCES.txt src/mopidy_beets.egg-info/dependency_links.txt src/mopidy_beets.egg-info/entry_points.txt src/mopidy_beets.egg-info/requires.txt src/mopidy_beets.egg-info/top_level.txt src/mopidy_beets/browsers/__init__.py src/mopidy_beets/browsers/albums.py tests/__init__.py tests/helper_beets.py tests/test_beets_api.py tests/test_extension.py tests/test_library.py tests/data/beets-rsrc/README.md tests/data/beets-rsrc/min.mp3././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1777128352.0 mopidy_beets-5.0.0/src/mopidy_beets.egg-info/dependency_links.txt0000644000175100017510000000000115173151640024662 0ustar00runnerrunner ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1777128352.0 mopidy_beets-5.0.0/src/mopidy_beets.egg-info/entry_points.txt0000644000175100017510000000005415173151640024111 0ustar00runnerrunner[mopidy.ext] beets = mopidy_beets:Extension ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1777128352.0 mopidy_beets-5.0.0/src/mopidy_beets.egg-info/requires.txt0000644000175100017510000000005015173151640023207 0ustar00runnerrunnermopidy>=4.0.0 pykka>=4.1 requests>=2.32 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1777128352.0 mopidy_beets-5.0.0/src/mopidy_beets.egg-info/top_level.txt0000644000175100017510000000001515173151640023342 0ustar00runnerrunnermopidy_beets ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1777128352.8144543 mopidy_beets-5.0.0/tests/0000755000175100017510000000000015173151641015013 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1777128348.0 mopidy_beets-5.0.0/tests/__init__.py0000644000175100017510000000116715173151634017133 0ustar00runnerrunnerimport logging import unittest from pathlib import Path from mopidy_beets.actor import BeetsBackend TEST_DATA_DIRECTORY = Path(__file__).parent.resolve() / "data" class MopidyBeetsTest(unittest.TestCase): @staticmethod def get_config(): config = {} config["enabled"] = True config["hostname"] = "example.org" config["port"] = 8337 return {"beets": config, "proxy": {}} def setUp(self): super().setUp() logging.getLogger("mopidy_beets.library").disabled = True config = self.get_config() self.backend = BeetsBackend(config=config, audio=None) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1777128352.8096373 mopidy_beets-5.0.0/tests/data/0000755000175100017510000000000015173151641015724 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1777128352.8147357 mopidy_beets-5.0.0/tests/data/beets-rsrc/0000755000175100017510000000000015173151641017775 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1777128348.0 mopidy_beets-5.0.0/tests/data/beets-rsrc/README.md0000644000175100017510000000071415173151634021260 0ustar00runnerrunner# Minimal replication of beets' ressource directory The `BeetsHelper.add_fixture` method relies on a file named `min.mp3` to exist in the *beets* ressource directory. By default *beets* assumes its ressource directory to be located right below the `test/` directory in the *beets* repository. But this path is not part of the *beets* package. Thus, we add the minimal amount of necessary files and manipulate the location stored in `beets.test._common.RSRC`. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1777128348.0 mopidy_beets-5.0.0/tests/data/beets-rsrc/min.mp30000644000175100017510000000000015173151634021171 0ustar00runnerrunner././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1777128348.0 mopidy_beets-5.0.0/tests/helper_beets.py0000644000175100017510000001127115173151634020032 0ustar00runnerrunnerimport logging import random import threading import time from typing import ClassVar, NamedTuple import beets.test._common import werkzeug.serving from beets.test.helper import PluginMixin as BeetsPluginMixin from beets.test.helper import TestHelper as BeetsTestHelper from beets.util import bytestring_path from beetsplug.web import app as beets_web_app from . import TEST_DATA_DIRECTORY, MopidyBeetsTest class BeetsTrack(NamedTuple): title: str artist: str | None = None track: int | None = None class BeetsAlbum(NamedTuple): title: str artist: str tracks: list genre: str = "" year: int = 0 # Manipulate beets's ressource path before any action wants to access these files. beets.test._common.RSRC = bytestring_path(TEST_DATA_DIRECTORY / "beets-rsrc") # noqa: SLF001 class BeetsLibrary(BeetsPluginMixin, BeetsTestHelper): """Provide a temporary Beets library for testing against a real Beets web plugin.""" db_on_disk = True def __init__( self, bind_host: str = "127.0.0.1", bind_port: int | None = None, ) -> None: self._app = beets_web_app # allow exceptions to propagate to the caller of the test client self._app.testing = True self._bind_host = bind_host if bind_port is None: self._bind_port = random.randint(10000, 32767) # noqa: S311 else: self._bind_port = bind_port self._server = None self.setup_beets() self._app.config["lib"] = self.lib self.load_plugins("web") # prepare the server instance self._server = werkzeug.serving.make_server( self._bind_host, self._bind_port, self._app ) self._server_thread = threading.Thread(target=self._server.serve_forever) def start(self): self._server_thread.start() # wait for the server to be ready while self._server is None: time.sleep(0.1) def stop(self): if self._server_thread is not None: self._server.shutdown() self._server_thread.join() self._server_thread = None self.teardown_beets() def get_connection_pair(self): return (self._bind_host, self._bind_port) class BeetsAPILibraryTest(MopidyBeetsTest): """Mixin for MopidyBeetsTest providing access to a temporary Beets library. Supported features: - import the albums defined in the 'BEETS_ALBUMS' class variable into the Beets library - accesses to `self.backend.library` will query the Beets library via the web plugin """ BEETS_ALBUMS: ClassVar[list[BeetsAlbum]] = [] def setUp(self): logging.getLogger("beets").disabled = True logging.getLogger("werkzeug").disabled = True self.beets = BeetsLibrary() # set the host and port of the beets API in our class-based configuration config = self.get_config() host, port = self.beets.get_connection_pair() config["beets"]["hostname"] = host config["beets"]["port"] = port self.get_config = lambda: config # we call our parent initializer late, since we needed to adjust its config super().setUp() # Run the thread as late as possible in order to avoid hangs due to exceptions. # Such exceptions would cause `tearDown` to be skipped. self.beets.start() self.beets_populate() def beets_populate(self) -> None: """Import the albums specified in the class variable 'BEETS_ALBUMS'.""" for album in self.BEETS_ALBUMS: album_items = [] for track_index, track_data in enumerate(album.tracks): args = { "album": album.title, "albumartist": album.artist, "genre": album.genre, "artist": track_data.artist, "title": track_data.title, "track": track_data.track, "year": album.year, } for key, fallback_value in { "artist": album.artist, "track": track_index, }.items(): if args[key] is None: args[key] = fallback_value new_item = self.beets.add_item_fixture(**args) album_items.append(new_item) beets_album = self.beets.lib.add_album(album_items) if album.genre: # Modern Beets tracks genres on albums as a multi-valued list; # populate it so browsing by genre finds the album. beets_album["genres"] = [album.genre] beets_album.store() def tearDown(self): self.beets.stop() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1777128348.0 mopidy_beets-5.0.0/tests/test_beets_api.py0000644000175100017510000000423115173151634020361 0ustar00runnerrunnerfrom typing import ClassVar from .helper_beets import BeetsAlbum, BeetsAPILibraryTest, BeetsTrack class LookupTest(BeetsAPILibraryTest): BEETS_ALBUMS: ClassVar[list[BeetsAlbum]] = [ BeetsAlbum( "Album-Title-1", "Album-Artist-1", [ BeetsTrack("Title-1"), BeetsTrack("Title-2"), BeetsTrack("Title-3"), ], "Genre-1", 2012, ), BeetsAlbum( "Album-Title-2", "Album-Artist-2", [BeetsTrack("Title-1")], ), ] BROWSE_CATEGORIES = ( "albums-by-artist", "albums-by-genre", "albums-by-year", ) def get_uri(self, *components): return ":".join(("beets", "library", *components)) def test_categories(self): response = self.backend.library.browse("beets:library") assert len(response) == len(self.BROWSE_CATEGORIES) for category in self.BROWSE_CATEGORIES: with self.subTest(category=category): full_category = self.get_uri(category) assert full_category in (item.uri for item in response) def test_browse_albums_by_artist(self): response = self.backend.library.browse("beets:library:albums-by-artist") expected_album_artists = sorted(album.artist for album in self.BEETS_ALBUMS) received_album_artists = [item.name for item in response] assert received_album_artists == expected_album_artists def test_browse_albums_by_genre(self): response = self.backend.library.browse("beets:library:albums-by-genre") expected_album_genres = sorted(album.genre for album in self.BEETS_ALBUMS) received_album_genres = [item.name for item in response] assert received_album_genres == expected_album_genres def test_browse_albums_by_year(self): response = self.backend.library.browse("beets:library:albums-by-year") expected_album_genres = sorted(str(album.year) for album in self.BEETS_ALBUMS) received_album_genres = [item.name for item in response] assert received_album_genres == expected_album_genres ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1777128348.0 mopidy_beets-5.0.0/tests/test_extension.py0000644000175100017510000000214615173151634020445 0ustar00runnerrunnerfrom unittest import mock from mopidy_beets import Extension from mopidy_beets.actor import BeetsBackend from . import MopidyBeetsTest class ExtensionTest(MopidyBeetsTest): def test_get_default_config(self): ext = Extension() config = ext.get_default_config() assert "[beets]" in config assert "enabled = true" in config assert "hostname = 127.0.0.1" in config assert "port = 8337" in config def test_get_config_schema(self): ext = Extension() schema = ext.get_config_schema() assert "enabled" in schema assert "hostname" in schema assert "port" in schema def test_get_backend_classes(self): registry = mock.Mock() ext = Extension() ext.setup(registry) assert mock.call("backend", BeetsBackend) in registry.add.mock_calls def test_init_backend(self): backend = BeetsBackend( config=self.get_config(), audio=None, # ty:ignore[invalid-argument-type] ) assert backend is not None backend.on_start() backend.on_stop() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1777128348.0 mopidy_beets-5.0.0/tests/test_library.py0000644000175100017510000000027615173151634020077 0ustar00runnerrunnerfrom . import MopidyBeetsTest class LibraryTest(MopidyBeetsTest): def test_invalid_uri(self): refs = self.backend.library.lookup("beets:invalid_uri") assert refs == []