Mopidy-Beets-3.0.0/0000755000175000017500000000000012722212536013741 5ustar larslars00000000000000Mopidy-Beets-3.0.0/.travis.yml0000644000175000017500000000071712705465621016065 0ustar larslars00000000000000sudo: false language: python python: - "2.7_with_system_site_packages" env: - TOX_ENV=py27 - TOX_ENV=flake8 install: - "pip install tox" script: - "tox -e $TOX_ENV" after_success: - "if [ $TOX_ENV == 'py27' ]; then pip install coveralls; coveralls; fi" branches: except: - debian notifications: irc: channels: - "irc.freenode.org#mopidy" on_success: change on_failure: change use_notice: true skip_join: true Mopidy-Beets-3.0.0/LICENSE0000644000175000017500000000205712705465621014760 0ustar larslars00000000000000Copyright (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-3.0.0/setup.py0000644000175000017500000000235412722206633015460 0ustar larslars00000000000000from __future__ import unicode_literals import re from setuptools import find_packages, setup def get_version(filename): with open(filename) as fh: metadata = dict(re.findall("__([a-z]+)__ = '([^']+)'", fh.read())) return metadata['version'] setup( name='Mopidy-Beets', version=get_version('mopidy_beets/__init__.py'), url='https://github.com/mopidy/mopidy-beets', license='MIT', author='Lars Kruse', author_email='devel@sumpfralle.de', description='Beets extension for Mopidy', long_description=open('README.rst').read(), packages=find_packages(exclude=['tests', 'tests.*']), zip_safe=False, include_package_data=True, install_requires=[ 'setuptools', 'Mopidy >= 1.0', 'Pykka >= 1.1', 'requests >= 2.0.0', ], entry_points={ 'mopidy.ext': [ 'beets = mopidy_beets:BeetsExtension', ], }, classifiers=[ 'Environment :: No Input/Output (Daemon)', 'Intended Audience :: End Users/Desktop', 'License :: OSI Approved :: MIT License', 'Operating System :: OS Independent', 'Programming Language :: Python :: 2', 'Topic :: Multimedia :: Sound/Audio :: Players', ], ) Mopidy-Beets-3.0.0/PKG-INFO0000644000175000017500000001334112722212536015040 0ustar larslars00000000000000Metadata-Version: 1.1 Name: Mopidy-Beets Version: 3.0.0 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.svg?style=flat :target: https://pypi.python.org/pypi/Mopidy-Beets/ :alt: Latest PyPI version .. image:: https://img.shields.io/pypi/dm/Mopidy-Beets.svg?style=flat :target: https://pypi.python.org/pypi/Mopidy-Beets/ :alt: Number of PyPI downloads .. image:: https://img.shields.io/travis/mopidy/mopidy-beets/master.svg?style=flat :target: https://travis-ci.org/mopidy/mopidy-beets :alt: Travis CI build status .. image:: https://img.shields.io/coveralls/mopidy/mopidy-beets/master.svg?style=flat :target: https://coveralls.io/r/mopidy/mopidy-beets?branch=master :alt: Test coverage `Mopidy `_ extension for browsing, searching and playing music from `Beets `_ via Beets' web extension. Installation ============ Install by running:: pip install Mopidy-Beets Or, if available, install the Debian/Ubuntu package from `apt.mopidy.com `_. 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 = 8888 #. Restart Mopidy. #. Searches in Mopidy will now 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 installation. 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:8889; root /usr/share/beets/beetsplug/web; server_name beets.local; location / { proxy_pass http://localhost:8888; # 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 intead 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 `_ Credits ======= - Original author: `Janez Troha `_ - Current maintainer: `Lars Kruse `_ - `Contributors `_ Changelog ========= 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. 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 :: 2 Classifier: Topic :: Multimedia :: Sound/Audio :: Players Mopidy-Beets-3.0.0/tests/0000755000175000017500000000000012722212536015103 5ustar larslars00000000000000Mopidy-Beets-3.0.0/tests/test_extension.py0000644000175000017500000000130212705465621020532 0ustar larslars00000000000000from __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 = 8888', 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-3.0.0/tests/__init__.py0000644000175000017500000000000012705465621017210 0ustar larslars00000000000000Mopidy-Beets-3.0.0/tox.ini0000644000175000017500000000056212705465621015265 0ustar larslars00000000000000[tox] envlist = py27, flake8 [testenv] deps = mock pytest pytest-cov pytest-xdist commands = py.test \ --basetemp={envtmpdir} \ --junit-xml=xunit-{envname}.xml \ --cov=mopidy_beets --cov-report=term-missing \ {posargs} [testenv:flake8] deps = flake8 flake8-import-order skip_install = true commands = flake8 Mopidy-Beets-3.0.0/mopidy_beets/0000755000175000017500000000000012722212536016424 5ustar larslars00000000000000Mopidy-Beets-3.0.0/mopidy_beets/browsers/0000755000175000017500000000000012722212536020272 5ustar larslars00000000000000Mopidy-Beets-3.0.0/mopidy_beets/browsers/albums.py0000644000175000017500000000354612722172232022135 0ustar larslars00000000000000from 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=unicode(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-3.0.0/mopidy_beets/browsers/__init__.py0000644000175000017500000000131112722165576022412 0ustar larslars00000000000000class 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-3.0.0/mopidy_beets/ext.conf0000644000175000017500000000007012705465621020076 0ustar larslars00000000000000[beets] enabled = true hostname = 127.0.0.1 port = 8888 Mopidy-Beets-3.0.0/mopidy_beets/translator.py0000644000175000017500000001467612722211031021172 0ustar larslars00000000000000import logging import urllib 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', # TODO: 'images' is deprecated since v1.2 - move to Library.get_images 'images': lambda d, api=api: _filter_none( [api.get_album_art_url(d['id'])]), } 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: id_value = urllib.unquote(id_string.encode('ascii')).decode('utf-8') # 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, unicode)): id_value = str(id_value) id_string = urllib.quote(id_value.encode('utf-8')) return '%s;%s' % (base_path, id_string) Mopidy-Beets-3.0.0/mopidy_beets/library.py0000644000175000017500000001702712722205066020451 0ustar larslars00000000000000from __future__ import unicode_literals import 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('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('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-3.0.0/mopidy_beets/client.py0000644000175000017500000002574412722211031020255 0ustar larslars00000000000000from __future__ import unicode_literals import logging import time import urllib 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): # utf-8 seems to be necessary for Python 2.7 and urllib.quote if isinstance(text, unicode): text = text.encode('utf-8') elif 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.quote(text) for attribute in attributes: if isinstance(attribute, basestring): key = None value = quote_and_encode(attribute) query_parts.append(value) exact_query_list.append((None, attribute)) else: # the beets API accepts upper and lower case, but always # returns lower case attributes key = quote_and_encode(attribute[0].lower()) value = quote_and_encode(attribute[1]) query_parts.append('{0}:{1}'.format(key, value)) exact_query_list.append((attribute[0].lower(), attribute[1])) # 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], basestring): 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. logging.warning( 'Failed to use the /item/unique/KEY feature of the Beets ' 'API (introduced after v1.3.17). 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.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('Requesting %s' % url) try: req = self.api.get(url) except RequestException as e: logger.error('Request %s, failed with error %s', url, e) return None if req.status_code != 200: logger.error('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('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('Failed to parse track data: %s', exc) return [track for track in tracks if track] Mopidy-Beets-3.0.0/mopidy_beets/__init__.py0000644000175000017500000000127112722211032020524 0ustar larslars00000000000000from __future__ import unicode_literals import os from mopidy import config, ext __version__ = '3.0.0' 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-3.0.0/mopidy_beets/actor.py0000644000175000017500000000177212722165576020130 0ustar larslars00000000000000from __future__ import unicode_literals import 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-3.0.0/README.rst0000644000175000017500000000766612722211032015435 0ustar larslars00000000000000************ Mopidy-Beets ************ .. image:: https://img.shields.io/pypi/v/Mopidy-Beets.svg?style=flat :target: https://pypi.python.org/pypi/Mopidy-Beets/ :alt: Latest PyPI version .. image:: https://img.shields.io/pypi/dm/Mopidy-Beets.svg?style=flat :target: https://pypi.python.org/pypi/Mopidy-Beets/ :alt: Number of PyPI downloads .. image:: https://img.shields.io/travis/mopidy/mopidy-beets/master.svg?style=flat :target: https://travis-ci.org/mopidy/mopidy-beets :alt: Travis CI build status .. image:: https://img.shields.io/coveralls/mopidy/mopidy-beets/master.svg?style=flat :target: https://coveralls.io/r/mopidy/mopidy-beets?branch=master :alt: Test coverage `Mopidy `_ extension for browsing, searching and playing music from `Beets `_ via Beets' web extension. Installation ============ Install by running:: pip install Mopidy-Beets Or, if available, install the Debian/Ubuntu package from `apt.mopidy.com `_. 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 = 8888 #. Restart Mopidy. #. Searches in Mopidy will now 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 installation. 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:8889; root /usr/share/beets/beetsplug/web; server_name beets.local; location / { proxy_pass http://localhost:8888; # 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 intead 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 `_ Credits ======= - Original author: `Janez Troha `_ - Current maintainer: `Lars Kruse `_ - `Contributors `_ Changelog ========= 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-3.0.0/.mailmap0000644000175000017500000000015112705465621015365 0ustar larslars00000000000000Janez Troha Janez Troha Mopidy-Beets-3.0.0/Mopidy_Beets.egg-info/0000755000175000017500000000000012722212536020016 5ustar larslars00000000000000Mopidy-Beets-3.0.0/Mopidy_Beets.egg-info/PKG-INFO0000644000175000017500000001334112722212533021112 0ustar larslars00000000000000Metadata-Version: 1.1 Name: Mopidy-Beets Version: 3.0.0 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.svg?style=flat :target: https://pypi.python.org/pypi/Mopidy-Beets/ :alt: Latest PyPI version .. image:: https://img.shields.io/pypi/dm/Mopidy-Beets.svg?style=flat :target: https://pypi.python.org/pypi/Mopidy-Beets/ :alt: Number of PyPI downloads .. image:: https://img.shields.io/travis/mopidy/mopidy-beets/master.svg?style=flat :target: https://travis-ci.org/mopidy/mopidy-beets :alt: Travis CI build status .. image:: https://img.shields.io/coveralls/mopidy/mopidy-beets/master.svg?style=flat :target: https://coveralls.io/r/mopidy/mopidy-beets?branch=master :alt: Test coverage `Mopidy `_ extension for browsing, searching and playing music from `Beets `_ via Beets' web extension. Installation ============ Install by running:: pip install Mopidy-Beets Or, if available, install the Debian/Ubuntu package from `apt.mopidy.com `_. 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 = 8888 #. Restart Mopidy. #. Searches in Mopidy will now 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 installation. 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:8889; root /usr/share/beets/beetsplug/web; server_name beets.local; location / { proxy_pass http://localhost:8888; # 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 intead 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 `_ Credits ======= - Original author: `Janez Troha `_ - Current maintainer: `Lars Kruse `_ - `Contributors `_ Changelog ========= 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. 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 :: 2 Classifier: Topic :: Multimedia :: Sound/Audio :: Players Mopidy-Beets-3.0.0/Mopidy_Beets.egg-info/SOURCES.txt0000644000175000017500000000110612722212535021677 0ustar larslars00000000000000.mailmap .travis.yml LICENSE MANIFEST.in README.rst setup.cfg setup.py tox.ini 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-3.0.0/Mopidy_Beets.egg-info/top_level.txt0000644000175000017500000000001512722212533022541 0ustar larslars00000000000000mopidy_beets Mopidy-Beets-3.0.0/Mopidy_Beets.egg-info/dependency_links.txt0000644000175000017500000000000112722212533024061 0ustar larslars00000000000000 Mopidy-Beets-3.0.0/Mopidy_Beets.egg-info/not-zip-safe0000644000175000017500000000000112722206642022245 0ustar larslars00000000000000 Mopidy-Beets-3.0.0/Mopidy_Beets.egg-info/entry_points.txt0000644000175000017500000000006212722212533023307 0ustar larslars00000000000000[mopidy.ext] beets = mopidy_beets:BeetsExtension Mopidy-Beets-3.0.0/Mopidy_Beets.egg-info/requires.txt0000644000175000017500000000007012722212533022410 0ustar larslars00000000000000setuptools Mopidy >= 1.0 Pykka >= 1.1 requests >= 2.0.0 Mopidy-Beets-3.0.0/MANIFEST.in0000644000175000017500000000025012705465621015502 0ustar larslars00000000000000include .mailmap include .travis.yml include LICENSE include MANIFEST.in include README.rst include mopidy_beets/ext.conf include tox.ini recursive-include tests *.py Mopidy-Beets-3.0.0/setup.cfg0000644000175000017500000000023612722212536015563 0ustar larslars00000000000000[flake8] application-import-names = mopidy_beets,tests exclude = .git,.tox [wheel] universal = 1 [egg_info] tag_build = tag_date = 0 tag_svn_revision = 0