PKUSGrmopidy_spotify/library.pyfrom __future__ import unicode_literals import logging from mopidy import backend from mopidy_spotify import browse, distinct, images, lookup, search logger = logging.getLogger(__name__) class SpotifyLibraryProvider(backend.LibraryProvider): root_directory = browse.ROOT_DIR def __init__(self, backend): self._backend = backend self._config = self._backend._config['spotify'] def browse(self, uri): return browse.browse(self._config, self._backend._session, uri) def get_distinct(self, field, query=None): return distinct.get_distinct( self._config, self._backend._session, field, query) def get_images(self, uris): return images.get_images(uris) def lookup(self, uri): return lookup.lookup(self._config, self._backend._session, uri) def search(self, query=None, uris=None, exact=False): return search.search( self._config, self._backend._session, query, uris, exact) PKUSG\nmopidy_spotify/search.pyfrom __future__ import unicode_literals import logging import urllib from mopidy import models import spotify from mopidy_spotify import lookup, translator logger = logging.getLogger(__name__) def search(config, session, query=None, uris=None, exact=False): # TODO Respect `uris` argument # TODO Support `exact` search if query is None: logger.debug('Ignored search without query') return models.SearchResult(uri='spotify:search') if 'uri' in query: return _search_by_uri(config, session, query) sp_query = translator.sp_search_query(query) if not sp_query: logger.debug('Ignored search with empty query') return models.SearchResult(uri='spotify:search') uri = 'spotify:search:%s' % urllib.quote(sp_query.encode('utf-8')) logger.info('Searching Spotify for: %s', sp_query) if session.connection.state is not spotify.ConnectionState.LOGGED_IN: logger.info('Spotify search aborted: Spotify is offline') return models.SearchResult(uri=uri) sp_search = session.search( sp_query, album_count=config['search_album_count'], artist_count=config['search_artist_count'], track_count=config['search_track_count']) sp_search.load() albums = [ translator.to_album(sp_album) for sp_album in sp_search.albums] artists = [ translator.to_artist(sp_artist) for sp_artist in sp_search.artists] tracks = [ translator.to_track(sp_track) for sp_track in sp_search.tracks] return models.SearchResult( uri=uri, albums=albums, artists=artists, tracks=tracks) def _search_by_uri(config, session, query): tracks = [] for uri in query['uri']: tracks += lookup.lookup(config, session, uri) uri = 'spotify:search' if len(query['uri']) == 1: uri = query['uri'][0] return models.SearchResult(uri=uri, tracks=tracks) PKZSG'mopidy_spotify/distinct.pyfrom __future__ import unicode_literals import logging import spotify from mopidy_spotify import translator logger = logging.getLogger(__name__) def get_distinct(config, session, field, query=None): # To make the returned data as interesting as possible, we limit # ourselves to data extracted from the user's playlists when no search # query is included. sp_query = translator.sp_search_query(query) if query else None if field == 'artist': result = _get_distinct_artists(config, session, sp_query) elif field == 'albumartist': result = _get_distinct_albumartists(config, session, sp_query) elif field == 'album': result = _get_distinct_albums(config, session, sp_query) elif field == 'date': result = _get_distinct_dates(config, session, sp_query) else: result = set() return result - {None} def _get_distinct_artists(config, session, sp_query): logger.debug('Getting distinct artists: %s', sp_query) if sp_query: sp_search = _get_sp_search(config, session, sp_query, artist=True) if sp_search is None: return set() return {artist.name for artist in sp_search.artists} else: return { artist.name for track in _get_playlist_tracks(config, session) for artist in track.artists} def _get_distinct_albumartists(config, session, sp_query): logger.debug( 'Getting distinct albumartists: %s', sp_query) if sp_query: sp_search = _get_sp_search(config, session, sp_query, album=True) if sp_search is None: return set() return { album.artist.name for album in sp_search.albums if album.artist} else: return { track.album.artist.name for track in _get_playlist_tracks(config, session) if track.album and track.album.artist} def _get_distinct_albums(config, session, sp_query): logger.debug('Getting distinct albums: %s', sp_query) if sp_query: sp_search = _get_sp_search(config, session, sp_query, album=True) if sp_search is None: return set() return {album.name for album in sp_search.albums} else: return { track.album.name for track in _get_playlist_tracks(config, session) if track.album} def _get_distinct_dates(config, session, sp_query): logger.debug('Getting distinct album years: %s', sp_query) if sp_query: sp_search = _get_sp_search(config, session, sp_query, album=True) if sp_search is None: return set() return { '%s' % album.year for album in sp_search.albums if album.year not in (None, 0)} else: return { '%s' % track.album.year for track in _get_playlist_tracks(config, session) if track.album and track.album.year not in (None, 0)} def _get_sp_search( config, session, sp_query, album=False, artist=False, track=False): if session.connection.state is not spotify.ConnectionState.LOGGED_IN: logger.info('Spotify search aborted: Spotify is offline') return None sp_search = session.search( sp_query, album_count=config['search_album_count'] if album else 0, artist_count=config['search_artist_count'] if artist else 0, track_count=config['search_track_count'] if track else 0) sp_search.load() return sp_search def _get_playlist_tracks(config, session): if not config['allow_playlists']: return for playlist in session.playlist_container: if not isinstance(playlist, spotify.Playlist): continue playlist.load() for track in playlist.tracks: try: track.load() yield track except spotify.Error: # TODO Why did we get "General error"? continue PKUSG"jʇmopidy_spotify/browse.pyfrom __future__ import unicode_literals import logging from mopidy import models import spotify from mopidy_spotify import countries, translator logger = logging.getLogger(__name__) ROOT_DIR = models.Ref.directory( uri='spotify:directory', name='Spotify') _ROOT_DIR_CONTENTS = [ models.Ref.directory( uri='spotify:top:tracks', name='Top tracks'), models.Ref.directory( uri='spotify:top:albums', name='Top albums'), models.Ref.directory( uri='spotify:top:artists', name='Top artists'), ] _TOPLIST_TYPES = { 'albums': spotify.ToplistType.ALBUMS, 'artists': spotify.ToplistType.ARTISTS, 'tracks': spotify.ToplistType.TRACKS, } _TOPLIST_REGIONS = { 'user': lambda session: spotify.ToplistRegion.USER, 'country': lambda session: session.user_country, 'everywhere': lambda session: spotify.ToplistRegion.EVERYWHERE, } def browse(config, session, uri): if uri == ROOT_DIR.uri: return _ROOT_DIR_CONTENTS elif uri.startswith('spotify:user:'): return _browse_playlist(session, uri) elif uri.startswith('spotify:album:'): return _browse_album(session, uri) elif uri.startswith('spotify:artist:'): return _browse_artist(session, uri) elif uri.startswith('spotify:top:'): parts = uri.replace('spotify:top:', '').split(':') if len(parts) == 1: return _browse_toplist_regions(variant=parts[0]) elif len(parts) == 2: return _browse_toplist( config, session, variant=parts[0], region=parts[1]) else: logger.info( 'Failed to browse "%s": Toplist URI parsing failed', uri) return [] else: logger.info('Failed to browse "%s": Unknown URI type', uri) return [] def _browse_playlist(session, uri): sp_playlist = session.get_playlist(uri) sp_playlist.load() return list(translator.to_track_refs(sp_playlist.tracks)) def _browse_album(session, uri): sp_album_browser = session.get_album(uri).browse() sp_album_browser.load() return list(translator.to_track_refs(sp_album_browser.tracks)) def _browse_artist(session, uri): sp_artist_browser = session.get_artist(uri).browse( type=spotify.ArtistBrowserType.NO_TRACKS) sp_artist_browser.load() top_tracks = list(translator.to_track_refs( sp_artist_browser.tophit_tracks)) albums = list(translator.to_album_refs(sp_artist_browser.albums)) return top_tracks + albums def _browse_toplist_regions(variant): return [ models.Ref.directory( uri='spotify:top:%s:user' % variant, name='Personal'), models.Ref.directory( uri='spotify:top:%s:country' % variant, name='Country'), models.Ref.directory( uri='spotify:top:%s:countries' % variant, name='Other countries'), models.Ref.directory( uri='spotify:top:%s:everywhere' % variant, name='Global'), ] def _browse_toplist(config, session, variant, region): if region == 'countries': codes = config['toplist_countries'] if not codes: codes = countries.COUNTRIES.keys() return [ models.Ref.directory( uri='spotify:top:%s:%s' % (variant, code.lower()), name=countries.COUNTRIES.get(code.upper(), code.upper())) for code in codes] if region in ('user', 'country', 'everywhere'): sp_toplist = session.get_toplist( type=_TOPLIST_TYPES[variant], region=_TOPLIST_REGIONS[region](session)) elif len(region) == 2: sp_toplist = session.get_toplist( type=_TOPLIST_TYPES[variant], region=region.upper()) else: return [] if session.connection.state is spotify.ConnectionState.LOGGED_IN: sp_toplist.load() if not sp_toplist.is_loaded: return [] if variant == 'tracks': return list(translator.to_track_refs(sp_toplist.tracks)) elif variant == 'albums': return list(translator.to_album_refs(sp_toplist.albums)) elif variant == 'artists': return list(translator.to_artist_refs(sp_toplist.artists)) else: return [] PKUSGB<mopidy_spotify/countries.pyfrom __future__ import unicode_literals """ List of countries that Spotify is available in. The list is based on: https://support.spotify.com/us/learn-more/faq/#!/article/Availability-in-overseas-territories Last updated: 2015-01-22 """ COUNTRIES = { 'AD': 'Andorra', 'AR': 'Argentina', 'AU': 'Australia', 'AT': 'Austria', 'BE': 'Belgium', 'BO': 'Bolivia', 'BR': 'Brazil', 'BG': 'Bulgaria', 'CA': 'Canada', 'CL': 'Chile', 'CO': 'Colombia', 'CR': 'Costa Rica', 'CY': 'Cyprus', 'CZ': 'Czech Republic', 'DK': 'Denmark', 'DO': 'Dominican Republic', 'EC': 'Ecuador', 'SV': 'El Salvador', 'EE': 'Estonia', 'FI': 'Finland', 'FR': 'France', 'DE': 'Germany', 'GR': 'Greece', 'GT': 'Guatemala', 'HN': 'Honduras', 'HK': 'Hong Kong', 'HU': 'Hungary', 'IS': 'Iceland', 'IE': 'Ireland', 'IT': 'Italy', 'LV': 'Latvia', 'LI': 'Liechtenstein', 'LT': 'Lithuania', 'LU': 'Luxembourg', 'MY': 'Malaysia', 'MX': 'Mexico', 'MC': 'Monaco', 'NL': 'Netherlands', 'NZ': 'New Zealand', 'NI': 'Nicaragua', 'NO': 'Norway', 'PA': 'Panama', 'PY': 'Paraguay', 'PE': 'Peru', 'PH': 'Philipines', 'PL': 'Poland', 'PT': 'Portugal', 'SG': 'Singapore', 'SK': 'Slovakia', 'ES': 'Spain', 'SE': 'Sweden', 'CH': 'Switzerland', 'TW': 'Taiwan', 'TR': 'Turkey', 'GB': 'United Kingdom', 'US': 'United States', 'UY': 'Uruguay', } PKUSGܚmopidy_spotify/playback.pyfrom __future__ import unicode_literals import functools import logging import threading from mopidy import audio, backend import spotify logger = logging.getLogger(__name__) # These GStreamer caps matches the audio data provided by libspotify LIBSPOTIFY_GST_CAPS = ( 'audio/x-raw-int, endianness=(int)1234, channels=(int)2, ' 'width=(int)16, depth=(int)16, signed=(boolean)true, ' 'rate=(int)44100') # Extra log level with lower importance than DEBUG=10 for noisy debug logging TRACE_LOG_LEVEL = 5 class SpotifyPlaybackProvider(backend.PlaybackProvider): def __init__(self, *args, **kwargs): super(SpotifyPlaybackProvider, self).__init__(*args, **kwargs) self._timeout = self.backend._config['spotify']['timeout'] self._buffer_timestamp = BufferTimestamp(0) self._first_seek = False self._push_audio_data_event = threading.Event() self._push_audio_data_event.set() self._events_connected = False def _connect_events(self): if not self._events_connected: self._events_connected = True self.backend._session.on( spotify.SessionEvent.MUSIC_DELIVERY, music_delivery_callback, self.audio, self._push_audio_data_event, self._buffer_timestamp) self.backend._session.on( spotify.SessionEvent.END_OF_TRACK, end_of_track_callback, self.audio) def change_track(self, track): self._connect_events() if track.uri is None: return False need_data_callback_bound = functools.partial( need_data_callback, self._push_audio_data_event) enough_data_callback_bound = functools.partial( enough_data_callback, self._push_audio_data_event) seek_data_callback_bound = functools.partial( seek_data_callback, self.backend._actor_proxy) self._first_seek = True try: sp_track = self.backend._session.get_track(track.uri) sp_track.load(self._timeout) self.backend._session.player.load(sp_track) self.backend._session.player.play() self._buffer_timestamp.set(0) self.audio.set_appsrc( LIBSPOTIFY_GST_CAPS, need_data=need_data_callback_bound, enough_data=enough_data_callback_bound, seek_data=seek_data_callback_bound) self.audio.set_metadata(track) return True except spotify.Error as exc: logger.info('Playback of %s failed: %s', track.uri, exc) return False def resume(self): self.backend._session.player.play() return super(SpotifyPlaybackProvider, self).resume() def stop(self): self.backend._session.player.pause() return super(SpotifyPlaybackProvider, self).stop() def on_seek_data(self, time_position): logger.debug('Audio asked us to seek to %d', time_position) if time_position == 0 and self._first_seek: self._first_seek = False logger.debug('Skipping seek due to issue mopidy/mopidy#300') return self._buffer_timestamp.set( audio.millisecond_to_clocktime(time_position)) self.backend._session.player.seek(time_position) def need_data_callback(push_audio_data_event, length_hint): # This callback is called from GStreamer/the GObject event loop. logger.log( TRACE_LOG_LEVEL, 'Audio asked for more data (hint=%d); accepting deliveries', length_hint) push_audio_data_event.set() def enough_data_callback(push_audio_data_event): # This callback is called from GStreamer/the GObject event loop. logger.log( TRACE_LOG_LEVEL, 'Audio says it has enough data; rejecting deliveries') push_audio_data_event.clear() def seek_data_callback(spotify_backend, time_position): # This callback is called from GStreamer/the GObject event loop. # It forwards the call to the backend actor. spotify_backend.playback.on_seek_data(time_position) def music_delivery_callback( session, audio_format, frames, num_frames, audio_actor, push_audio_data_event, buffer_timestamp): # This is called from an internal libspotify thread. # Ideally, nothing here should block. if not push_audio_data_event.is_set(): return 0 known_format = ( audio_format.sample_type == spotify.SampleType.INT16_NATIVE_ENDIAN) assert known_format, 'Expects 16-bit signed integer samples' capabilites = """ audio/x-raw-int, endianness=(int)1234, channels=(int)%(channels)d, width=(int)16, depth=(int)16, signed=(boolean)true, rate=(int)%(sample_rate)d """ % { 'channels': audio_format.channels, 'sample_rate': audio_format.sample_rate, } duration = audio.calculate_duration( num_frames, audio_format.sample_rate) buffer_ = audio.create_buffer( bytes(frames), capabilites=capabilites, timestamp=buffer_timestamp.get(), duration=duration) buffer_timestamp.increase(duration) # We must block here to know if the buffer was consumed successfully. if audio_actor.emit_data(buffer_).get(): return num_frames else: return 0 def end_of_track_callback(session, audio_actor): # This callback is called from the pyspotify event loop. logger.debug('End of track reached') audio_actor.emit_data(None) class BufferTimestamp(object): """Wrapper around an int to serialize access by multiple threads. The value is used both from the backend actor and callbacks called by internal libspotify threads. """ def __init__(self, value): self._value = value self._lock = threading.RLock() def get(self): with self._lock: return self._value def set(self, value): with self._lock: self._value = value def increase(self, value): with self._lock: self._value += value PKUSGSݭmopidy_spotify/utils.pyfrom __future__ import unicode_literals import contextlib import locale import logging import time logger = logging.getLogger(__name__) TRACE = logging.getLevelName('TRACE') def locale_decode(bytestr): try: return unicode(bytestr) except UnicodeError: return bytes(bytestr).decode(locale.getpreferredencoding()) @contextlib.contextmanager def time_logger(name, level=TRACE): start = time.time() yield logger.log(level, '%s took %dms', name, (time.time() - start) * 1000) PKUSG' =mopidy_spotify/ext.conf[spotify] enabled = true username = password = bitrate = 160 volume_normalization = true private_session = false timeout = 10 allow_cache = true allow_network = true allow_playlists = true search_album_count = 20 search_artist_count = 10 search_track_count = 50 toplist_countries = PKUSG{a a mopidy_spotify/images.pyfrom __future__ import unicode_literals import itertools import json import logging import operator import urllib2 import urlparse from mopidy import models from mopidy_spotify import utils # NOTE: This module is independent of libspotify and built using the Spotify # Web APIs. As such it does not tie in with any of the regular code used # elsewhere in the mopidy-spotify extensions. It is also intended to be used # across both the 1.x and 2.x versions. _API_MAX_IDS_PER_REQUEST = 50 _API_BASE_URI = 'https://api.spotify.com/v1/%ss/?ids=%s' _cache = {} # (type, id) -> [Image(), ...] logger = logging.getLogger(__name__) def get_images(uris): result = {} uri_type_getter = operator.itemgetter('type') uris = sorted((_parse_uri(u) for u in uris), key=uri_type_getter) for uri_type, group in itertools.groupby(uris, uri_type_getter): batch = [] for uri in group: if uri['key'] in _cache: result[uri['uri']] = _cache[uri['key']] else: batch.append(uri) if len(batch) >= _API_MAX_IDS_PER_REQUEST: result.update(_process_uris(uri_type, batch)) batch = [] result.update(_process_uris(uri_type, batch)) return result def _parse_uri(uri): parsed_uri = urlparse.urlparse(uri) uri_type, uri_id = None, None if parsed_uri.scheme == 'spotify': uri_type, uri_id = parsed_uri.path.split(':')[:2] elif parsed_uri.scheme in ('http', 'https'): if parsed_uri.netloc in ('open.spotify.com', 'play.spotify.com'): uri_type, uri_id = parsed_uri.path.split('/')[1:3] if uri_type and uri_type in ('track', 'album', 'artist') and uri_id: return {'uri': uri, 'type': uri_type, 'id': uri_id, 'key': (uri_type, uri_id)} raise ValueError('Could not parse %r as a Spotify URI' % uri) def _process_uris(uri_type, uris): result = {} ids_to_uris = {u['id']: u for u in uris} if not uris: return result try: lookup_uri = _API_BASE_URI % ( uri_type, ','.join(sorted(ids_to_uris.keys()))) data = json.load(urllib2.urlopen(lookup_uri)) except (ValueError, IOError) as e: error_msg = utils.locale_decode(e) logger.debug('Fetching %s failed: %s', lookup_uri, error_msg) return result for item in data.get(uri_type + 's', []): if not item: continue uri = ids_to_uris[item['id']] if uri['key'] not in _cache: if uri_type == 'track': album_key = _parse_uri(item['album']['uri'])['key'] if album_key not in _cache: _cache[album_key] = tuple( _translate_image(i) for i in item['album']['images']) _cache[uri['key']] = _cache[album_key] else: _cache[uri['key']] = tuple( _translate_image(i) for i in item['images']) result[uri['uri']] = _cache[uri['key']] return result def _translate_image(i): return models.Image(uri=i['url'], height=i['height'], width=i['width']) PKUSGF/mopidy_spotify/translator.pyfrom __future__ import unicode_literals import collections import logging from mopidy import models import spotify logger = logging.getLogger(__name__) class memoized(object): def __init__(self, func): self.func = func self.cache = {} def __call__(self, *args, **kwargs): # NOTE Only args, not kwargs, are part of the memoization key. if not isinstance(args, collections.Hashable): return self.func(*args, **kwargs) if args in self.cache: return self.cache[args] else: value = self.func(*args, **kwargs) if value is not None: self.cache[args] = value return value @memoized def to_artist(sp_artist): if not sp_artist.is_loaded: return return models.Artist(uri=sp_artist.link.uri, name=sp_artist.name) @memoized def to_artist_ref(sp_artist): if not sp_artist.is_loaded: return return models.Ref.artist(uri=sp_artist.link.uri, name=sp_artist.name) def to_artist_refs(sp_artists): for sp_artist in sp_artists: sp_artist.load() ref = to_artist_ref(sp_artist) if ref is not None: yield ref @memoized def to_album(sp_album): if not sp_album.is_loaded: return if sp_album.artist is not None and sp_album.artist.is_loaded: artists = [to_artist(sp_album.artist)] else: artists = [] if sp_album.year is not None: date = '%d' % sp_album.year else: date = None return models.Album( uri=sp_album.link.uri, name=sp_album.name, artists=artists, date=date) @memoized def to_album_ref(sp_album): if not sp_album.is_loaded: return if sp_album.artist is None or not sp_album.artist.is_loaded: name = sp_album.name else: name = '%s - %s' % (sp_album.artist.name, sp_album.name) return models.Ref.album(uri=sp_album.link.uri, name=name) def to_album_refs(sp_albums): for sp_album in sp_albums: sp_album.load() ref = to_album_ref(sp_album) if ref is not None: yield ref @memoized def to_track(sp_track, bitrate=None): if not sp_track.is_loaded: return if sp_track.error != spotify.ErrorType.OK: logger.debug( 'Error loading %s: %r', sp_track.link.uri, sp_track.error) return if sp_track.availability != spotify.TrackAvailability.AVAILABLE: return artists = [to_artist(sp_artist) for sp_artist in sp_track.artists] artists = filter(None, artists) album = to_album(sp_track.album) return models.Track( uri=sp_track.link.uri, name=sp_track.name, artists=artists, album=album, date=album.date, length=sp_track.duration, disc_no=sp_track.disc, track_no=sp_track.index, bitrate=bitrate) @memoized def to_track_ref(sp_track): if not sp_track.is_loaded: return if sp_track.error != spotify.ErrorType.OK: logger.debug( 'Error loading %s: %r', sp_track.link.uri, sp_track.error) return if sp_track.availability != spotify.TrackAvailability.AVAILABLE: return return models.Ref.track(uri=sp_track.link.uri, name=sp_track.name) def to_track_refs(sp_tracks): for sp_track in sp_tracks: sp_track.load() ref = to_track_ref(sp_track) if ref is not None: yield ref def to_playlist( sp_playlist, folders=None, username=None, bitrate=None, as_ref=False, as_items=False): if not isinstance(sp_playlist, spotify.Playlist): return if not sp_playlist.is_loaded: return if as_items: return list(to_track_refs(sp_playlist.tracks)) name = sp_playlist.name if not as_ref: tracks = [ to_track(sp_track, bitrate=bitrate) for sp_track in sp_playlist.tracks] tracks = filter(None, tracks) if name is None: # Use same starred order as the Spotify client tracks = list(reversed(tracks)) if name is None: name = 'Starred' if folders is not None: name = '/'.join(folders + [name]) if username is not None and sp_playlist.owner.canonical_name != username: name = '%s (by %s)' % (name, sp_playlist.owner.canonical_name) if as_ref: return models.Ref.playlist(uri=sp_playlist.link.uri, name=name) else: return models.Playlist( uri=sp_playlist.link.uri, name=name, tracks=tracks) def to_playlist_ref(sp_playlist, folders=None, username=None): return to_playlist( sp_playlist, folders=folders, username=username, as_ref=True) # Maps from Mopidy search query field to Spotify search query field. # `None` if there is no matching concept. SEARCH_FIELD_MAP = { 'albumartist': 'artist', 'date': 'year', 'track_name': 'track', 'track_number': None, } def sp_search_query(query): """Translate a Mopidy search query to a Spotify search query""" result = [] for (field, values) in query.items(): field = SEARCH_FIELD_MAP.get(field, field) if field is None: continue for value in values: if field == 'year': value = _transform_year(value) if value is not None: result.append('%s:%d' % (field, value)) elif field == 'any': result.append('"%s"' % value) else: result.append('%s:"%s"' % (field, value)) return ' '.join(result) def _transform_year(date): try: return int(date.split('-')[0]) except ValueError: logger.debug( 'Excluded year from search query: ' 'Cannot parse date "%s"', date) PKZSGi)mopidy_spotify/__init__.pyfrom __future__ import unicode_literals import os from mopidy import config, ext __version__ = '2.0.1' class Extension(ext.Extension): dist_name = 'Mopidy-Spotify' ext_name = 'spotify' 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(Extension, self).get_config_schema() schema['username'] = config.String() schema['password'] = config.Secret() schema['bitrate'] = config.Integer(choices=(96, 160, 320)) schema['volume_normalization'] = config.Boolean() schema['private_session'] = config.Boolean() schema['timeout'] = config.Integer(minimum=0) schema['cache_dir'] = config.Deprecated() # since 2.0 schema['settings_dir'] = config.Deprecated() # since 2.0 schema['allow_cache'] = config.Boolean() schema['allow_network'] = config.Boolean() schema['allow_playlists'] = config.Boolean() schema['search_album_count'] = config.Integer(minimum=0, maximum=200) schema['search_artist_count'] = config.Integer(minimum=0, maximum=200) schema['search_track_count'] = config.Integer(minimum=0, maximum=200) schema['toplist_countries'] = config.List(optional=True) return schema def setup(self, registry): from mopidy_spotify.backend import SpotifyBackend registry.add('backend', SpotifyBackend) PKUSGSt""mopidy_spotify/backend.pyfrom __future__ import unicode_literals import logging import os import threading from mopidy import backend import pykka import spotify from mopidy_spotify import Extension, library, playback, playlists logger = logging.getLogger(__name__) BITRATES = { 96: spotify.Bitrate.BITRATE_96k, 160: spotify.Bitrate.BITRATE_160k, 320: spotify.Bitrate.BITRATE_320k, } class SpotifyBackend(pykka.ThreadingActor, backend.Backend): _logged_in = threading.Event() _logged_out = threading.Event() _logged_out.set() def __init__(self, config, audio): super(SpotifyBackend, self).__init__() self._config = config self._audio = audio self._actor_proxy = None self._session = None self._event_loop = None self._bitrate = None self.library = library.SpotifyLibraryProvider(backend=self) self.playback = playback.SpotifyPlaybackProvider( audio=audio, backend=self) if config['spotify']['allow_playlists']: self.playlists = playlists.SpotifyPlaylistsProvider(backend=self) else: self.playlists = None self.uri_schemes = ['spotify'] def on_start(self): self._actor_proxy = self.actor_ref.proxy() self._session = self._get_session(self._config) self._event_loop = spotify.EventLoop(self._session) self._event_loop.start() self._session.login( self._config['spotify']['username'], self._config['spotify']['password']) def on_stop(self): logger.debug('Logging out of Spotify') self._session.logout() self._logged_out.wait() self._event_loop.stop() def _get_session(self, config): session = spotify.Session(self._get_spotify_config(config)) session.connection.allow_network = config['spotify']['allow_network'] self._bitrate = config['spotify']['bitrate'] session.preferred_bitrate = BITRATES[self._bitrate] session.volume_normalization = ( config['spotify']['volume_normalization']) backend_actor_proxy = self._actor_proxy session.on( spotify.SessionEvent.CONNECTION_STATE_UPDATED, on_connection_state_changed, self._logged_in, self._logged_out, backend_actor_proxy) session.on( spotify.SessionEvent.PLAY_TOKEN_LOST, on_play_token_lost, backend_actor_proxy) return session def _get_spotify_config(self, config): ext = Extension() spotify_config = spotify.Config() spotify_config.load_application_key_file( os.path.join(os.path.dirname(__file__), 'spotify_appkey.key')) if config['spotify']['allow_cache']: spotify_config.cache_location = ext.get_cache_dir(config) else: spotify_config.cache_location = None spotify_config.settings_location = ext.get_data_dir(config) return spotify_config def on_logged_in(self): if self._config['spotify']['private_session']: logger.info('Spotify private session activated') self._session.social.private_session = True self._session.playlist_container.on( spotify.PlaylistContainerEvent.CONTAINER_LOADED, playlists.on_container_loaded) self._session.playlist_container.on( spotify.PlaylistContainerEvent.PLAYLIST_ADDED, playlists.on_playlist_added) self._session.playlist_container.on( spotify.PlaylistContainerEvent.PLAYLIST_REMOVED, playlists.on_playlist_removed) self._session.playlist_container.on( spotify.PlaylistContainerEvent.PLAYLIST_MOVED, playlists.on_playlist_moved) def on_play_token_lost(self): if self._session.player.state == spotify.PlayerState.PLAYING: self.playback.pause() logger.warning( 'Spotify has been paused because your account is ' 'being used somewhere else.') def on_connection_state_changed( session, logged_in_event, logged_out_event, backend): # Called from the pyspotify event loop, and not in an actor context. if session.connection.state is spotify.ConnectionState.LOGGED_OUT: logger.debug('Logged out of Spotify') logged_in_event.clear() logged_out_event.set() elif session.connection.state is spotify.ConnectionState.LOGGED_IN: logger.info('Logged in to Spotify in online mode') logged_in_event.set() logged_out_event.clear() backend.on_logged_in() elif session.connection.state is spotify.ConnectionState.DISCONNECTED: logger.info('Disconnected from Spotify') elif session.connection.state is spotify.ConnectionState.OFFLINE: logger.info('Logged in to Spotify in offline mode') logged_in_event.set() logged_out_event.clear() def on_play_token_lost(session, backend): # Called from the pyspotify event loop, and not in an actor context. logger.debug('Spotify play token lost') backend.on_play_token_lost() PKUSG;UP  mopidy_spotify/lookup.pyfrom __future__ import unicode_literals import logging import spotify from mopidy_spotify import translator, utils logger = logging.getLogger(__name__) _VARIOUS_ARTISTS_URIS = [ 'spotify:artist:0LyfQWJT6nXafLPZqxe9Of', ] def lookup(config, session, uri): try: sp_link = session.get_link(uri) except ValueError as exc: logger.info('Failed to lookup "%s": %s', uri, exc) return [] try: if sp_link.type is spotify.LinkType.TRACK: return list(_lookup_track(config, sp_link)) elif sp_link.type is spotify.LinkType.ALBUM: return list(_lookup_album(config, sp_link)) elif sp_link.type is spotify.LinkType.ARTIST: with utils.time_logger('Artist lookup'): return list(_lookup_artist(config, sp_link)) elif sp_link.type is spotify.LinkType.PLAYLIST: return list(_lookup_playlist(config, sp_link)) elif sp_link.type is spotify.LinkType.STARRED: return list(reversed(list(_lookup_playlist(config, sp_link)))) else: logger.info( 'Failed to lookup "%s": Cannot handle %r', uri, sp_link.type) return [] except spotify.Error as exc: logger.info('Failed to lookup "%s": %s', uri, exc) return [] def _lookup_track(config, sp_link): sp_track = sp_link.as_track() sp_track.load() track = translator.to_track(sp_track, bitrate=config['bitrate']) if track is not None: yield track def _lookup_album(config, sp_link): sp_album = sp_link.as_album() sp_album_browser = sp_album.browse() sp_album_browser.load() for sp_track in sp_album_browser.tracks: track = translator.to_track( sp_track, bitrate=config['bitrate']) if track is not None: yield track def _lookup_artist(config, sp_link): sp_artist = sp_link.as_artist() sp_artist_browser = sp_artist.browse( type=spotify.ArtistBrowserType.NO_TRACKS) sp_artist_browser.load() # Get all album browsers we need first, so they can start retrieving # data in the background. sp_album_browsers = [] for sp_album in sp_artist_browser.albums: sp_album.load() if not sp_album.is_available: continue if sp_album.type is spotify.AlbumType.COMPILATION: continue if sp_album.artist.link.uri in _VARIOUS_ARTISTS_URIS: continue sp_album_browsers.append(sp_album.browse()) for sp_album_browser in sp_album_browsers: sp_album_browser.load() for sp_track in sp_album_browser.tracks: track = translator.to_track( sp_track, bitrate=config['bitrate']) if track is not None: yield track def _lookup_playlist(config, sp_link): sp_playlist = sp_link.as_playlist() sp_playlist.load() for sp_track in sp_playlist.tracks: track = translator.to_track( sp_track, bitrate=config['bitrate']) if track is not None: yield track PKUSG+~mopidy_spotify/playlists.pyfrom __future__ import unicode_literals import logging from mopidy import backend import spotify from mopidy_spotify import translator, utils logger = logging.getLogger(__name__) class SpotifyPlaylistsProvider(backend.PlaylistsProvider): def __init__(self, backend): self._backend = backend def as_list(self): with utils.time_logger('playlists.as_list()'): return ( list(self._get_starred_playlist_ref()) + list(self._get_flattened_playlist_refs())) def _get_starred_playlist_ref(self): if self._backend._session is None: return sp_starred = self._backend._session.get_starred() if sp_starred is None: return if ( self._backend._session.connection.state is spotify.ConnectionState.LOGGED_IN): sp_starred.load() starred_ref = translator.to_playlist_ref( sp_starred, username=self._backend._session.user_name) if starred_ref is not None: yield starred_ref def _get_flattened_playlist_refs(self): if self._backend._session is None: return if self._backend._session.playlist_container is None: return username = self._backend._session.user_name folders = [] for sp_playlist in self._backend._session.playlist_container: if isinstance(sp_playlist, spotify.PlaylistFolder): if sp_playlist.type is spotify.PlaylistType.START_FOLDER: folders.append(sp_playlist.name) elif sp_playlist.type is spotify.PlaylistType.END_FOLDER: folders.pop() continue playlist_ref = translator.to_playlist_ref( sp_playlist, folders=folders, username=username) if playlist_ref is not None: yield playlist_ref def get_items(self, uri): with utils.time_logger('playlist.get_items(%s)' % uri): return self._get_playlist(uri, as_items=True) def lookup(self, uri): with utils.time_logger('playlists.lookup(%s)' % uri): return self._get_playlist(uri) def _get_playlist(self, uri, as_items=False): try: sp_playlist = self._backend._session.get_playlist(uri) except spotify.Error as exc: logger.debug('Failed to lookup Spotify URI %s: %s', uri, exc) return if not sp_playlist.is_loaded: logger.debug( 'Waiting for Spotify playlist to load: %s', sp_playlist) sp_playlist.load() username = self._backend._session.user_name return translator.to_playlist( sp_playlist, username=username, bitrate=self._backend._bitrate, as_items=as_items) def refresh(self): pass # Not needed as long as we don't cache anything. def create(self, name): try: sp_playlist = ( self._backend._session.playlist_container .add_new_playlist(name)) except ValueError as exc: logger.warning( 'Failed creating new Spotify playlist "%s": %s', name, exc) except spotify.Error: logger.warning('Failed creating new Spotify playlist "%s"', name) else: username = self._backend._session.user_name return translator.to_playlist(sp_playlist, username=username) def delete(self, uri): pass # TODO def save(self, playlist): pass # TODO def on_container_loaded(sp_playlist_container): # Called from the pyspotify event loop, and not in an actor context. logger.debug('Spotify playlist container loaded') # This event listener is also called after playlists are added, removed and # moved, so since Mopidy currently only supports the "playlists_loaded" # event this is the only place we need to trigger a Mopidy backend event. backend.BackendListener.send('playlists_loaded') def on_playlist_added(sp_playlist_container, sp_playlist, index): # Called from the pyspotify event loop, and not in an actor context. logger.debug( 'Spotify playlist "%s" added to index %d', sp_playlist.name, index) # XXX Should Mopidy support more fine grained playlist events which this # event can trigger? def on_playlist_removed(sp_playlist_container, sp_playlist, index): # Called from the pyspotify event loop, and not in an actor context. logger.debug( 'Spotify playlist "%s" removed from index %d', sp_playlist.name, index) # XXX Should Mopidy support more fine grained playlist events which this # event can trigger? def on_playlist_moved( sp_playlist_container, sp_playlist, old_index, new_index): # Called from the pyspotify event loop, and not in an actor context. logger.debug( 'Spotify playlist "%s" moved from index %d to %d', sp_playlist.name, old_index, new_index) # XXX Should Mopidy support more fine grained playlist events which this # event can trigger? PK[9FMAA!mopidy_spotify/spotify_appkey.keyωޟ!P3sRNG S"^=+ d/dK~8(%nG~L .$qsmŽ󲁞y|30>t|d ޢڷ+߲4λy/!u?d9W (qGHTM+ã+"†gpvP 24dKo bHW])õWtNY묙p,`_>Vt(],E o`%г0[a:ȾէE{{&]nLBrIi}fωLAd_<6M=PKSG(t*t*.Mopidy_Spotify-2.0.1.dist-info/DESCRIPTION.rst************** Mopidy-Spotify ************** .. image:: https://img.shields.io/pypi/v/Mopidy-Spotify.svg?style=flat :target: https://pypi.python.org/pypi/Mopidy-Spotify/ :alt: Latest PyPI version .. image:: https://img.shields.io/pypi/dm/Mopidy-Spotify.svg?style=flat :target: https://pypi.python.org/pypi/Mopidy-Spotify/ :alt: Number of PyPI downloads .. image:: https://img.shields.io/travis/mopidy/mopidy-spotify/develop.svg?style=flat :target: https://travis-ci.org/mopidy/mopidy-spotify :alt: Travis CI build status .. image:: https://img.shields.io/coveralls/mopidy/mopidy-spotify/develop.svg?style=flat :target: https://coveralls.io/r/mopidy/mopidy-spotify?branch=develop :alt: Test coverage `Mopidy `_ extension for playing music from `Spotify `_. Dependencies ============ - A Spotify Premium subscription. Mopidy-Spotify **will not** work with Spotify Free, just Spotify Premium. - A non-Facebook Spotify username and password. If you created your account through Facebook you'll need to create a "device password" to be able to use Mopidy-Spotify. Go to http://www.spotify.com/account/set-device-password/, login with your Facebook account, and follow the instructions. - ``libspotify`` >= 12, < 13. The official C library from the `Spotify developer site `_. The package is available as ``libspotify12`` from `apt.mopidy.com `__. - ``pyspotify`` >= 2.0. The ``libspotify`` python wrapper. The package is available as ``python-spotify`` from apt.mopidy.com or ``pyspotify`` on PyPI. - ``Mopidy`` >= 1.1. The music server that Mopidy-Spotify extends. If you install Mopidy-Spotify from apt.mopidy.com, AUR, or Homebrew, these dependencies are installed automatically. Installation ============ Debian/Ubuntu/Raspbian: Install the ``mopidy-spotify`` package from `apt.mopidy.com `_:: sudo apt-get install mopidy-spotify Arch Linux: Install the ``mopidy-spotify`` package from `AUR `_:: yaourt -S mopidy-spotify OS X: Install the ``mopidy-spotify`` package from the `mopidy/mopidy `_ Homebrew tap:: brew install mopidy-spotify Else: Install the dependencies listed above yourself, and then install the package from PyPI:: pip install Mopidy-Spotify Configuration ============= Before starting Mopidy, you must add your Spotify Premium username and password to your Mopidy configuration file:: [spotify] username = alice password = secret The following configuration values are available: - ``spotify/enabled``: If the Spotify extension should be enabled or not. Defaults to ``true``. - ``spotify/username``: Your Spotify Premium username. You *must* provide this. - ``spotify/password``: Your Spotify Premium password. You *must* provide this. - ``spotify/bitrate``: Audio bitrate in kbps. ``96``, ``160``, or ``320``. Defaults to ``160``. - ``spotify/volume_normalization``: Whether volume normalization is active or not. Defaults to ``true``. - ``spotify/timeout``: Seconds before giving up waiting for search results, etc. Defaults to ``10``. - ``spotify/allow_cache``: Whether to allow caching. The cache is stored in a "spotify" directory within Mopidy's ``core/cache_dir``. Defaults to ``true``. - ``spotify/allow_network``: Whether to allow network access or not. Defaults to ``true``. - ``spotify/allow_playlists``: Whether or not playlists should be exposed. Defaults to ``true``. - ``spotify/search_album_count``: Maximum number of albums returned in search results. Number between 0 and 200. Defaults to 20. - ``spotify/search_artist_count``: Maximum number of artists returned in search results. Number between 0 and 200. Defaults to 10. - ``spotify/search_track_count``: Maximum number of tracks returned in search results. Number between 0 and 200. Defaults to 50. - ``spotify/toplist_countries``: Comma separated list of two letter ISO country codes to get toplists for. Defaults to blank, which is interpreted as all countries that Spotify is available in. - ``spotify/private_session``: Whether to use a private Spotify session. Turn on private session to disable sharing of played tracks with friends through the Spotify activity feed, Last.fm scrobbling, and Facebook. This only affects social sharing done by Spotify, not by other Mopidy extensions. Defaults to ``false``. Project resources ================= - `Source code `_ - `Issue tracker `_ - `Download development snapshot `_ Changelog ========= v2.0.1 (2015-08-23) ------------------- Bug fix release. - Filter out ``None`` from ``library.get_distinct()`` return values. (Fixes: #63) v2.0.0 (2015-08-11) ------------------- Rewrite using pyspotify 2. Should have feature parity with Mopidy-Spotify 1. **Config** - Add ``spotify/volume_normalization`` config. (Fixes: #13) - Add ``spotify/allow_network`` config which can be used to force Mopidy-Spotify to stay offline. This is mostly useful for testing during development. - Add ``spotify/allow_playlists`` config which can be used to disable all access to playlists on the Spotify account. Useful where Mopidy is shared by multiple users. (Fixes: #25) - Make maximum number of returned results configurable through ``spotify/search_album_count``, ``spotify/search_artist_count``, and ``spotify/search_track_count``. - Add ``spotify/private_session`` config. - Change ``spotify/toplist_countries`` default value to blank, which is now interpreted as all supported countries instead of no countries. - Removed ``spotify/cache_dir`` and ``spotify/settings_dir`` config values. We now use a "spotify" directory in the ``core/cache_dir`` and ``core/data_dir`` directories defined in Mopidy's configuration. - Add ``spotify/allow_cache`` config value to make it possible to disable caching. **Browse** - Add browsing of top albums and top artists, in additon to top tracks. - Add browsing by current user's country, in addition to personal, global and per-country browsing. - Add browsing of artists, which includes the artist's top tracks and albums. - Update list of countries Spotify is available in and provides toplists for. **Lookup** - Adding an artist by URI will now first find all albums by the artist and then all tracks in the albums. This way, the returned tracks are grouped by album and they are sorted by track number. (Fixes: #7) - When adding an artist by URI, all albums that are marked as "compilations" or where the album artist is "Various Artists" are now ignored. (Fixes: #5) **Library** - The library provider method ``get_distinct()`` is now supported. When called without a query, the tracks in the user's playlists is used as the data source. When called with a query, a Spotify search is used as the data source. This addition makes the library view in some notable MPD clients, like ncmpcpp, become quite fast and usable with Spotify. (Fixes: #50) **Playback** - If another Spotify client starts playback with the same account, we get a "play token lost" event. Previously, Mopidy-Spotify would unconditionally pause Mopidy playback if this happened. Now, we only pause playback if we're currently playing music from Spotify. (Fixes: #1) v1.4.0 (2015-05-19) ------------------- - Update to not use deprecated Mopidy audio APIs. - Use strings and not ints for the model's date field. This is required for compatibility with the model validation added in Mopidy 1.1. (Fixes: #52) - Fix error causing the image of every 50th URI in a ``library.get_images()`` call to not be looked up and returned. - Fix handling of empty search queries. This was still using the removed ``playlists.playlists`` to fetch all your tracks. - Update the ``SpotifyTrack`` proxy model to work with Mopidy 1.1 model changes. - Updated to work with the renaming of ``mopidy.utils`` to ``mopidy.internal`` in Mopidy 1.1. v1.3.0 (2015-03-25) ------------------- - Require Mopidy >= 1.0. - Update to work with new playback API in Mopidy 1.0. - Update to work with new playlists API in Mopidy 1.0. - Update to work with new search API in Mopidy 1.0. - Add ``library.get_images()`` support for cover art. v1.2.0 (2014-07-21) ------------------- - Add support for browsing playlists and albums. Needed to allow music discovery extensions expose these in a clean way. - Fix loss of audio when resuming from paused, when caused by another Spotify client starting playback. (Fixes: #2, PR: #19) v1.1.3 (2014-02-18) ------------------- - Switch to new backend API locations, required by the upcoming Mopidy 0.19 release. v1.1.2 (2014-02-18) ------------------- - Wait for track to be loaded before playing it. This fixes playback of tracks looked up directly by URI, and not through a playlist or search. (Fixes: mopidy/mopidy#675) v1.1.1 (2014-02-16) ------------------- - Change requirement on pyspotify from ``>= 1.9, < 2`` to ``>= 1.9, < 1.999``, so that it is parsed correctly and pyspotify 1.x is installed instead of 2.x. v1.1.0 (2014-01-20) ------------------- - Require Mopidy >= 0.18. - Change ``library.lookup()`` to return tracks even if they are unplayable. There's no harm in letting them be added to the tracklist, as Mopidy will simply skip to the next track when failing to play the track. (Fixes: mopidy/mopidy#606) - Added basic library browsing support that exposes user, global and country toplists. v1.0.3 (2013-12-15) ------------------- - Change search field ``track`` to ``track_name`` for compatibility with Mopidy 0.17. (Fixes: mopidy/mopidy#610) v1.0.2 (2013-11-19) ------------------- - Add ``spotify/settings_dir`` config value so that libspotify settings can be stored to another location than the libspotify cache. This also allows ``spotify/cache_dir`` to be unset, since settings are now using it's own config value. - Make the ``spotify/cache_dir`` config value optional, so that it can be set to an empty string to disable caching. v1.0.1 (2013-10-28) ------------------- - Support searches from Mopidy that are using the ``albumartist`` field type, added in Mopidy 0.16. - Ignore the ``track_no`` field in search queries, added in Mopidy 0.16. - Abort Spotify searches immediately if the search query is empty instead of waiting for the 10s timeout before returning an empty search result. v1.0.0 (2013-10-08) ------------------- - Moved extension out of the main Mopidy project. PKSG11/Mopidy_Spotify-2.0.1.dist-info/entry_points.txt[mopidy.ext] spotify = mopidy_spotify:Extension PKSGؘT,Mopidy_Spotify-2.0.1.dist-info/metadata.json{"classifiers": ["Environment :: No Input/Output (Daemon)", "Intended Audience :: End Users/Desktop", "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", "Programming Language :: Python :: 2", "Topic :: Multimedia :: Sound/Audio :: Players"], "extensions": {"python.details": {"contacts": [{"email": "stein.magnus@jodal.no", "name": "Stein Magnus Jodal", "role": "author"}], "document_names": {"description": "DESCRIPTION.rst"}, "project_urls": {"Home": "https://github.com/mopidy/mopidy-spotify"}}, "python.exports": {"mopidy.ext": {"spotify": "mopidy_spotify:Extension"}}}, "extras": [], "generator": "bdist_wheel (0.24.0)", "license": "Apache License, Version 2.0", "metadata_version": "2.0", "name": "Mopidy-Spotify", "run_requires": [{"requires": ["setuptools", "Mopidy (>=1.1)", "Pykka (>=1.1)", "pyspotify (>=2.0.2)"]}], "summary": "Mopidy extension for playing music from Spotify", "version": "2.0.1"}PKSG]..'Mopidy_Spotify-2.0.1.dist-info/pbr.json{"is_release": true, "git_version": "ba773a0"}PKSGy,Mopidy_Spotify-2.0.1.dist-info/top_level.txtmopidy_spotify PKSG3onn$Mopidy_Spotify-2.0.1.dist-info/WHEELWheel-Version: 1.0 Generator: bdist_wheel (0.24.0) Root-Is-Purelib: true Tag: py2-none-any Tag: py3-none-any PKSGbI-I-'Mopidy_Spotify-2.0.1.dist-info/METADATAMetadata-Version: 2.0 Name: Mopidy-Spotify Version: 2.0.1 Summary: Mopidy extension for playing music from Spotify Home-page: https://github.com/mopidy/mopidy-spotify Author: Stein Magnus Jodal Author-email: stein.magnus@jodal.no License: Apache License, Version 2.0 Platform: UNKNOWN Classifier: Environment :: No Input/Output (Daemon) Classifier: Intended Audience :: End Users/Desktop Classifier: License :: OSI Approved :: Apache Software License Classifier: Operating System :: OS Independent Classifier: Programming Language :: Python :: 2 Classifier: Topic :: Multimedia :: Sound/Audio :: Players Requires-Dist: setuptools Requires-Dist: Mopidy (>=1.1) Requires-Dist: Pykka (>=1.1) Requires-Dist: pyspotify (>=2.0.2) ************** Mopidy-Spotify ************** .. image:: https://img.shields.io/pypi/v/Mopidy-Spotify.svg?style=flat :target: https://pypi.python.org/pypi/Mopidy-Spotify/ :alt: Latest PyPI version .. image:: https://img.shields.io/pypi/dm/Mopidy-Spotify.svg?style=flat :target: https://pypi.python.org/pypi/Mopidy-Spotify/ :alt: Number of PyPI downloads .. image:: https://img.shields.io/travis/mopidy/mopidy-spotify/develop.svg?style=flat :target: https://travis-ci.org/mopidy/mopidy-spotify :alt: Travis CI build status .. image:: https://img.shields.io/coveralls/mopidy/mopidy-spotify/develop.svg?style=flat :target: https://coveralls.io/r/mopidy/mopidy-spotify?branch=develop :alt: Test coverage `Mopidy `_ extension for playing music from `Spotify `_. Dependencies ============ - A Spotify Premium subscription. Mopidy-Spotify **will not** work with Spotify Free, just Spotify Premium. - A non-Facebook Spotify username and password. If you created your account through Facebook you'll need to create a "device password" to be able to use Mopidy-Spotify. Go to http://www.spotify.com/account/set-device-password/, login with your Facebook account, and follow the instructions. - ``libspotify`` >= 12, < 13. The official C library from the `Spotify developer site `_. The package is available as ``libspotify12`` from `apt.mopidy.com `__. - ``pyspotify`` >= 2.0. The ``libspotify`` python wrapper. The package is available as ``python-spotify`` from apt.mopidy.com or ``pyspotify`` on PyPI. - ``Mopidy`` >= 1.1. The music server that Mopidy-Spotify extends. If you install Mopidy-Spotify from apt.mopidy.com, AUR, or Homebrew, these dependencies are installed automatically. Installation ============ Debian/Ubuntu/Raspbian: Install the ``mopidy-spotify`` package from `apt.mopidy.com `_:: sudo apt-get install mopidy-spotify Arch Linux: Install the ``mopidy-spotify`` package from `AUR `_:: yaourt -S mopidy-spotify OS X: Install the ``mopidy-spotify`` package from the `mopidy/mopidy `_ Homebrew tap:: brew install mopidy-spotify Else: Install the dependencies listed above yourself, and then install the package from PyPI:: pip install Mopidy-Spotify Configuration ============= Before starting Mopidy, you must add your Spotify Premium username and password to your Mopidy configuration file:: [spotify] username = alice password = secret The following configuration values are available: - ``spotify/enabled``: If the Spotify extension should be enabled or not. Defaults to ``true``. - ``spotify/username``: Your Spotify Premium username. You *must* provide this. - ``spotify/password``: Your Spotify Premium password. You *must* provide this. - ``spotify/bitrate``: Audio bitrate in kbps. ``96``, ``160``, or ``320``. Defaults to ``160``. - ``spotify/volume_normalization``: Whether volume normalization is active or not. Defaults to ``true``. - ``spotify/timeout``: Seconds before giving up waiting for search results, etc. Defaults to ``10``. - ``spotify/allow_cache``: Whether to allow caching. The cache is stored in a "spotify" directory within Mopidy's ``core/cache_dir``. Defaults to ``true``. - ``spotify/allow_network``: Whether to allow network access or not. Defaults to ``true``. - ``spotify/allow_playlists``: Whether or not playlists should be exposed. Defaults to ``true``. - ``spotify/search_album_count``: Maximum number of albums returned in search results. Number between 0 and 200. Defaults to 20. - ``spotify/search_artist_count``: Maximum number of artists returned in search results. Number between 0 and 200. Defaults to 10. - ``spotify/search_track_count``: Maximum number of tracks returned in search results. Number between 0 and 200. Defaults to 50. - ``spotify/toplist_countries``: Comma separated list of two letter ISO country codes to get toplists for. Defaults to blank, which is interpreted as all countries that Spotify is available in. - ``spotify/private_session``: Whether to use a private Spotify session. Turn on private session to disable sharing of played tracks with friends through the Spotify activity feed, Last.fm scrobbling, and Facebook. This only affects social sharing done by Spotify, not by other Mopidy extensions. Defaults to ``false``. Project resources ================= - `Source code `_ - `Issue tracker `_ - `Download development snapshot `_ Changelog ========= v2.0.1 (2015-08-23) ------------------- Bug fix release. - Filter out ``None`` from ``library.get_distinct()`` return values. (Fixes: #63) v2.0.0 (2015-08-11) ------------------- Rewrite using pyspotify 2. Should have feature parity with Mopidy-Spotify 1. **Config** - Add ``spotify/volume_normalization`` config. (Fixes: #13) - Add ``spotify/allow_network`` config which can be used to force Mopidy-Spotify to stay offline. This is mostly useful for testing during development. - Add ``spotify/allow_playlists`` config which can be used to disable all access to playlists on the Spotify account. Useful where Mopidy is shared by multiple users. (Fixes: #25) - Make maximum number of returned results configurable through ``spotify/search_album_count``, ``spotify/search_artist_count``, and ``spotify/search_track_count``. - Add ``spotify/private_session`` config. - Change ``spotify/toplist_countries`` default value to blank, which is now interpreted as all supported countries instead of no countries. - Removed ``spotify/cache_dir`` and ``spotify/settings_dir`` config values. We now use a "spotify" directory in the ``core/cache_dir`` and ``core/data_dir`` directories defined in Mopidy's configuration. - Add ``spotify/allow_cache`` config value to make it possible to disable caching. **Browse** - Add browsing of top albums and top artists, in additon to top tracks. - Add browsing by current user's country, in addition to personal, global and per-country browsing. - Add browsing of artists, which includes the artist's top tracks and albums. - Update list of countries Spotify is available in and provides toplists for. **Lookup** - Adding an artist by URI will now first find all albums by the artist and then all tracks in the albums. This way, the returned tracks are grouped by album and they are sorted by track number. (Fixes: #7) - When adding an artist by URI, all albums that are marked as "compilations" or where the album artist is "Various Artists" are now ignored. (Fixes: #5) **Library** - The library provider method ``get_distinct()`` is now supported. When called without a query, the tracks in the user's playlists is used as the data source. When called with a query, a Spotify search is used as the data source. This addition makes the library view in some notable MPD clients, like ncmpcpp, become quite fast and usable with Spotify. (Fixes: #50) **Playback** - If another Spotify client starts playback with the same account, we get a "play token lost" event. Previously, Mopidy-Spotify would unconditionally pause Mopidy playback if this happened. Now, we only pause playback if we're currently playing music from Spotify. (Fixes: #1) v1.4.0 (2015-05-19) ------------------- - Update to not use deprecated Mopidy audio APIs. - Use strings and not ints for the model's date field. This is required for compatibility with the model validation added in Mopidy 1.1. (Fixes: #52) - Fix error causing the image of every 50th URI in a ``library.get_images()`` call to not be looked up and returned. - Fix handling of empty search queries. This was still using the removed ``playlists.playlists`` to fetch all your tracks. - Update the ``SpotifyTrack`` proxy model to work with Mopidy 1.1 model changes. - Updated to work with the renaming of ``mopidy.utils`` to ``mopidy.internal`` in Mopidy 1.1. v1.3.0 (2015-03-25) ------------------- - Require Mopidy >= 1.0. - Update to work with new playback API in Mopidy 1.0. - Update to work with new playlists API in Mopidy 1.0. - Update to work with new search API in Mopidy 1.0. - Add ``library.get_images()`` support for cover art. v1.2.0 (2014-07-21) ------------------- - Add support for browsing playlists and albums. Needed to allow music discovery extensions expose these in a clean way. - Fix loss of audio when resuming from paused, when caused by another Spotify client starting playback. (Fixes: #2, PR: #19) v1.1.3 (2014-02-18) ------------------- - Switch to new backend API locations, required by the upcoming Mopidy 0.19 release. v1.1.2 (2014-02-18) ------------------- - Wait for track to be loaded before playing it. This fixes playback of tracks looked up directly by URI, and not through a playlist or search. (Fixes: mopidy/mopidy#675) v1.1.1 (2014-02-16) ------------------- - Change requirement on pyspotify from ``>= 1.9, < 2`` to ``>= 1.9, < 1.999``, so that it is parsed correctly and pyspotify 1.x is installed instead of 2.x. v1.1.0 (2014-01-20) ------------------- - Require Mopidy >= 0.18. - Change ``library.lookup()`` to return tracks even if they are unplayable. There's no harm in letting them be added to the tracklist, as Mopidy will simply skip to the next track when failing to play the track. (Fixes: mopidy/mopidy#606) - Added basic library browsing support that exposes user, global and country toplists. v1.0.3 (2013-12-15) ------------------- - Change search field ``track`` to ``track_name`` for compatibility with Mopidy 0.17. (Fixes: mopidy/mopidy#610) v1.0.2 (2013-11-19) ------------------- - Add ``spotify/settings_dir`` config value so that libspotify settings can be stored to another location than the libspotify cache. This also allows ``spotify/cache_dir`` to be unset, since settings are now using it's own config value. - Make the ``spotify/cache_dir`` config value optional, so that it can be set to an empty string to disable caching. v1.0.1 (2013-10-28) ------------------- - Support searches from Mopidy that are using the ``albumartist`` field type, added in Mopidy 0.16. - Ignore the ``track_no`` field in search queries, added in Mopidy 0.16. - Abort Spotify searches immediately if the search query is empty instead of waiting for the 10s timeout before returning an empty search result. v1.0.0 (2013-10-08) ------------------- - Moved extension out of the main Mopidy project. PKSG%Mopidy_Spotify-2.0.1.dist-info/RECORDMopidy_Spotify-2.0.1.dist-info/METADATA,sha256=LH68JOF2eHnmoXVxb-v_KDYbE0n5hwM02Xd7FGlaVjI,11593 Mopidy_Spotify-2.0.1.dist-info/DESCRIPTION.rst,sha256=ddhn7oviymcBp3gAdSag4_o-WgSaWDw_LerK4gAi51g,10868 Mopidy_Spotify-2.0.1.dist-info/RECORD,, Mopidy_Spotify-2.0.1.dist-info/WHEEL,sha256=AvR0WeTpDaxT645bl5FQxUK6NPsTls2ttpcGJg3j1Xg,110 Mopidy_Spotify-2.0.1.dist-info/pbr.json,sha256=19lvGrydUgJNzVouq62xyhjgu6_2T8e6Rhm-Dxs9gPA,46 Mopidy_Spotify-2.0.1.dist-info/entry_points.txt,sha256=-s_A2d5bP90_tCxjW1TWXoGs0kxBjI7auv1y52nPaT8,49 Mopidy_Spotify-2.0.1.dist-info/metadata.json,sha256=mUuwJRJFgs8YrperqkOIAK-5rK7_4L2glYw4VosCGI0,949 Mopidy_Spotify-2.0.1.dist-info/top_level.txt,sha256=_5VdB0GVAfrfbDKiJgCgUldYBUu0yxwuSM1pC5Ie9Ks,15 mopidy_spotify/library.py,sha256=uB8c4yXwB8YCxw3sopCdU41xGAUlWKxXpdFPlydnOLQ,1001 mopidy_spotify/search.py,sha256=EiWoB5UlOyL4PJievHfqjGQUVkWERXW5nuhibD437Z0,1920 mopidy_spotify/distinct.py,sha256=96jcIniyy4cMnlp2NJIKUFTprUAuYvSHch5kwaqpRuU,4000 mopidy_spotify/browse.py,sha256=MzQifhZ4BAolPVSkgaueMJYoP9BQleh69dWjMotu_Nk,4231 mopidy_spotify/countries.py,sha256=tBG5btB_HmzmR6tyNreaqSmQebQhh8_m-I5U1KzGV4g,1513 mopidy_spotify/playback.py,sha256=Gr4GCUMUNmAjlFFGvedINWkO414wo4wGtBT_Y5nWifE,6130 mopidy_spotify/utils.py,sha256=I3OgOKe2wMbUIbrMF7SF0sesQYZVzHS_DtPmg8rySgg,514 mopidy_spotify/ext.conf,sha256=E8eUhWlKKz3-l_QqSIhtPU8RGPm60k74J64tZ5NkPP4,282 mopidy_spotify/images.py,sha256=JQ_4AJYY6PzhwbOkxQPsuQhU7Loh2S571Ws1sdQ9ea4,3169 mopidy_spotify/translator.py,sha256=GZoqHEzZvRScvetL46-OkrkG2DRf71ezVcjLGaqt9SU,5852 mopidy_spotify/__init__.py,sha256=Vghhos46ie88S0ZIrPSgG4WFW0oHXzpb00DT8Z4q54M,1538 mopidy_spotify/backend.py,sha256=cOMV9Wn8_wei3_6sCRQDsj7x84FuG_xEszuMYbsq8LI,5154 mopidy_spotify/lookup.py,sha256=o4wIH9REx-veVGe6Ftux3Y9vFdHRP3JpMGJVTky0rjA,3100 mopidy_spotify/playlists.py,sha256=s_uCPalHysZUOSmQP09ubYr_l35Sy-ZHjsCmoIpXljw,5138 mopidy_spotify/spotify_appkey.key,sha256=TZ-2ceWYTxKFZTSbRtwWccOK6dCJoGsfu_hCa24fvpY,321 PKUSGrmopidy_spotify/library.pyPKUSG\n mopidy_spotify/search.pyPKZSG' mopidy_spotify/distinct.pyPKUSG"jʇmopidy_spotify/browse.pyPKUSGB<k,mopidy_spotify/countries.pyPKUSGܚ2mopidy_spotify/playback.pyPKUSGSݭJmopidy_spotify/utils.pyPKUSG' =Lmopidy_spotify/ext.confPKUSG{a a =Nmopidy_spotify/images.pyPKUSGF/Zmopidy_spotify/translator.pyPKZSGi)qmopidy_spotify/__init__.pyPKUSGSt""$xmopidy_spotify/backend.pyPKUSG;UP  }mopidy_spotify/lookup.pyPKUSG+~Ϙmopidy_spotify/playlists.pyPK[9FMAA!mopidy_spotify/spotify_appkey.keyPKSG(t*t*.Mopidy_Spotify-2.0.1.dist-info/DESCRIPTION.rstPKSG11/ZMopidy_Spotify-2.0.1.dist-info/entry_points.txtPKSGؘT,Mopidy_Spotify-2.0.1.dist-info/metadata.jsonPKSG]..'Mopidy_Spotify-2.0.1.dist-info/pbr.jsonPKSGy,JMopidy_Spotify-2.0.1.dist-info/top_level.txtPKSG3onn$Mopidy_Spotify-2.0.1.dist-info/WHEELPKSGbI-I-'SMopidy_Spotify-2.0.1.dist-info/METADATAPKSG% Mopidy_Spotify-2.0.1.dist-info/RECORDPK