Mopidy-Beets-4.0.0rc2/0000775000175000017500000000000013601452241014627 5ustar jodaljodal00000000000000Mopidy-Beets-4.0.0rc2/.circleci/0000775000175000017500000000000013601452241016462 5ustar jodaljodal00000000000000Mopidy-Beets-4.0.0rc2/.circleci/config.yml0000664000175000017500000000206513601445147020464 0ustar jodaljodal00000000000000version: 2.1 orbs: codecov: codecov/codecov@1.0.5 workflows: version: 2 test: jobs: - py38 - py37 - black - check-manifest - flake8 jobs: py38: &test-template docker: - image: mopidy/ci-python:3.8 steps: - checkout - restore_cache: name: Restoring tox cache key: tox-v1-{{ .Environment.CIRCLE_JOB }}-{{ checksum "setup.cfg" }} - run: name: Run tests command: | tox -e $CIRCLE_JOB -- \ --junit-xml=test-results/pytest/results.xml \ --cov-report=xml - save_cache: name: Saving tox cache key: tox-v1-{{ .Environment.CIRCLE_JOB }}-{{ checksum "setup.cfg" }} paths: - ./.tox - ~/.cache/pip - codecov/upload: file: coverage.xml - store_test_results: path: test-results py37: <<: *test-template docker: - image: mopidy/ci-python:3.7 black: *test-template check-manifest: *test-template flake8: *test-template Mopidy-Beets-4.0.0rc2/.mailmap0000664000175000017500000000015113562060473016255 0ustar jodaljodal00000000000000Janez Troha Janez Troha Mopidy-Beets-4.0.0rc2/CHANGELOG.rst0000664000175000017500000000311613601452155016655 0ustar jodaljodal00000000000000********* Changelog ********* v4.0.0rc2 (2019-12-27) ====================== - Fix casing of PyPI distribution name. v4.0.0rc1 (2019-12-27) ====================== - Require Mopidy >= 3.0.0a3. - Require Python >= 3.7. No major changes required. - Update project setup. - Change default port from 8888 to 8337 to match Beets' defaults. v3.1.0 (2016-11-23) =================== - Fix handling of non-ascii characters in album titles and artist names - Fix handling of empty titles and names - Reduce ressource consumption of string matching API requests v3.0.0 (2016-05-28) =================== - Support browsing albums by artist, genre and year - Improved search (more categories, more precise) - Align with Mopidy's current extension guidelines v2.0.0 (2015-03-25) =================== - Require Mopidy >= 1.0. - Update to work with new playback API in Mopidy 1.0. - Update to work with new backend search API in Mopidy 1.0. v1.1.0 (2014-01-20) =================== - Require Requests >= 2.0. - Updated extension and backend APIs to match Mopidy 0.18. v1.0.4 (2013-12-15) =================== - Require Requests >= 1.0, as 0.x does not seem to be enough. (Fixes: #7) - Remove hacks for Python 2.6 compatibility. - Change search field ``track`` to ``track_name`` for compatibility with Mopidy 0.17. (Fixes: mopidy/mopidy#610) v1.0.3 (2013-11-02) =================== - Properly encode search queries containing non-ASCII chars. - Rename logger to ``mopidy_beets``. v1.0.2 (2013-04-30) =================== - Fix search. v1.0.1 (2013-04-28) =================== - Initial release. Mopidy-Beets-4.0.0rc2/LICENSE0000664000175000017500000000205713562060473015650 0ustar jodaljodal00000000000000Copyright (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.Mopidy-Beets-4.0.0rc2/MANIFEST.in0000664000175000017500000000043613601444604016374 0ustar jodaljodal00000000000000include *.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 * Mopidy-Beets-4.0.0rc2/Mopidy_Beets.egg-info/0000775000175000017500000000000013601452241020704 5ustar jodaljodal00000000000000Mopidy-Beets-4.0.0rc2/Mopidy_Beets.egg-info/PKG-INFO0000664000175000017500000001060513601452241022003 0ustar jodaljodal00000000000000Metadata-Version: 2.1 Name: Mopidy-Beets Version: 4.0.0rc2 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: Topic :: Multimedia :: Sound/Audio :: Players Requires-Python: >=3.7 Provides-Extra: lint Provides-Extra: release Provides-Extra: test Provides-Extra: dev Mopidy-Beets-4.0.0rc2/Mopidy_Beets.egg-info/SOURCES.txt0000664000175000017500000000115413601452241022571 0ustar jodaljodal00000000000000.mailmap CHANGELOG.rst LICENSE MANIFEST.in README.rst pyproject.toml setup.cfg setup.py tox.ini .circleci/config.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.pyMopidy-Beets-4.0.0rc2/Mopidy_Beets.egg-info/dependency_links.txt0000664000175000017500000000000113601452241024752 0ustar jodaljodal00000000000000 Mopidy-Beets-4.0.0rc2/Mopidy_Beets.egg-info/entry_points.txt0000664000175000017500000000006213601452241024200 0ustar jodaljodal00000000000000[mopidy.ext] beets = mopidy_beets:BeetsExtension Mopidy-Beets-4.0.0rc2/Mopidy_Beets.egg-info/not-zip-safe0000664000175000017500000000000113601452241023132 0ustar jodaljodal00000000000000 Mopidy-Beets-4.0.0rc2/Mopidy_Beets.egg-info/requires.txt0000664000175000017500000000046413601452241023310 0ustar jodaljodal00000000000000Mopidy>=3.0.0 Pykka>=2.0.1 requests>=2.0.0 setuptools [dev] black check-manifest flake8 flake8-bugbear flake8-import-order isort[pyproject] twine wheel pytest pytest-cov [lint] black check-manifest flake8 flake8-bugbear flake8-import-order isort[pyproject] [release] twine wheel [test] pytest pytest-cov Mopidy-Beets-4.0.0rc2/Mopidy_Beets.egg-info/top_level.txt0000664000175000017500000000001513601452241023432 0ustar jodaljodal00000000000000mopidy_beets Mopidy-Beets-4.0.0rc2/PKG-INFO0000664000175000017500000001060513601452241015726 0ustar jodaljodal00000000000000Metadata-Version: 2.1 Name: Mopidy-Beets Version: 4.0.0rc2 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: Topic :: Multimedia :: Sound/Audio :: Players Requires-Python: >=3.7 Provides-Extra: lint Provides-Extra: release Provides-Extra: test Provides-Extra: dev Mopidy-Beets-4.0.0rc2/README.rst0000664000175000017500000000552613601447067016340 0ustar jodaljodal00000000000000************ 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 `_ Mopidy-Beets-4.0.0rc2/mopidy_beets/0000775000175000017500000000000013601452241017312 5ustar jodaljodal00000000000000Mopidy-Beets-4.0.0rc2/mopidy_beets/__init__.py0000664000175000017500000000132513601445040021423 0ustar jodaljodal00000000000000import 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) Mopidy-Beets-4.0.0rc2/mopidy_beets/actor.py0000664000175000017500000000174613601444604021010 0ustar jodaljodal00000000000000import 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) Mopidy-Beets-4.0.0rc2/mopidy_beets/browsers/0000775000175000017500000000000013601452241021160 5ustar jodaljodal00000000000000Mopidy-Beets-4.0.0rc2/mopidy_beets/browsers/__init__.py0000664000175000017500000000131013601444604023270 0ustar jodaljodal00000000000000class 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 Mopidy-Beets-4.0.0rc2/mopidy_beets/browsers/albums.py0000664000175000017500000000363613601444604023031 0ustar jodaljodal00000000000000from 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 Mopidy-Beets-4.0.0rc2/mopidy_beets/client.py0000664000175000017500000002743413601444604021160 0ustar jodaljodal00000000000000import 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): # 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] Mopidy-Beets-4.0.0rc2/mopidy_beets/ext.conf0000664000175000017500000000007013601446276020771 0ustar jodaljodal00000000000000[beets] enabled = true hostname = 127.0.0.1 port = 8337 Mopidy-Beets-4.0.0rc2/mopidy_beets/library.py0000664000175000017500000001724213601444604021342 0ustar jodaljodal00000000000000import 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") Mopidy-Beets-4.0.0rc2/mopidy_beets/translator.py0000664000175000017500000001430413601444723022065 0ustar jodaljodal00000000000000import 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) Mopidy-Beets-4.0.0rc2/pyproject.toml0000664000175000017500000000052613601444604017552 0ustar jodaljodal00000000000000[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" Mopidy-Beets-4.0.0rc2/setup.cfg0000664000175000017500000000251313601452241016451 0ustar jodaljodal00000000000000[metadata] name = Mopidy-Beets version = 4.0.0rc2 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 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 = twine wheel 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 Mopidy-Beets-4.0.0rc2/setup.py0000664000175000017500000000004613601444604016345 0ustar jodaljodal00000000000000from setuptools import setup setup() Mopidy-Beets-4.0.0rc2/tests/0000775000175000017500000000000013601452241015771 5ustar jodaljodal00000000000000Mopidy-Beets-4.0.0rc2/tests/__init__.py0000664000175000017500000000000013562060473020100 0ustar jodaljodal00000000000000Mopidy-Beets-4.0.0rc2/tests/test_extension.py0000664000175000017500000000130113601447010021407 0ustar jodaljodal00000000000000from __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 Mopidy-Beets-4.0.0rc2/tox.ini0000664000175000017500000000073613601447363016161 0ustar jodaljodal00000000000000[tox] envlist = py37, py38, 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