././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1607901605.1539023 Mopidy-Beets-4.0.1/0000755000175100001710000000000000000000000013355 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1607901605.1539023 Mopidy-Beets-4.0.1/.circleci/0000755000175100001710000000000000000000000015210 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607901597.0 Mopidy-Beets-4.0.1/.circleci/config.yml0000644000175100001710000000222200000000000017176 0ustar00runnerdockerversion: 2.1 orbs: codecov: codecov/codecov@1.0.5 workflows: version: 2 test: jobs: - py39 - py38 - py37 - black - check-manifest - flake8 jobs: py39: &test-template docker: - image: mopidy/ci-python:3.9 steps: - checkout - restore_cache: name: Restoring tox cache key: tox-v1-{{ .Environment.CIRCLE_JOB }}-{{ checksum "setup.cfg" }} - run: name: Run tests command: | tox -e $CIRCLE_JOB -- \ --junit-xml=test-results/pytest/results.xml \ --cov-report=xml - save_cache: name: Saving tox cache key: tox-v1-{{ .Environment.CIRCLE_JOB }}-{{ checksum "setup.cfg" }} paths: - ./.tox - ~/.cache/pip - codecov/upload: file: coverage.xml - store_test_results: path: test-results py38: <<: *test-template docker: - image: mopidy/ci-python:3.8 py37: <<: *test-template docker: - image: mopidy/ci-python:3.7 black: *test-template check-manifest: *test-template flake8: *test-template ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1607901605.1499019 Mopidy-Beets-4.0.1/.github/0000755000175100001710000000000000000000000014715 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1607901605.1539023 Mopidy-Beets-4.0.1/.github/workflows/0000755000175100001710000000000000000000000016752 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607901597.0 Mopidy-Beets-4.0.1/.github/workflows/release.yml0000644000175100001710000000073500000000000021122 0ustar00runnerdockername: Release on: release: types: [published] jobs: release: runs-on: ubuntu-20.04 steps: - uses: actions/checkout@v2 - uses: actions/setup-python@v2 with: python-version: '3.9' - name: "Install dependencies" run: python3 -m pip install build - name: "Build package" run: python3 -m build - uses: pypa/gh-action-pypi-publish@v1.4.1 with: user: __token__ password: ${{ secrets.PYPI_TOKEN }} ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607901597.0 Mopidy-Beets-4.0.1/.mailmap0000644000175100001710000000015100000000000014773 0ustar00runnerdockerJanez Troha Janez Troha ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607901597.0 Mopidy-Beets-4.0.1/LICENSE0000644000175100001710000000205700000000000014366 0ustar00runnerdockerCopyright (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.././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607901597.0 Mopidy-Beets-4.0.1/MANIFEST.in0000644000175100001710000000043600000000000015116 0ustar00runnerdockerinclude *.py include *.rst include .mailmap include .travis.yml include LICENSE include MANIFEST.in include pyproject.toml include tox.ini recursive-include .circleci * recursive-include .github * include mopidy_*/ext.conf recursive-include tests *.py recursive-include tests/data * ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1607901605.1539023 Mopidy-Beets-4.0.1/Mopidy_Beets.egg-info/0000755000175100001710000000000000000000000017432 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607901604.0 Mopidy-Beets-4.0.1/Mopidy_Beets.egg-info/PKG-INFO0000644000175100001710000001064300000000000020533 0ustar00runnerdockerMetadata-Version: 2.1 Name: Mopidy-Beets Version: 4.0.1 Summary: Beets extension for Mopidy Home-page: https://github.com/mopidy/mopidy-beets Author: Lars Kruse Author-email: devel@sumpfralle.de License: MIT Description: ************ 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/circleci/build/gh/mopidy/mopidy-beets :target: https://circleci.com/gh/mopidy/mopidy-beets :alt: CircleCI 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 `Beets `_ via Beets' web extension. 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) -------------------------------------------- You may want to configure an http proxy server in front of your Beets plugin (not mopidy). Otherwise you could have problems with playing OGG files and other formats that require seeking (in technical terms: support for http "Range" requests is required for these files). 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; } } 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 `_ Platform: UNKNOWN Classifier: Environment :: No Input/Output (Daemon) Classifier: Intended Audience :: End Users/Desktop Classifier: License :: OSI Approved :: MIT License Classifier: Operating System :: OS Independent Classifier: Programming Language :: Python :: 3 Classifier: Programming Language :: Python :: 3.7 Classifier: Programming Language :: Python :: 3.8 Classifier: Programming Language :: Python :: 3.9 Classifier: Topic :: Multimedia :: Sound/Audio :: Players Requires-Python: >=3.7 Provides-Extra: lint Provides-Extra: release Provides-Extra: test Provides-Extra: dev ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607901605.0 Mopidy-Beets-4.0.1/Mopidy_Beets.egg-info/SOURCES.txt0000644000175100001710000000117400000000000021321 0ustar00runnerdocker.mailmap LICENSE MANIFEST.in README.rst pyproject.toml setup.cfg setup.py tox.ini .circleci/config.yml .github/workflows/release.yml Mopidy_Beets.egg-info/PKG-INFO Mopidy_Beets.egg-info/SOURCES.txt Mopidy_Beets.egg-info/dependency_links.txt Mopidy_Beets.egg-info/entry_points.txt Mopidy_Beets.egg-info/not-zip-safe Mopidy_Beets.egg-info/requires.txt Mopidy_Beets.egg-info/top_level.txt mopidy_beets/__init__.py mopidy_beets/actor.py mopidy_beets/client.py mopidy_beets/ext.conf mopidy_beets/library.py mopidy_beets/translator.py mopidy_beets/browsers/__init__.py mopidy_beets/browsers/albums.py tests/__init__.py tests/test_extension.py././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607901604.0 Mopidy-Beets-4.0.1/Mopidy_Beets.egg-info/dependency_links.txt0000644000175100001710000000000100000000000023500 0ustar00runnerdocker ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607901604.0 Mopidy-Beets-4.0.1/Mopidy_Beets.egg-info/entry_points.txt0000644000175100001710000000006200000000000022726 0ustar00runnerdocker[mopidy.ext] beets = mopidy_beets:BeetsExtension ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607901604.0 Mopidy-Beets-4.0.1/Mopidy_Beets.egg-info/not-zip-safe0000644000175100001710000000000100000000000021660 0ustar00runnerdocker ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607901604.0 Mopidy-Beets-4.0.1/Mopidy_Beets.egg-info/requires.txt0000644000175100001710000000046400000000000022036 0ustar00runnerdockerMopidy>=3.0.0 Pykka>=2.0.1 requests>=2.0.0 setuptools [dev] black check-manifest flake8 flake8-bugbear flake8-import-order isort[pyproject] build twine pytest pytest-cov [lint] black check-manifest flake8 flake8-bugbear flake8-import-order isort[pyproject] [release] build twine [test] pytest pytest-cov ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607901604.0 Mopidy-Beets-4.0.1/Mopidy_Beets.egg-info/top_level.txt0000644000175100001710000000001500000000000022160 0ustar00runnerdockermopidy_beets ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1607901605.1539023 Mopidy-Beets-4.0.1/PKG-INFO0000644000175100001710000001064300000000000014456 0ustar00runnerdockerMetadata-Version: 2.1 Name: Mopidy-Beets Version: 4.0.1 Summary: Beets extension for Mopidy Home-page: https://github.com/mopidy/mopidy-beets Author: Lars Kruse Author-email: devel@sumpfralle.de License: MIT Description: ************ 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/circleci/build/gh/mopidy/mopidy-beets :target: https://circleci.com/gh/mopidy/mopidy-beets :alt: CircleCI 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 `Beets `_ via Beets' web extension. 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) -------------------------------------------- You may want to configure an http proxy server in front of your Beets plugin (not mopidy). Otherwise you could have problems with playing OGG files and other formats that require seeking (in technical terms: support for http "Range" requests is required for these files). 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; } } 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 `_ Platform: UNKNOWN Classifier: Environment :: No Input/Output (Daemon) Classifier: Intended Audience :: End Users/Desktop Classifier: License :: OSI Approved :: MIT License Classifier: Operating System :: OS Independent Classifier: Programming Language :: Python :: 3 Classifier: Programming Language :: Python :: 3.7 Classifier: Programming Language :: Python :: 3.8 Classifier: Programming Language :: Python :: 3.9 Classifier: Topic :: Multimedia :: Sound/Audio :: Players Requires-Python: >=3.7 Provides-Extra: lint Provides-Extra: release Provides-Extra: test Provides-Extra: dev ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607901597.0 Mopidy-Beets-4.0.1/README.rst0000644000175100001710000000550500000000000015051 0ustar00runnerdocker************ 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/circleci/build/gh/mopidy/mopidy-beets :target: https://circleci.com/gh/mopidy/mopidy-beets :alt: CircleCI 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 `Beets `_ via Beets' web extension. 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) -------------------------------------------- You may want to configure an http proxy server in front of your Beets plugin (not mopidy). Otherwise you could have problems with playing OGG files and other formats that require seeking (in technical terms: support for http "Range" requests is required for these files). 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; } } 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 `_ ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1607901605.1539023 Mopidy-Beets-4.0.1/mopidy_beets/0000755000175100001710000000000000000000000016040 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607901597.0 Mopidy-Beets-4.0.1/mopidy_beets/__init__.py0000644000175100001710000000132500000000000020152 0ustar00runnerdockerimport os import pkg_resources from mopidy import config, ext __version__ = pkg_resources.get_distribution("Mopidy-Beets").version class BeetsExtension(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) def get_config_schema(self): schema = super(BeetsExtension, self).get_config_schema() schema["hostname"] = config.Hostname() schema["port"] = config.Port() return schema def setup(self, registry): from .actor import BeetsBackend registry.add("backend", BeetsBackend) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607901597.0 Mopidy-Beets-4.0.1/mopidy_beets/actor.py0000644000175100001710000000174600000000000017532 0ustar00runnerdockerimport logging from mopidy import backend import pykka from .client import BeetsRemoteClient from .library import BeetsLibraryProvider logger = logging.getLogger(__name__) class BeetsBackend(pykka.ThreadingActor, backend.Backend): uri_schemes = ["beets"] def __init__(self, config, audio): super(BeetsBackend, self).__init__() beets_endpoint = "http://%s:%s" % ( 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): def translate_uri(self, uri): track_id = uri.split(";")[1] logger.debug("Getting info for track %s with id %s" % (uri, track_id)) return self.backend.beets_api.get_track_stream_url(track_id) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1607901605.1539023 Mopidy-Beets-4.0.1/mopidy_beets/browsers/0000755000175100001710000000000000000000000017706 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607901597.0 Mopidy-Beets-4.0.1/mopidy_beets/browsers/__init__.py0000644000175100001710000000130600000000000022017 0ustar00runnerdockerclass GenericBrowserBase: def __init__(self, ref, api): self.ref = ref self.api = api def get_toplevel(self): """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): """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=1607901597.0 Mopidy-Beets-4.0.1/mopidy_beets/browsers/albums.py0000644000175100001710000000363600000000000021553 0ustar00runnerdockerfrom mopidy import models from mopidy_beets.browsers import GenericBrowserBase from mopidy_beets.translator import assemble_uri class AlbumsCategoryBrowser(GenericBrowserBase): field = None sort_fields = None label_fields = None def get_toplevel(self): keys = self.api.get_sorted_unique_album_attributes(self.field) return [ models.Ref.directory( name=key, uri=assemble_uri(self.ref.uri, id_value=key) ) for key in keys ] def get_directory(self, key): albums = self.api.get_albums_by( [(self.field, key)], True, self.sort_fields ) return [ models.Ref.album(uri=album.uri, name=self._get_label(album)) for album in albums ] class AlbumsByArtistBrowser(AlbumsCategoryBrowser): field = "albumartist" sort_fields = ("original_year+", "year+", "album+") def _get_label(self, album): return album.name class AlbumsByGenreBrowser(AlbumsCategoryBrowser): field = "genre" sort_fields = ("albumartist", "original_year+", "year+", "album+") 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 class AlbumsByYearBrowser(AlbumsCategoryBrowser): field = "year" sort_fields = ( "original_month+", "original_day+", "month+", "day+", "album+", ) 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 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607901597.0 Mopidy-Beets-4.0.1/mopidy_beets/client.py0000644000175100001710000002735500000000000017704 0ustar00runnerdockerimport logging import re import time import urllib.error import urllib.parse import urllib.request from mopidy import httpclient import requests from requests.exceptions import RequestException import mopidy_beets from mopidy_beets.translator import parse_album, parse_track logger = logging.getLogger(__name__) class cache(object): # noqa: N801 # TODO: merge this to util library def __init__(self, ctl=8, ttl=3600): self.cache = {} self.ctl = ctl self.ttl = ttl self._call_count = 1 def __call__(self, func): def _memoized(*args): self.func = func now = time.time() try: value, last_update = self.cache[args] age = now - last_update if self._call_count >= self.ctl or age > self.ttl: self._call_count = 1 raise AttributeError self._call_count += 1 return value except (KeyError, AttributeError): value = self.func(*args) self.cache[args] = (value, now) return value except TypeError: return self.func(*args) return _memoized class BeetsRemoteClient(object): def __init__(self, endpoint, proxy_config): super(BeetsRemoteClient, self).__init__() self.api = self._get_session(proxy_config) self.api_endpoint = endpoint logger.info("Connecting to Beets remote library %s", endpoint) try: self.api.get(self.api_endpoint) self.has_connection = True except RequestException as e: logger.error("Beets error - connection failed: %s", e) self.has_connection = False 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__, ) ) ) session = requests.Session() session.proxies.update({"http": proxy, "https": proxy}) session.headers.update({"user-agent": full_user_agent}) return session @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 @cache(ctl=16) def get_track(self, track_id): return parse_track(self._get("/item/%s" % track_id), self) @cache(ctl=16) def get_album(self, album_id): return parse_album(self._get("/album/%s" % album_id), self) @cache() def get_tracks_by(self, attributes, exact_text, sort_fields): tracks = self._get_objects_by_attribute( "/item", attributes, exact_text, sort_fields ) return self._parse_multiple_tracks(tracks) @cache() def get_albums_by(self, attributes, exact_text, sort_fields): albums = self._get_objects_by_attribute( "/album", attributes, exact_text, sort_fields ) return self._parse_multiple_albums(albums) def _get_objects_by_attribute( self, base_path, attributes, exact_text, sort_fields ): """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) @type exact_text: bool @param sort_fields: fieldnames, each followed by '+' or '-' @type sort_fields: list of strings @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): 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( "{}:{}".format( 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 ressource 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 ) ) 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 = "{0}/query/{1}".format(base_path, query_string) logger.debug("Beets query: %s", query_url) items = self._get(query_url)["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()] 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] return items @cache() def get_artists(self): """ returns all artists of one or more tracks """ names = self._get("/artist/")["artist_names"] names.sort() # remove empty names return [name for name in names if name] def get_sorted_unique_track_attributes(self, field): 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): 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, field, sort_field): """ returns all artists, genres, ... of tracks or albums """ if not hasattr(self, "__legacy_beets_api_detected"): try: result = self._get( "{0}/values/{1}?sort_key={2}".format( base_url, field, sort_field ), raise_not_found=True, ) except KeyError: # The above URL was added to the Beets API after v1.3.17 # Probably we are working against an older version. logger.warning( "Failed to use the /item/unique/KEY feature of the Beets " "API (introduced in v1.3.18). Falling back to the " "slower and more ressource intensive manual approach. " "Please upgrade Beets, if possible." ) # Warn only once and use the manual approach for all future # requests. self.__legacy_beets_api_detected = True # continue below with the fallback else: 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" ] # extract the wanted field and remove all duplicates unique_values = [] for item in sorted_items: value = item[field] if not unique_values or (value != unique_values[-1]): unique_values.append(value) return unique_values def get_track_stream_url(self, track_id): return "{0}/item/{1}/file".format(self.api_endpoint, track_id) @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) try: request = urllib.request.urlopen(url) except IOError: # DNS problem or similar return None request.close() return url if request.getcode() == 200 else None def _get(self, url, raise_not_found=False): url = self.api_endpoint + url logger.debug("Beets - requesting %s" % url) try: req = self.api.get(url) except RequestException as e: logger.error("Beets - Request %s, failed with error %s", url, e) return None if req.status_code != 200: logger.error( "Beets - Request %s, failed with status code %s", url, req.status_code, ) if (req.status_code == 404) 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() def _parse_multiple_albums(self, album_datasets): albums = [] for dataset in album_datasets or []: try: albums.append(parse_album(dataset, self)) except (ValueError, KeyError) as exc: logger.info("Beets - Failed to parse album data: %s", exc) return [album for album in albums if album] def _parse_multiple_tracks(self, track_datasets): tracks = [] for dataset in track_datasets or []: try: tracks.append(parse_track(dataset, self)) except (ValueError, KeyError) as exc: logger.info("Beets - Failed to parse track data: %s", exc) return [track for track in tracks if track] ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607901597.0 Mopidy-Beets-4.0.1/mopidy_beets/ext.conf0000644000175100001710000000007000000000000017504 0ustar00runnerdocker[beets] enabled = true hostname = 127.0.0.1 port = 8337 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607901597.0 Mopidy-Beets-4.0.1/mopidy_beets/library.py0000644000175100001710000001724200000000000020064 0ustar00runnerdockerimport logging import re from mopidy import backend, models from mopidy.models import SearchResult from mopidy_beets.browsers.albums import ( AlbumsByArtistBrowser, AlbumsByGenreBrowser, AlbumsByYearBrowser, ) from mopidy_beets.translator import assemble_uri, parse_uri 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 = models.Ref.directory( uri="beets:library", name="Beets library" ) root_categorie_list = [ ("albums-by-artist", "Albums by Artist", AlbumsByArtistBrowser), ("albums-by-genre", "Albums by Genre", AlbumsByGenreBrowser), ("albums-by-year", "Albums by Year", AlbumsByYearBrowser), ] def __init__(self, *args, **kwargs): super(BeetsLibraryProvider, self).__init__(*args, **kwargs) self.remote = self.backend.beets_api self.category_browsers = [] for key, label, browser_class in self.root_categorie_list: ref = models.Ref.directory( name=label, uri=assemble_uri(self.root_directory.uri, key) ) browser = browser_class(ref, self.remote) self.category_browsers.append(browser) def browse(self, uri): 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: # 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": # show an album try: album_id = int(item_id) except ValueError: logger.error("Beets - invalid album ID in URI: %s", uri) return [] tracks = self.remote.get_tracks_by( [("album_id", album_id)], True, ["track+"] ) return [ 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 [] def search(self, query=None, uris=None, exact=False): # TODO: restrict the result to 'uris' logger.debug( 'Beets Query (exact=%s) within "%s": %s', exact, uris, query ) if not self.remote.has_connection: return SearchResult(uri="beets:search-disconnected", tracks=[]) 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(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, []) uri = "-".join( [ item if isinstance(item, str) else "=".join(item) for item in search_list ] ) return SearchResult(uri="beets:search-" + uri, tracks=tracks) def lookup(self, uri=None, uris=None): logger.debug("Beets lookup: %s", uri or uris) if uri: # the older method (mopidy < 1.0): return a list of tracks # handle one or more tracks given with multiple semicolons logger.debug("Beets lookup: %s", uri) path, item_id = parse_uri(uri, uri_prefix=self.root_directory.uri) if path == "track": tracks = [self.remote.get_track(item_id)] elif path == "album": tracks = self.remote.get_tracks_by( [("album_id", item_id)], True, ("disc+", "track+") ) elif path == "artist": artist_tracks = self.remote.get_tracks_by( [("artist", item_id)], True, [] ) composer_tracks = self.remote.get_tracks_by( [("composer", item_id)], True, [] ) # Append composer tracks to the artist tracks (unique items). tracks = list(set(artist_tracks + composer_tracks)) tracks.sort(key=lambda t: (t.date, t.disc_no, t.track_no)) else: logger.info("Unknown Beets lookup URI: %s", uri) 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} def get_distinct(self, field, query=None): logger.debug("Beets distinct query: %s (uri=%s)", field, query) if not self.remote.has_connection: return [] else: return self.remote.get_sorted_unique_track_attributes(field) def _validate_query(self, query): for values in query.values(): if not values: raise LookupError("Missing query") for value in values: if not value: raise LookupError("Missing query") ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607901597.0 Mopidy-Beets-4.0.1/mopidy_beets/translator.py0000644000175100001710000001422200000000000020604 0ustar00runnerdockerimport logging import urllib.error import urllib.parse import urllib.request from mopidy.models import Album, Artist, Track logger = logging.getLogger(__name__) def parse_date(data): # use 'original' dates if possible if "original_year" in data: day = data.get("original_day", None) month = data.get("original_month", None) year = data.get("original_year", None) elif "year" in data: day = data.get("day", None) month = data.get("month", None) year = data.get("year", None) else: 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) def _apply_beets_mapping(target_class, mapping, data): """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 = {} 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, None) # 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(values): return [value for value in values if value is not None] 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] ), "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, 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'. 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": lambda d: parse_date(d), "musicbrainz_id": "mb_albumid", } return _apply_beets_mapping(Album, mapping, data) def parse_track(data, api): # 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: "beets:library:track;%s" % 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 "album_id" in d else None ), "composers": lambda d: _filter_none([parse_artist(d, "composer")]), "performers": None, "genre": "genre", "track_no": "track", "disc_no": "disc", "date": lambda d: parse_date(d), "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_prefix=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 color, 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 splitted 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, **kwargs): base_path = ":".join(args) 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) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607901597.0 Mopidy-Beets-4.0.1/pyproject.toml0000644000175100001710000000052600000000000016274 0ustar00runnerdocker[build-system] requires = ["setuptools >= 30.3.0", "wheel"] [tool.black] target-version = ["py37", "py38"] line-length = 80 [tool.isort] multi_line_output = 3 include_trailing_comma = true force_grid_wrap = 0 use_parentheses = true line_length = 88 known_tests = "tests" sections = "FUTURE,STDLIB,THIRDPARTY,FIRSTPARTY,TESTS,LOCALFOLDER" ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1607901605.1539023 Mopidy-Beets-4.0.1/setup.cfg0000644000175100001710000000255700000000000015207 0ustar00runnerdocker[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.7 Programming Language :: Python :: 3.8 Programming Language :: Python :: 3.9 Topic :: Multimedia :: Sound/Audio :: Players [options] zip_safe = False include_package_data = True packages = find: python_requires = >= 3.7 install_requires = Mopidy >= 3.0.0 Pykka >= 2.0.1 requests >= 2.0.0 setuptools [options.extras_require] lint = black check-manifest flake8 flake8-bugbear flake8-import-order isort[pyproject] release = build twine test = pytest pytest-cov dev = %(lint)s %(release)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 = C, E, F, W B B950 N ignore = E203 E501 W503 B305 [egg_info] tag_build = tag_date = 0 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607901597.0 Mopidy-Beets-4.0.1/setup.py0000644000175100001710000000004600000000000015067 0ustar00runnerdockerfrom setuptools import setup setup() ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1607901605.1539023 Mopidy-Beets-4.0.1/tests/0000755000175100001710000000000000000000000014517 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607901597.0 Mopidy-Beets-4.0.1/tests/__init__.py0000644000175100001710000000000000000000000016616 0ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607901597.0 Mopidy-Beets-4.0.1/tests/test_extension.py0000644000175100001710000000130100000000000020137 0ustar00runnerdockerfrom __future__ import unicode_literals import unittest from mopidy_beets import BeetsExtension class ExtensionTest(unittest.TestCase): def test_get_default_config(self): ext = BeetsExtension() 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) def test_get_config_schema(self): ext = BeetsExtension() schema = ext.get_config_schema() self.assertIn("enabled", schema) self.assertIn("hostname", schema) self.assertIn("port", schema) # TODO Write more tests ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1607901597.0 Mopidy-Beets-4.0.1/tox.ini0000644000175100001710000000074400000000000014675 0ustar00runnerdocker[tox] envlist = py37, py38, py39, black, check-manifest, flake8 [testenv] sitepackages = true deps = .[test] commands = python -m pytest \ --basetemp={envtmpdir} \ --cov=mopidy_beets --cov-report=term-missing \ {posargs} [testenv:black] deps = .[lint] commands = python -m black --check . [testenv:check-manifest] deps = .[lint] commands = python -m check_manifest [testenv:flake8] deps = .[lint] commands = python -m flake8 --show-source --statistics