diff --git a/.copier-answers.yml b/.copier-answers.yml new file mode 100644 index 0000000..9d04c03 --- /dev/null +++ b/.copier-answers.yml @@ -0,0 +1,9 @@ +_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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8f22746..c6038a4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,47 +5,57 @@ on: 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: "Test: Python 3.9" - python: "3.9" - tox: py39 - - name: "Test: Python 3.10" - python: "3.10" - tox: py310 - - name: "Test: Python 3.11" - python: "3.11" - tox: py311 + - name: "pytest (3.13)" + python: "3.13" + tox: "3.13" + - name: "pytest (3.14)" + python: "3.14" + tox: "3.14" coverage: true - - name: "Lint: check-manifest" - python: "3.11" - tox: check-manifest - - name: "Lint: flake8" - python: "3.11" - tox: flake8 + # - 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-22.04 + runs-on: ubuntu-24.04 container: ghcr.io/mopidy/ci:latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v6 - name: Fix home dir permissions to enable pip caching run: chown -R root /github/home - - uses: actions/setup-python@v4 + - uses: actions/setup-python@v6 with: python-version: ${{ matrix.python }} cache: pip - cache-dependency-path: setup.cfg - - run: python -m pip install pygobject tox + 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@v3 + - uses: codecov/codecov-action@v5 if: ${{ matrix.coverage }} + with: + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7badb07..e10af6d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,18 +6,18 @@ on: jobs: release: - runs-on: ubuntu-20.04 - + runs-on: ubuntu-24.04 + environment: + name: pypi + url: https://pypi.org/project/mopidy-beets/ + permissions: + id-token: write steps: - - uses: actions/checkout@v2 - - uses: actions/setup-python@v2 - with: - python-version: '3.9' - - name: "Install dependencies" - run: python3 -m pip install build - - name: "Build package" - run: python3 -m build - - uses: pypa/gh-action-pypi-publish@v1.13.0 - with: - user: __token__ - password: ${{ secrets.PYPI_TOKEN }} + - 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 diff --git a/.gitignore b/.gitignore index 3394205..bd2e3f6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,9 @@ -*.pyc -/*.egg-info +*.egg-info/ +/*.lock +/.*_cache/ /.coverage -/.mypy_cache/ -/.pytest_cache/ /.tox/ -/MANIFEST +/.venv/ /build/ /dist/ -/docs/_build/ +__pycache__/ diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index ee7c19b..0000000 --- a/MANIFEST.in +++ /dev/null @@ -1,15 +0,0 @@ -include *.py -include *.rst -include .mailmap -include .travis.yml -include LICENSE -include MANIFEST.in -include pyproject.toml -include tox.ini - -recursive-include .github * - -include mopidy_*/ext.conf - -recursive-include tests *.py -recursive-include tests/data * diff --git a/README.md b/README.md new file mode 100644 index 0000000..ca01645 --- /dev/null +++ b/README.md @@ -0,0 +1,166 @@ +# 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) diff --git a/README.rst b/README.rst deleted file mode 100644 index c088ae8..0000000 --- a/README.rst +++ /dev/null @@ -1,109 +0,0 @@ -************ -Mopidy-Beets -************ - -.. image:: https://img.shields.io/pypi/v/Mopidy-Beets - :target: https://pypi.org/project/Mopidy-Beets/ - :alt: Latest PyPI version - -.. image:: https://img.shields.io/github/actions/workflow/status/mopidy/mopidy-beets/ci.yml?branch=main - :target: https://github.com/mopidy/mopidy-beets/actions - :alt: CI build status - -.. image:: https://img.shields.io/codecov/c/gh/mopidy/mopidy-beets - :target: https://codecov.io/gh/mopidy/mopidy-beets - :alt: Test coverage - -`Mopidy `_ extension for browsing, searching and -playing music from a music collection managed via `Beets `_. -This extension uses the -`Beets plugin "web" `_. - - -Installation -============ - -Install by running:: - - sudo python3 -m pip install Mopidy-Beets - -See https://mopidy.com/ext/beets/ for alternative installation methods. - - -Configuration -============= - -#. Setup the `Beets web plugin - `_. - -#. Tell Mopidy where to find the Beets web interface by adding the following to - your ``mopidy.conf``:: - - [beets] - hostname = 127.0.0.1 - port = 8337 - -#. Restart Mopidy. - -#. The Beets library is now accessible in the "browser" section of your Mopidy - client. Additionally 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 -===== - -#. Run ``beet web`` to start the Beets web interface. - -#. 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 `_ -- `Issue tracker `_ -- `Changelog `_ - - -Credits -======= - -- Original author: `Janez Troha `_ -- Current maintainer: `Lars Kruse `_ -- `Contributors `_ diff --git a/pyproject.toml b/pyproject.toml index 04a6524..aa3a33b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,17 +1,159 @@ +[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 :: Apache Software License", + "Operating System :: OS Independent", + "Topic :: Multimedia :: Sound/Audio :: Players", +] +dynamic = ["version"] +dependencies = [ + "mopidy >= 4.0.0a7", + "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 >= 30.3.0", "wheel"] +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 + "ANN001", # missing-type-function-argument # TODO + "ANN002", # missing-type-args # TODO + "ANN003", # missing-type-kwargs # TODO + "ANN201", # missing-return-type-undocumented-public-function # TODO + "ANN202", # missing-return-type-private-function # TODO + "ANN204", # missing-return-type-special-method # TODO + "ANN205", # missing-return-type-static-method # TODO + "D", # pydocstyle + "D203", # one-blank-line-before-class + "D213", # multi-line-summary-second-line + "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/*" = [ + "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.black] -target-version = ["py39", "py310", "py311"] -line-length = 80 +[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.isort] -multi_line_output = 3 -include_trailing_comma = true -force_grid_wrap = 0 -use_parentheses = true -line_length = 88 -known_tests = "tests" -sections = "FUTURE,STDLIB,THIRDPARTY,FIRSTPARTY,TESTS,LOCALFOLDER" +[tool.tox.env.ruff-format] +skip_install = true +dependency_groups = ["ruff"] +commands = [["ruff", "format", "--check", "--diff", "{posargs:.}"]] diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 239915c..0000000 --- a/setup.cfg +++ /dev/null @@ -1,86 +0,0 @@ -[metadata] -name = Mopidy-Beets -version = 4.0.1 -url = https://github.com/mopidy/mopidy-beets -author = Lars Kruse -author_email = devel@sumpfralle.de -license = MIT -license_file = LICENSE -description = Beets extension for Mopidy -long_description = file: README.rst -classifiers = - Environment :: No Input/Output (Daemon) - Intended Audience :: End Users/Desktop - License :: OSI Approved :: MIT License - Operating System :: OS Independent - Programming Language :: Python :: 3 - Programming Language :: Python :: 3.9 - Programming Language :: Python :: 3.10 - Programming Language :: Python :: 3.11 - Topic :: Multimedia :: Sound/Audio :: Players - - -[options] -zip_safe = False -include_package_data = True -packages = find: -python_requires = >= 3.9 -install_requires = - Mopidy >= 3.0.0 - Pykka >= 2.0.1 - requests >= 2.0.0 - setuptools - - -[options.extras_require] -lint = - black - check-manifest - flake8 - flake8-black - flake8-bugbear - flake8-import-order - isort[pyproject] -test = - pytest - pytest-cov -dev = - %(lint)s - %(test)s - - -[options.packages.find] -exclude = - tests - tests.* - - -[options.entry_points] -mopidy.ext = - beets = mopidy_beets:BeetsExtension - - -[flake8] -application-import-names = mopidy_{{ cookiecutter.ext_name }}, tests -max-line-length = 80 -exclude = .git, .tox, build -select = - # Regular flake8 rules - C, E, F, W - # flake8-bugbear rules - B - # B950: line too long (soft speed limit) - B950 - # pep8-naming rules - N -ignore = - # E203: whitespace before ':' (not PEP8 compliant) - E203 - # E501: line too long (replaced by B950) - E501 - # W503: line break before binary operator (not PEP8 compliant) - W503 - # B019: Use of `functools.lru_cache` or `functools.cache` on methods can lead to memory leaks. - B019 - # B305: .next() is not a thing on Python 3 (used by playback controller) - B305 diff --git a/setup.py b/setup.py deleted file mode 100644 index 6068493..0000000 --- a/setup.py +++ /dev/null @@ -1,3 +0,0 @@ -from setuptools import setup - -setup() diff --git a/mopidy_beets/__init__.py b/src/mopidy_beets/__init__.py similarity index 54% rename from mopidy_beets/__init__.py rename to src/mopidy_beets/__init__.py index 7891acb..d2c7ce9 100644 --- a/mopidy_beets/__init__.py +++ b/src/mopidy_beets/__init__.py @@ -1,20 +1,18 @@ -import os +import pathlib +from importlib.metadata import version -import pkg_resources from mopidy import config, ext +__version__ = version("mopidy-beets") -__version__ = pkg_resources.get_distribution("Mopidy-Beets").version - -class BeetsExtension(ext.Extension): - dist_name = "Mopidy-Beets" +class Extension(ext.Extension): + dist_name = "mopidy-beets" ext_name = "beets" version = __version__ def get_default_config(self): - conf_file = os.path.join(os.path.dirname(__file__), "ext.conf") - return config.read(conf_file) + return config.read(pathlib.Path(__file__).parent / "ext.conf") def get_config_schema(self): schema = super().get_config_schema() @@ -23,6 +21,6 @@ def get_config_schema(self): return schema def setup(self, registry): - from .actor import BeetsBackend + from mopidy_beets.actor import BeetsBackend # noqa: PLC0415 registry.add("backend", BeetsBackend) diff --git a/mopidy_beets/actor.py b/src/mopidy_beets/actor.py similarity index 69% rename from mopidy_beets/actor.py rename to src/mopidy_beets/actor.py index e1630f2..cfe9aa3 100644 --- a/mopidy_beets/actor.py +++ b/src/mopidy_beets/actor.py @@ -1,25 +1,24 @@ import logging - -from mopidy import backend +from typing import ClassVar import pykka +from mopidy import backend +from mopidy.types import UriScheme from .client import BeetsRemoteClient from .library import BeetsLibraryProvider - logger = logging.getLogger(__name__) class BeetsBackend(pykka.ThreadingActor, backend.Backend): - uri_schemes = ["beets"] + uri_schemes: ClassVar[list[UriScheme]] = [UriScheme("beets")] def __init__(self, config, audio): super().__init__() - beets_endpoint = "http://%s:%s" % ( - config["beets"]["hostname"], - config["beets"]["port"], + beets_endpoint = ( + f"http://{config['beets']['hostname']}:{config['beets']['port']}" ) self.beets_api = BeetsRemoteClient(beets_endpoint, config["proxy"]) @@ -29,7 +28,9 @@ def __init__(self, config, audio): class BeetsPlaybackProvider(backend.PlaybackProvider): + backend: BeetsBackend + def translate_uri(self, uri): track_id = uri.split(";")[1] - logger.debug("Getting info for track %s with id %s" % (uri, track_id)) + logger.debug(f"Getting info for track {uri} with id {track_id}") return self.backend.beets_api.get_track_stream_url(track_id) diff --git a/mopidy_beets/browsers/__init__.py b/src/mopidy_beets/browsers/__init__.py similarity index 100% rename from mopidy_beets/browsers/__init__.py rename to src/mopidy_beets/browsers/__init__.py diff --git a/mopidy_beets/browsers/albums.py b/src/mopidy_beets/browsers/albums.py similarity index 79% rename from mopidy_beets/browsers/albums.py rename to src/mopidy_beets/browsers/albums.py index ebffde0..75b864f 100644 --- a/mopidy_beets/browsers/albums.py +++ b/src/mopidy_beets/browsers/albums.py @@ -20,7 +20,9 @@ def get_toplevel(self): def get_directory(self, key): albums = self.api.get_albums_by( - [(self.field, key)], True, self.sort_fields + [(self.field, key)], + True, # noqa: FBT003 + self.sort_fields, ) return [ models.Ref.album(uri=album.uri, name=self._get_label(album)) @@ -43,13 +45,10 @@ class AlbumsByGenreBrowser(AlbumsCategoryBrowser): def _get_label(self, album): artists = " / ".join([artist.name for artist in album.artists]) if artists and album.date: - return "{0} - {1} ({2})".format( - artists, album.name, album.date.split("-")[0] - ) - elif artists: - return "{0} - {1}".format(artists, album.name) - else: - return album.name + return f"{artists} - {album.name} ({album.date.split('-')[0]})" + if artists: + return f"{artists} - {album.name}" + return album.name class AlbumsByYearBrowser(AlbumsCategoryBrowser): @@ -65,6 +64,5 @@ class AlbumsByYearBrowser(AlbumsCategoryBrowser): def _get_label(self, album): artists = " / ".join([artist.name for artist in album.artists]) if artists: - return "{0} - {1}".format(artists, album.name) - else: - return album.name + return f"{artists} - {album.name}" + return album.name diff --git a/mopidy_beets/client.py b/src/mopidy_beets/client.py similarity index 77% rename from mopidy_beets/client.py rename to src/mopidy_beets/client.py index 2bf79e5..5e02656 100644 --- a/mopidy_beets/client.py +++ b/src/mopidy_beets/client.py @@ -4,20 +4,19 @@ import urllib.error import urllib.parse import urllib.request - -from mopidy import httpclient +from http import HTTPStatus import requests +from mopidy import httpclient from requests.exceptions import RequestException import mopidy_beets from mopidy_beets.translator import parse_album, parse_track - logger = logging.getLogger(__name__) -class cache: +class cache: # noqa: N801 # TODO: merge this to util library def __init__(self, ctl=8, ttl=3600): @@ -35,10 +34,9 @@ def _memoized(*args): age = now - last_update if self._call_count >= self.ctl or age > self.ttl: self._call_count = 1 - raise AttributeError + raise AttributeError # noqa: TRY301 self._call_count += 1 - return value except (KeyError, AttributeError): value = self.func(*args) @@ -48,6 +46,9 @@ def _memoized(*args): except TypeError: return self.func(*args) + else: + return value + return _memoized @@ -62,12 +63,7 @@ def __init__(self, endpoint, proxy_config, request_timeout=4): def _get_session(self, proxy_config): proxy = httpclient.format_proxy(proxy_config) full_user_agent = httpclient.format_user_agent( - "/".join( - ( - mopidy_beets.BeetsExtension.dist_name, - mopidy_beets.__version__, - ) - ) + f"{mopidy_beets.Extension.dist_name}/{mopidy_beets.__version__}" ) session = requests.Session() session.proxies.update({"http": proxy, "https": proxy}) @@ -77,16 +73,15 @@ def _get_session(self, proxy_config): @cache() def get_tracks(self): track_ids = self._get("/item/").get("item_ids") or [] - tracks = [self.get_track(track_id) for track_id in track_ids] - return tracks + return [self.get_track(track_id) for track_id in track_ids] @cache(ctl=16) def get_track(self, track_id): - return parse_track(self._get("/item/%s" % track_id), self) + return parse_track(self._get(f"/item/{track_id}"), self) @cache(ctl=16) def get_album(self, album_id): - return parse_album(self._get("/album/%s" % album_id), self) + return parse_album(self._get(f"/album/{album_id}"), self) @cache() def get_tracks_by(self, attributes, exact_text, sort_fields): @@ -102,9 +97,7 @@ def get_albums_by(self, attributes, exact_text, sort_fields): ) return self._parse_multiple_albums(albums) - def _get_objects_by_attribute( - self, base_path, attributes, exact_text, sort_fields - ): + def _get_objects_by_attribute(self, base_path, attributes, exact_text, sort_fields): # noqa: C901 """The beets web-api accepts queries like: /item/query/album_id:183/track:2 /item/query/album:Foo @@ -146,24 +139,16 @@ def quote_and_encode(text): # returns lower case attributes key = attribute[0].lower() value = attribute[1] - query_parts.append( - "{}:{}".format( - quote_and_encode(key), quote_and_encode(value) - ) - ) + 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 = "^{}$".format(re.escape(value)) - beets_query = "{}::{}".format( - quote_and_encode(key), quote_and_encode(regex_query) - ) - logger.debug( - "Beets - regular expression query: {}".format( - beets_query - ) + 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) @@ -173,11 +158,9 @@ def quote_and_encode(text): 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 - ) + logger.info("Beets - invalid sorting field ignore: %s", sort_field) query_string = "/".join(query_parts) - query_url = "{0}/query/{1}".format(base_path, query_string) + query_url = f"{base_path}/query/{query_string}" logger.debug("Beets query: %s", query_url) items = self._get(query_url)["results"] if exact_text: @@ -187,10 +170,9 @@ def quote_and_encode(text): if key is None: # the value must match one of the item attributes items = [item for item in items if value in item.values()] - else: - # filtering is necessary only for text based attributes - if items and isinstance(items[0][key], str): - items = [item for item in items if item[key] == value] + # 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() @@ -215,9 +197,7 @@ def _get_unique_attribute_values(self, base_url, field, sort_field): if not hasattr(self, "__legacy_beets_api_detected"): try: result = self._get( - "{0}/values/{1}?sort_key={2}".format( - base_url, field, sort_field - ), + f"{base_url}/values/{field}?sort_key={sort_field}", raise_not_found=True, ) except KeyError: @@ -237,9 +217,7 @@ def _get_unique_attribute_values(self, base_url, field, sort_field): return result["values"] # Fallback: use manual filtering (requires too much time and memory for # most collections). - sorted_items = self._get("{0}/query/{1}+".format(base_url, sort_field))[ - "results" - ] + sorted_items = self._get(f"{base_url}/query/{sort_field}+")["results"] # extract the wanted field and remove all duplicates unique_values = [] for item in sorted_items: @@ -249,42 +227,41 @@ def _get_unique_attribute_values(self, base_url, field, sort_field): return unique_values def get_track_stream_url(self, track_id): - return "{0}/item/{1}/file".format(self.api_endpoint, track_id) + return f"{self.api_endpoint}/item/{track_id}/file" @cache(ctl=32) def get_album_art_url(self, album_id): # 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 = "{0}/album/{1}/art".format(self.api_endpoint, album_id) + url = f"{self.api_endpoint}/album/{album_id}/art" try: - request = urllib.request.urlopen(url) - except IOError: + request = urllib.request.urlopen(url) # noqa: S310 + except OSError: # DNS problem or similar return None request.close() - return url if request.getcode() == 200 else None + return url if request.getcode() == HTTPStatus.OK else None - def _get(self, url, raise_not_found=False): + def _get(self, url, *, raise_not_found=False): url = self.api_endpoint + url - logger.debug("Beets - requesting %s" % url) + logger.debug(f"Beets - requesting {url}") try: req = self.api.get(url, timeout=self._request_timeout) except RequestException as e: - logger.error("Beets - Request %s, failed with error %s", url, e) + logger.error(f"Beets - Request {url}, failed with error {e}") # noqa: TRY400 return None - if req.status_code != 200: + if req.status_code != HTTPStatus.OK: logger.error( "Beets - Request %s, failed with status code %s", url, req.status_code, ) - if (req.status_code == 404) and raise_not_found: + if (req.status_code == HTTPStatus.NOT_FOUND) and raise_not_found: # sometimes we need to distinguish empty and 'not found' - raise KeyError("URL not found: %s" % url) - else: - return None - else: - return req.json() + msg = f"URL not found: {url}" + raise KeyError(msg) + return None + return req.json() def _parse_multiple_albums(self, album_datasets): albums = [] @@ -292,7 +269,7 @@ def _parse_multiple_albums(self, album_datasets): try: albums.append(parse_album(dataset, self)) except (ValueError, KeyError) as exc: - logger.info("Beets - Failed to parse album data: %s", 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): @@ -301,5 +278,5 @@ def _parse_multiple_tracks(self, track_datasets): try: tracks.append(parse_track(dataset, self)) except (ValueError, KeyError) as exc: - logger.info("Beets - Failed to parse track data: %s", exc) + logger.info(f"Beets - Failed to parse track data: {exc}") return [track for track in tracks if track] diff --git a/mopidy_beets/ext.conf b/src/mopidy_beets/ext.conf similarity index 100% rename from mopidy_beets/ext.conf rename to src/mopidy_beets/ext.conf diff --git a/mopidy_beets/library.py b/src/mopidy_beets/library.py similarity index 76% rename from mopidy_beets/library.py rename to src/mopidy_beets/library.py index 657c80c..129c2b1 100644 --- a/mopidy_beets/library.py +++ b/src/mopidy_beets/library.py @@ -1,9 +1,11 @@ import logging import re +from typing import ClassVar from mopidy import backend, models from mopidy.models import SearchResult +from mopidy_beets.browsers import GenericBrowserBase from mopidy_beets.browsers.albums import ( AlbumsByArtistBrowser, AlbumsByGenreBrowser, @@ -11,7 +13,6 @@ ) from mopidy_beets.translator import assemble_uri, parse_uri - logger = logging.getLogger(__name__) # match dates of the following format: @@ -22,10 +23,8 @@ class BeetsLibraryProvider(backend.LibraryProvider): - root_directory = models.Ref.directory( - uri="beets:library", name="Beets library" - ) - root_categorie_list = [ + root_directory = models.Ref.directory(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), @@ -42,53 +41,47 @@ def __init__(self, *args, **kwargs): browser = browser_class(ref, self.remote) self.category_browsers.append(browser) - def browse(self, uri): + def browse(self, uri): # noqa: PLR0911 logger.debug("Browsing Beets at: %s", uri) 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 [] - elif uri == self.root_directory.uri: + 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 - elif path == "album": + if path == "album": # show an album try: album_id = int(item_id) except ValueError: - logger.error("Beets - invalid album ID in URI: %s", uri) + logger.error(f"Beets - invalid album ID in URI: {uri}") # noqa: TRY400 return [] tracks = self.remote.get_tracks_by( - [("album_id", album_id)], True, ["track+"] + [("album_id", album_id)], + True, # noqa: FBT003 + ["track+"], ) return [ - models.Ref.track(uri=track.uri, name=track.name) - for track in tracks + models.Ref.track(uri=track.uri, name=track.name) for track in tracks ] - else: - # 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() - else: - return browser.get_directory(item_id) - else: - logger.error("Beets - Invalid browse URI: %s / %s", uri, path) - return [] + # 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 [] - def search(self, query=None, uris=None, exact=False): + def search(self, query=None, uris=None, exact=False): # noqa: C901, FBT002, PLR0912 # TODO: restrict the result to 'uris' - logger.debug( - 'Beets Query (exact=%s) within "%s": %s', exact, uris, query - ) + logger.debug('Beets Query (exact=%s) within "%s": %s', exact, uris, query) self._validate_query(query) search_list = [] for field, values in query.items(): @@ -152,14 +145,20 @@ def lookup(self, uri=None, uris=None): tracks = [self.remote.get_track(item_id)] elif path == "album": tracks = self.remote.get_tracks_by( - [("album_id", item_id)], True, ("disc+", "track+") + [("album_id", item_id)], + True, # noqa: FBT003 + ("disc+", "track+"), ) elif path == "artist": artist_tracks = self.remote.get_tracks_by( - [("artist", item_id)], True, [] + [("artist", item_id)], + True, # noqa: FBT003 + [], ) composer_tracks = self.remote.get_tracks_by( - [("composer", item_id)], True, [] + [("composer", item_id)], + True, # noqa: FBT003 + [], ) # Append composer tracks to the artist tracks (unique items). tracks = list(set(artist_tracks + composer_tracks)) @@ -171,9 +170,8 @@ def lookup(self, uri=None, uris=None): tracks = [] # remove occourences of None return [track for track in tracks if track] - else: - # the newer method (mopidy>=1.0): return a dict of uris and tracks - return {uri: self.lookup(uri=uri) for uri in uris} + # the newer method (mopidy>=1.0): return a dict of uris and tracks + return {uri: self.lookup(uri=uri) for uri in uris} def get_distinct(self, field, query=None): logger.debug("Beets distinct query: %s (uri=%s)", field, query) @@ -182,7 +180,9 @@ def get_distinct(self, field, query=None): def _validate_query(self, query): for values in query.values(): if not values: - raise LookupError("Missing query") + msg = "Missing query" + raise LookupError(msg) for value in values: if not value: - raise LookupError("Missing query") + msg = "Missing query" + raise LookupError(msg) diff --git a/mopidy_beets/translator.py b/src/mopidy_beets/translator.py similarity index 90% rename from mopidy_beets/translator.py rename to src/mopidy_beets/translator.py index a08bd27..c25159f 100644 --- a/mopidy_beets/translator.py +++ b/src/mopidy_beets/translator.py @@ -5,7 +5,6 @@ from mopidy.models import Album, Artist, Track - logger = logging.getLogger(__name__) @@ -23,11 +22,8 @@ def parse_date(data): return None # mopidy accepts dates as 'YYYY' or 'YYYY-MM-DD' if day is not None and month is not None: - return "{year:04d}-{month:02d}-{day:02d}".format( - day=day, month=month, year=year - ) - else: - return "{year:04d}".format(year=year) + return f"{year:04d}-{month:02d}-{day:02d}" + return f"{year:04d}" def _apply_beets_mapping(target_class, mapping, data): @@ -60,9 +56,7 @@ def _filter_none(values): def parse_artist(data, name_keyword): # 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] - ), + "uri": lambda d: assemble_uri("beets:library:artist", id_value=d[name_keyword]), "name": name_keyword, } if name_keyword == "artist": @@ -77,7 +71,7 @@ def parse_artist(data, name_keyword): return _apply_beets_mapping(Artist, mapping, data) -def parse_album(data, api): +def parse_album(data, _api): # 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'. @@ -98,7 +92,7 @@ def parse_track(data, api): # The order of items is based on the above documentation. # Attributes without corresponding Beets data are mapped to 'None'. mapping = { - "uri": lambda d: "beets:library:track;%s" % d["id"], + "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: ( @@ -170,9 +164,8 @@ def assemble_uri(*args, **kwargs): id_value = kwargs.pop("id_value", None) if id_value is None: return base_path - else: - # 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 "%s;%s" % (base_path, id_string) + # 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 f"{base_path};{id_string}" diff --git a/tests/__init__.py b/tests/__init__.py index 21fef14..957019d 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,11 +1,10 @@ import logging -import os import unittest +from pathlib import Path from mopidy_beets.actor import BeetsBackend - -TEST_DATA_DIRECTORY = os.path.join(os.path.dirname(__file__), "data") +TEST_DATA_DIRECTORY = Path(__file__).parent.resolve() / "data" class MopidyBeetsTest(unittest.TestCase): diff --git a/tests/helper_beets.py b/tests/helper_beets.py index 285eaf2..d6783f0 100644 --- a/tests/helper_beets.py +++ b/tests/helper_beets.py @@ -1,34 +1,34 @@ -import collections import logging -import os import random import threading import time -import typing +from typing import ClassVar, NamedTuple import beets.test._common import werkzeug.serving -from beets.util import bytestring_path 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 MopidyBeetsTest, TEST_DATA_DIRECTORY +from . import TEST_DATA_DIRECTORY, MopidyBeetsTest + +class BeetsTrack(NamedTuple): + title: str + artist: str | None = None + track: int | None = None -BeetsTrack = collections.namedtuple( - "BeetsTrack", ("title", "artist", "track"), defaults=(None, None) -) -BeetsAlbum = collections.namedtuple( - "BeetsAlbum", - ("title", "artist", "tracks", "genre", "year"), - defaults=("", 0), -) + +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( - os.path.abspath(os.path.join(TEST_DATA_DIRECTORY, "beets-rsrc")) -) +beets.test._common.RSRC = bytestring_path(TEST_DATA_DIRECTORY / "beets-rsrc") # noqa: SLF001 class BeetsLibrary(BeetsTestHelper): @@ -37,14 +37,14 @@ class BeetsLibrary(BeetsTestHelper): def __init__( self, bind_host: str = "127.0.0.1", - bind_port: typing.Optional[int] = None, + 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) + self._bind_port = random.randint(10000, 32767) # noqa: S311 else: self._bind_port = bind_port self._server = None @@ -56,9 +56,7 @@ def __init__( self._server = werkzeug.serving.make_server( self._bind_host, self._bind_port, self._app ) - self._server_thread = threading.Thread( - target=self._server.serve_forever - ) + self._server_thread = threading.Thread(target=self._server.serve_forever) def start(self): self._server_thread.start() @@ -85,7 +83,7 @@ class BeetsAPILibraryTest(MopidyBeetsTest): - accesses to `self.backend.library` will query the Beets library via the web plugin """ - BEETS_ALBUMS: list[BeetsAlbum] = [] + BEETS_ALBUMS: ClassVar[list[BeetsAlbum]] = [] def setUp(self): logging.getLogger("beets").disabled = True diff --git a/tests/test_beets_api.py b/tests/test_beets_api.py index 17c00ab..7c603c8 100644 --- a/tests/test_beets_api.py +++ b/tests/test_beets_api.py @@ -1,8 +1,10 @@ +from typing import ClassVar + from .helper_beets import BeetsAlbum, BeetsAPILibraryTest, BeetsTrack class LookupTest(BeetsAPILibraryTest): - BEETS_ALBUMS = [ + BEETS_ALBUMS: ClassVar[list[BeetsAlbum]] = [ BeetsAlbum( "Album-Title-1", "Album-Artist-1", @@ -28,36 +30,30 @@ class LookupTest(BeetsAPILibraryTest): ) def get_uri(self, *components): - return ":".join(("beets", "library") + components) + return ":".join(("beets", "library", *components)) def test_categories(self): response = self.backend.library.browse("beets:library") - self.assertEqual(len(response), len(self.BROWSE_CATEGORIES)) + assert len(response) == len(self.BROWSE_CATEGORIES) for category in self.BROWSE_CATEGORIES: with self.subTest(category=category): full_category = self.get_uri(category) - self.assertIn(full_category, (item.uri for item in response)) + 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 - ) + expected_album_artists = sorted(album.artist for album in self.BEETS_ALBUMS) received_album_artists = [item.name for item in response] - self.assertEqual(received_album_artists, expected_album_artists) + 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 - ) + expected_album_genres = sorted(album.genre for album in self.BEETS_ALBUMS) received_album_genres = [item.name for item in response] - self.assertEqual(received_album_genres, expected_album_genres) + 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 - ) + expected_album_genres = sorted(str(album.year) for album in self.BEETS_ALBUMS) received_album_genres = [item.name for item in response] - self.assertEqual(received_album_genres, expected_album_genres) + assert received_album_genres == expected_album_genres diff --git a/tests/test_extension.py b/tests/test_extension.py index 9e65c91..c69350c 100644 --- a/tests/test_extension.py +++ b/tests/test_extension.py @@ -1,6 +1,6 @@ from unittest import mock -from mopidy_beets import BeetsExtension +from mopidy_beets import Extension from mopidy_beets.actor import BeetsBackend from . import MopidyBeetsTest @@ -8,34 +8,32 @@ class ExtensionTest(MopidyBeetsTest): def test_get_default_config(self): - ext = BeetsExtension() + ext = Extension() config = ext.get_default_config() - self.assertIn("[beets]", config) - self.assertIn("enabled = true", config) - self.assertIn("hostname = 127.0.0.1", config) - self.assertIn("port = 8337", 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 = BeetsExtension() + ext = Extension() schema = ext.get_config_schema() - self.assertIn("enabled", schema) - self.assertIn("hostname", schema) - self.assertIn("port", schema) + assert "enabled" in schema + assert "hostname" in schema + assert "port" in schema def test_get_backend_classes(self): registry = mock.Mock() - ext = BeetsExtension() + ext = Extension() ext.setup(registry) - self.assertIn( - mock.call("backend", BeetsBackend), registry.add.mock_calls - ) + assert mock.call("backend", BeetsBackend) in registry.add.mock_calls def test_init_backend(self): backend = BeetsBackend(self.get_config(), None) - self.assertIsNotNone(backend) + assert backend is not None backend.on_start() backend.on_stop() diff --git a/tests/test_library.py b/tests/test_library.py index 75dea27..ec19955 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -4,4 +4,4 @@ class LibraryTest(MopidyBeetsTest): def test_invalid_uri(self): refs = self.backend.library.lookup("beets:invalid_uri") - self.assertEqual(refs, []) + assert refs == [] diff --git a/tox.ini b/tox.ini deleted file mode 100644 index 7e7024b..0000000 --- a/tox.ini +++ /dev/null @@ -1,24 +0,0 @@ -[tox] -envlist = py39, py310, py311, check-manifest, flake8 - -[testenv] -sitepackages = true -deps = - .[test] - # 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 - werkzeug -commands = - python -m pytest \ - --basetemp={envtmpdir} \ - --cov=mopidy_beets --cov-report=term-missing \ - {posargs} - -[testenv:check-manifest] -deps = .[lint] -commands = python -m check_manifest - -[testenv:flake8] -deps = .[lint] -commands = python -m flake8 --show-source --statistics