spotify: Move to external extension
This commit is contained in:
parent
f9a6fa525a
commit
e7d6a995e8
@ -2,7 +2,6 @@ include *.rst
|
|||||||
include LICENSE
|
include LICENSE
|
||||||
include MANIFEST.in
|
include MANIFEST.in
|
||||||
include data/mopidy.desktop
|
include data/mopidy.desktop
|
||||||
include mopidy/backends/spotify/spotify_appkey.key
|
|
||||||
include pylintrc
|
include pylintrc
|
||||||
|
|
||||||
recursive-include docs *
|
recursive-include docs *
|
||||||
|
|||||||
@ -9,15 +9,18 @@ v0.16.0 (UNRELEASED)
|
|||||||
|
|
||||||
**Dependencies**
|
**Dependencies**
|
||||||
|
|
||||||
- The Last.fm scrobbler has been moved to its own external extension,
|
Parts of Mopidy have been moved to their own external extensions. If you want
|
||||||
`Mopidy-Scrobbler <https://github.com/mopidy/mopidy-scrobbler>`_. You'll need
|
Mopidy to continue to work like it used to, you may have to install one or more
|
||||||
to install it in addition to Mopidy if you want it to continue to work as it
|
of the following extensions as well:
|
||||||
used to.
|
|
||||||
|
|
||||||
- The MPRIS frontend has been moved to its own external extension,
|
- The Spotify backend has been moved to
|
||||||
`Mopidy-MPRIS <https://github.com/mopidy/mopidy-mpris>`_. You'll need to
|
`Mopidy-Scrobbler <https://github.com/mopidy/mopidy-spotify>`_.
|
||||||
install it in addition to Mopidy if you want it to continue to work as it
|
|
||||||
used to.
|
- The Last.fm scrobbler has been moved to
|
||||||
|
`Mopidy-Scrobbler <https://github.com/mopidy/mopidy-scrobbler>`_.
|
||||||
|
|
||||||
|
- The MPRIS frontend has been moved to
|
||||||
|
`Mopidy-MPRIS <https://github.com/mopidy/mopidy-mpris>`_.
|
||||||
|
|
||||||
**Audio**
|
**Audio**
|
||||||
|
|
||||||
|
|||||||
@ -87,10 +87,19 @@ Mopidy-SoundCloud
|
|||||||
|
|
||||||
https://github.com/mopidy/mopidy-soundcloud
|
https://github.com/mopidy/mopidy-soundcloud
|
||||||
|
|
||||||
Provides a backend for playing music from the `SoundCloud
|
rovides a backend for playing music from the `SoundCloud
|
||||||
<http://www.soundcloud.com/>`_ service.
|
<http://www.soundcloud.com/>`_ service.
|
||||||
|
|
||||||
|
|
||||||
|
Mopidy-Spotify
|
||||||
|
--------------
|
||||||
|
|
||||||
|
https://github.com/mopidy/mopidy-spotify
|
||||||
|
|
||||||
|
Extension for playing music from the `Spotify <http://www.spotify.com/>`_ music
|
||||||
|
streaming service.
|
||||||
|
|
||||||
|
|
||||||
Mopidy-Subsonic
|
Mopidy-Subsonic
|
||||||
---------------
|
---------------
|
||||||
|
|
||||||
|
|||||||
@ -1,83 +0,0 @@
|
|||||||
.. _ext-spotify:
|
|
||||||
|
|
||||||
**************
|
|
||||||
Mopidy-Spotify
|
|
||||||
**************
|
|
||||||
|
|
||||||
An extension for playing music from Spotify.
|
|
||||||
|
|
||||||
`Spotify <http://www.spotify.com/>`_ is a music streaming service. The backend
|
|
||||||
uses the official `libspotify
|
|
||||||
<http://developer.spotify.com/en/libspotify/overview/>`_ library and the
|
|
||||||
`pyspotify <http://github.com/mopidy/pyspotify/>`_ Python bindings for
|
|
||||||
libspotify. This backend handles URIs starting with ``spotify:``.
|
|
||||||
|
|
||||||
.. note::
|
|
||||||
|
|
||||||
This product uses SPOTIFY(R) CORE but is not endorsed, certified or
|
|
||||||
otherwise approved in any way by Spotify. Spotify is the registered
|
|
||||||
trade mark of the Spotify Group.
|
|
||||||
|
|
||||||
|
|
||||||
Known issues
|
|
||||||
============
|
|
||||||
|
|
||||||
https://github.com/mopidy/mopidy/issues?labels=Spotify+backend
|
|
||||||
|
|
||||||
|
|
||||||
Dependencies
|
|
||||||
============
|
|
||||||
|
|
||||||
.. literalinclude:: ../../requirements/spotify.txt
|
|
||||||
|
|
||||||
|
|
||||||
Default configuration
|
|
||||||
=====================
|
|
||||||
|
|
||||||
.. literalinclude:: ../../mopidy/backends/spotify/ext.conf
|
|
||||||
:language: ini
|
|
||||||
|
|
||||||
|
|
||||||
Configuration values
|
|
||||||
====================
|
|
||||||
|
|
||||||
.. confval:: spotify/enabled
|
|
||||||
|
|
||||||
If the Spotify extension should be enabled or not.
|
|
||||||
|
|
||||||
.. confval:: spotify/username
|
|
||||||
|
|
||||||
Your Spotify Premium username.
|
|
||||||
|
|
||||||
.. confval:: spotify/password
|
|
||||||
|
|
||||||
Your Spotify Premium password.
|
|
||||||
|
|
||||||
.. confval:: spotify/bitrate
|
|
||||||
|
|
||||||
The preferred audio bitrate. Valid values are 96, 160, 320.
|
|
||||||
|
|
||||||
.. confval:: spotify/timeout
|
|
||||||
|
|
||||||
Max number of seconds to wait for Spotify operations to complete.
|
|
||||||
|
|
||||||
.. confval:: spotify/cache_dir
|
|
||||||
|
|
||||||
Path to the Spotify data cache. Cannot be shared with other Spotify apps.
|
|
||||||
|
|
||||||
|
|
||||||
Usage
|
|
||||||
=====
|
|
||||||
|
|
||||||
If you are using the Spotify backend, which is the default, enter your Spotify
|
|
||||||
Premium account's username and password into ``~/.config/mopidy/mopidy.conf``,
|
|
||||||
like this:
|
|
||||||
|
|
||||||
.. code-block:: ini
|
|
||||||
|
|
||||||
[spotify]
|
|
||||||
username = myusername
|
|
||||||
password = mysecret
|
|
||||||
|
|
||||||
This will only work if you have the Spotify Premium subscription. Spotify
|
|
||||||
Unlimited will not work.
|
|
||||||
@ -4,9 +4,9 @@ Mopidy
|
|||||||
|
|
||||||
Mopidy is a music server which can play music both from multiple sources, like
|
Mopidy is a music server which can play music both from multiple sources, like
|
||||||
your :ref:`local hard drive <ext-local>`, :ref:`radio streams <ext-stream>`,
|
your :ref:`local hard drive <ext-local>`, :ref:`radio streams <ext-stream>`,
|
||||||
and from :ref:`Spotify <ext-spotify>` and SoundCloud. Searches combines results
|
and from Spotify and SoundCloud. Searches combines results from all music
|
||||||
from all music sources, and you can mix tracks from all sources in your play
|
sources, and you can mix tracks from all sources in your play queue. Your
|
||||||
queue. Your playlists from Spotify or SoundCloud are also available for use.
|
playlists from Spotify or SoundCloud are also available for use.
|
||||||
|
|
||||||
To control your Mopidy music server, you can use one of Mopidy's :ref:`web
|
To control your Mopidy music server, you can use one of Mopidy's :ref:`web
|
||||||
clients <http-clients>`, the :ref:`Ubuntu Sound Menu <ubuntu-sound-menu>`, any
|
clients <http-clients>`, the :ref:`Ubuntu Sound Menu <ubuntu-sound-menu>`, any
|
||||||
|
|||||||
@ -1,36 +0,0 @@
|
|||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
import os
|
|
||||||
|
|
||||||
import mopidy
|
|
||||||
from mopidy import config, exceptions, ext
|
|
||||||
|
|
||||||
|
|
||||||
class Extension(ext.Extension):
|
|
||||||
|
|
||||||
dist_name = 'Mopidy-Spotify'
|
|
||||||
ext_name = 'spotify'
|
|
||||||
version = mopidy.__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['timeout'] = config.Integer(minimum=0)
|
|
||||||
schema['cache_dir'] = config.Path()
|
|
||||||
return schema
|
|
||||||
|
|
||||||
def validate_environment(self):
|
|
||||||
try:
|
|
||||||
import spotify # noqa
|
|
||||||
except ImportError as e:
|
|
||||||
raise exceptions.ExtensionError('pyspotify library not found', e)
|
|
||||||
|
|
||||||
def get_backend_classes(self):
|
|
||||||
from .actor import SpotifyBackend
|
|
||||||
return [SpotifyBackend]
|
|
||||||
@ -1,37 +0,0 @@
|
|||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
import logging
|
|
||||||
|
|
||||||
import pykka
|
|
||||||
|
|
||||||
from mopidy.backends import base
|
|
||||||
from mopidy.backends.spotify.library import SpotifyLibraryProvider
|
|
||||||
from mopidy.backends.spotify.playback import SpotifyPlaybackProvider
|
|
||||||
from mopidy.backends.spotify.session_manager import SpotifySessionManager
|
|
||||||
from mopidy.backends.spotify.playlists import SpotifyPlaylistsProvider
|
|
||||||
|
|
||||||
logger = logging.getLogger('mopidy.backends.spotify')
|
|
||||||
|
|
||||||
|
|
||||||
class SpotifyBackend(pykka.ThreadingActor, base.Backend):
|
|
||||||
def __init__(self, config, audio):
|
|
||||||
super(SpotifyBackend, self).__init__()
|
|
||||||
|
|
||||||
self.config = config
|
|
||||||
|
|
||||||
self.library = SpotifyLibraryProvider(backend=self)
|
|
||||||
self.playback = SpotifyPlaybackProvider(audio=audio, backend=self)
|
|
||||||
self.playlists = SpotifyPlaylistsProvider(backend=self)
|
|
||||||
|
|
||||||
self.uri_schemes = ['spotify']
|
|
||||||
|
|
||||||
self.spotify = SpotifySessionManager(
|
|
||||||
config, audio=audio, backend_ref=self.actor_ref)
|
|
||||||
|
|
||||||
def on_start(self):
|
|
||||||
logger.info('Mopidy uses SPOTIFY(R) CORE')
|
|
||||||
logger.debug('Connecting to Spotify')
|
|
||||||
self.spotify.start()
|
|
||||||
|
|
||||||
def on_stop(self):
|
|
||||||
self.spotify.logout()
|
|
||||||
@ -1,51 +0,0 @@
|
|||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from spotify.manager import SpotifyContainerManager as \
|
|
||||||
PyspotifyContainerManager
|
|
||||||
|
|
||||||
logger = logging.getLogger('mopidy.backends.spotify')
|
|
||||||
|
|
||||||
|
|
||||||
class SpotifyContainerManager(PyspotifyContainerManager):
|
|
||||||
def __init__(self, session_manager):
|
|
||||||
PyspotifyContainerManager.__init__(self)
|
|
||||||
self.session_manager = session_manager
|
|
||||||
|
|
||||||
def container_loaded(self, container, userdata):
|
|
||||||
"""Callback used by pyspotify"""
|
|
||||||
logger.debug('Callback called: playlist container loaded')
|
|
||||||
|
|
||||||
self.session_manager.refresh_playlists()
|
|
||||||
|
|
||||||
count = 0
|
|
||||||
for playlist in self.session_manager.session.playlist_container():
|
|
||||||
if playlist.type() == 'playlist':
|
|
||||||
self.session_manager.playlist_manager.watch(playlist)
|
|
||||||
count += 1
|
|
||||||
logger.debug('Watching %d playlist(s) for changes', count)
|
|
||||||
|
|
||||||
def playlist_added(self, container, playlist, position, userdata):
|
|
||||||
"""Callback used by pyspotify"""
|
|
||||||
logger.debug(
|
|
||||||
'Callback called: playlist added at position %d', position)
|
|
||||||
# container_loaded() is called after this callback, so we do not need
|
|
||||||
# to handle this callback.
|
|
||||||
|
|
||||||
def playlist_moved(self, container, playlist, old_position, new_position,
|
|
||||||
userdata):
|
|
||||||
"""Callback used by pyspotify"""
|
|
||||||
logger.debug(
|
|
||||||
'Callback called: playlist "%s" moved from position %d to %d',
|
|
||||||
playlist.name(), old_position, new_position)
|
|
||||||
# container_loaded() is called after this callback, so we do not need
|
|
||||||
# to handle this callback.
|
|
||||||
|
|
||||||
def playlist_removed(self, container, playlist, position, userdata):
|
|
||||||
"""Callback used by pyspotify"""
|
|
||||||
logger.debug(
|
|
||||||
'Callback called: playlist "%s" removed from position %d',
|
|
||||||
playlist.name(), position)
|
|
||||||
# container_loaded() is called after this callback, so we do not need
|
|
||||||
# to handle this callback.
|
|
||||||
@ -1,7 +0,0 @@
|
|||||||
[spotify]
|
|
||||||
enabled = true
|
|
||||||
username =
|
|
||||||
password =
|
|
||||||
bitrate = 160
|
|
||||||
timeout = 10
|
|
||||||
cache_dir = $XDG_CACHE_DIR/mopidy/spotify
|
|
||||||
@ -1,211 +0,0 @@
|
|||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import time
|
|
||||||
import urllib
|
|
||||||
|
|
||||||
import pykka
|
|
||||||
from spotify import Link, SpotifyError
|
|
||||||
|
|
||||||
from mopidy.backends import base
|
|
||||||
from mopidy.models import Track, SearchResult
|
|
||||||
|
|
||||||
from . import translator
|
|
||||||
|
|
||||||
logger = logging.getLogger('mopidy.backends.spotify')
|
|
||||||
|
|
||||||
TRACK_AVAILABLE = 1
|
|
||||||
|
|
||||||
|
|
||||||
class SpotifyTrack(Track):
|
|
||||||
"""Proxy object for unloaded Spotify tracks."""
|
|
||||||
def __init__(self, uri=None, track=None):
|
|
||||||
super(SpotifyTrack, self).__init__()
|
|
||||||
if (uri and track) or (not uri and not track):
|
|
||||||
raise AttributeError('uri or track must be provided')
|
|
||||||
elif uri:
|
|
||||||
self._spotify_track = Link.from_string(uri).as_track()
|
|
||||||
elif track:
|
|
||||||
self._spotify_track = track
|
|
||||||
self._unloaded_track = Track(uri=uri, name='[loading...]')
|
|
||||||
self._track = None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def _proxy(self):
|
|
||||||
if self._track:
|
|
||||||
return self._track
|
|
||||||
elif self._spotify_track.is_loaded():
|
|
||||||
self._track = translator.to_mopidy_track(self._spotify_track)
|
|
||||||
return self._track
|
|
||||||
else:
|
|
||||||
return self._unloaded_track
|
|
||||||
|
|
||||||
def __getattribute__(self, name):
|
|
||||||
if name.startswith('_'):
|
|
||||||
return super(SpotifyTrack, self).__getattribute__(name)
|
|
||||||
return self._proxy.__getattribute__(name)
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return self._proxy.__repr__()
|
|
||||||
|
|
||||||
def __hash__(self):
|
|
||||||
return hash(self._proxy.uri)
|
|
||||||
|
|
||||||
def __eq__(self, other):
|
|
||||||
if not isinstance(other, Track):
|
|
||||||
return False
|
|
||||||
return self._proxy.uri == other.uri
|
|
||||||
|
|
||||||
def copy(self, **values):
|
|
||||||
return self._proxy.copy(**values)
|
|
||||||
|
|
||||||
|
|
||||||
class SpotifyLibraryProvider(base.BaseLibraryProvider):
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
super(SpotifyLibraryProvider, self).__init__(*args, **kwargs)
|
|
||||||
self._timeout = self.backend.config['spotify']['timeout']
|
|
||||||
|
|
||||||
def find_exact(self, query=None, uris=None):
|
|
||||||
return self.search(query=query, uris=uris)
|
|
||||||
|
|
||||||
def lookup(self, uri):
|
|
||||||
try:
|
|
||||||
link = Link.from_string(uri)
|
|
||||||
if link.type() == Link.LINK_TRACK:
|
|
||||||
return self._lookup_track(uri)
|
|
||||||
if link.type() == Link.LINK_ALBUM:
|
|
||||||
return self._lookup_album(uri)
|
|
||||||
elif link.type() == Link.LINK_ARTIST:
|
|
||||||
return self._lookup_artist(uri)
|
|
||||||
elif link.type() == Link.LINK_PLAYLIST:
|
|
||||||
return self._lookup_playlist(uri)
|
|
||||||
else:
|
|
||||||
return []
|
|
||||||
except SpotifyError as error:
|
|
||||||
logger.debug(u'Failed to lookup "%s": %s', uri, error)
|
|
||||||
return []
|
|
||||||
|
|
||||||
def _lookup_track(self, uri):
|
|
||||||
track = Link.from_string(uri).as_track()
|
|
||||||
self._wait_for_object_to_load(track)
|
|
||||||
if track.is_loaded():
|
|
||||||
if track.availability() == TRACK_AVAILABLE:
|
|
||||||
return [SpotifyTrack(track=track)]
|
|
||||||
else:
|
|
||||||
return []
|
|
||||||
else:
|
|
||||||
return [SpotifyTrack(uri=uri)]
|
|
||||||
|
|
||||||
def _lookup_album(self, uri):
|
|
||||||
album = Link.from_string(uri).as_album()
|
|
||||||
album_browser = self.backend.spotify.session.browse_album(album)
|
|
||||||
self._wait_for_object_to_load(album_browser)
|
|
||||||
return [
|
|
||||||
SpotifyTrack(track=t)
|
|
||||||
for t in album_browser if t.availability() == TRACK_AVAILABLE]
|
|
||||||
|
|
||||||
def _lookup_artist(self, uri):
|
|
||||||
artist = Link.from_string(uri).as_artist()
|
|
||||||
artist_browser = self.backend.spotify.session.browse_artist(artist)
|
|
||||||
self._wait_for_object_to_load(artist_browser)
|
|
||||||
return [
|
|
||||||
SpotifyTrack(track=t)
|
|
||||||
for t in artist_browser if t.availability() == TRACK_AVAILABLE]
|
|
||||||
|
|
||||||
def _lookup_playlist(self, uri):
|
|
||||||
playlist = Link.from_string(uri).as_playlist()
|
|
||||||
self._wait_for_object_to_load(playlist)
|
|
||||||
return [
|
|
||||||
SpotifyTrack(track=t)
|
|
||||||
for t in playlist if t.availability() == TRACK_AVAILABLE]
|
|
||||||
|
|
||||||
def _wait_for_object_to_load(self, spotify_obj, timeout=None):
|
|
||||||
# XXX Sleeping to wait for the Spotify object to load is an ugly hack,
|
|
||||||
# but it works. We should look into other solutions for this.
|
|
||||||
if timeout is None:
|
|
||||||
timeout = self._timeout
|
|
||||||
wait_until = time.time() + timeout
|
|
||||||
while not spotify_obj.is_loaded():
|
|
||||||
time.sleep(0.1)
|
|
||||||
if time.time() > wait_until:
|
|
||||||
logger.debug(
|
|
||||||
'Timeout: Spotify object did not load in %ds', timeout)
|
|
||||||
return
|
|
||||||
|
|
||||||
def refresh(self, uri=None):
|
|
||||||
pass # TODO
|
|
||||||
|
|
||||||
def search(self, query=None, uris=None):
|
|
||||||
# TODO Only return results within URI roots given by ``uris``
|
|
||||||
|
|
||||||
if not query:
|
|
||||||
return self._get_all_tracks()
|
|
||||||
|
|
||||||
uris = query.get('uri', [])
|
|
||||||
if uris:
|
|
||||||
tracks = []
|
|
||||||
for uri in uris:
|
|
||||||
tracks += self.lookup(uri)
|
|
||||||
if len(uris) == 1:
|
|
||||||
uri = uris[0]
|
|
||||||
else:
|
|
||||||
uri = 'spotify:search'
|
|
||||||
return SearchResult(uri=uri, tracks=tracks)
|
|
||||||
|
|
||||||
spotify_query = self._translate_search_query(query)
|
|
||||||
logger.debug('Spotify search query: %s' % spotify_query)
|
|
||||||
|
|
||||||
future = pykka.ThreadingFuture()
|
|
||||||
|
|
||||||
def callback(results, userdata=None):
|
|
||||||
search_result = SearchResult(
|
|
||||||
uri='spotify:search:%s' % (
|
|
||||||
urllib.quote(results.query().encode('utf-8'))),
|
|
||||||
albums=[
|
|
||||||
translator.to_mopidy_album(a) for a in results.albums()],
|
|
||||||
artists=[
|
|
||||||
translator.to_mopidy_artist(a) for a in results.artists()],
|
|
||||||
tracks=[
|
|
||||||
translator.to_mopidy_track(t) for t in results.tracks()])
|
|
||||||
future.set(search_result)
|
|
||||||
|
|
||||||
if not self.backend.spotify.connected.is_set():
|
|
||||||
logger.debug('Not connected: Spotify search cancelled')
|
|
||||||
return SearchResult(uri='spotify:search')
|
|
||||||
|
|
||||||
self.backend.spotify.session.search(
|
|
||||||
spotify_query, callback,
|
|
||||||
album_count=200, artist_count=200, track_count=200)
|
|
||||||
|
|
||||||
try:
|
|
||||||
return future.get(timeout=self._timeout)
|
|
||||||
except pykka.Timeout:
|
|
||||||
logger.debug(
|
|
||||||
'Timeout: Spotify search did not return in %ds', self._timeout)
|
|
||||||
return SearchResult(uri='spotify:search')
|
|
||||||
|
|
||||||
def _get_all_tracks(self):
|
|
||||||
# Since we can't search for the entire Spotify library, we return
|
|
||||||
# all tracks in the playlists when the query is empty.
|
|
||||||
tracks = []
|
|
||||||
for playlist in self.backend.playlists.playlists:
|
|
||||||
tracks += playlist.tracks
|
|
||||||
return SearchResult(uri='spotify:search', tracks=tracks)
|
|
||||||
|
|
||||||
def _translate_search_query(self, mopidy_query):
|
|
||||||
spotify_query = []
|
|
||||||
for (field, values) in mopidy_query.iteritems():
|
|
||||||
if field == 'date':
|
|
||||||
field = 'year'
|
|
||||||
if not hasattr(values, '__iter__'):
|
|
||||||
values = [values]
|
|
||||||
for value in values:
|
|
||||||
if field == 'any':
|
|
||||||
spotify_query.append(value)
|
|
||||||
elif field == 'year':
|
|
||||||
value = int(value.split('-')[0]) # Extract year
|
|
||||||
spotify_query.append('%s:%d' % (field, value))
|
|
||||||
else:
|
|
||||||
spotify_query.append('%s:"%s"' % (field, value))
|
|
||||||
spotify_query = ' '.join(spotify_query)
|
|
||||||
return spotify_query
|
|
||||||
@ -1,94 +0,0 @@
|
|||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import functools
|
|
||||||
|
|
||||||
from spotify import Link, SpotifyError
|
|
||||||
|
|
||||||
from mopidy import audio
|
|
||||||
from mopidy.backends import base
|
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger('mopidy.backends.spotify')
|
|
||||||
|
|
||||||
|
|
||||||
def need_data_callback(spotify_backend, length_hint):
|
|
||||||
spotify_backend.playback.on_need_data(length_hint)
|
|
||||||
|
|
||||||
|
|
||||||
def enough_data_callback(spotify_backend):
|
|
||||||
spotify_backend.playback.on_enough_data()
|
|
||||||
|
|
||||||
|
|
||||||
def seek_data_callback(spotify_backend, time_position):
|
|
||||||
spotify_backend.playback.on_seek_data(time_position)
|
|
||||||
|
|
||||||
|
|
||||||
class SpotifyPlaybackProvider(base.BasePlaybackProvider):
|
|
||||||
# These GStreamer caps matches the audio data provided by libspotify
|
|
||||||
_caps = (
|
|
||||||
'audio/x-raw-int, endianness=(int)1234, channels=(int)2, '
|
|
||||||
'width=(int)16, depth=(int)16, signed=(boolean)true, '
|
|
||||||
'rate=(int)44100')
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
super(SpotifyPlaybackProvider, self).__init__(*args, **kwargs)
|
|
||||||
self._first_seek = False
|
|
||||||
|
|
||||||
def play(self, track):
|
|
||||||
if track.uri is None:
|
|
||||||
return False
|
|
||||||
|
|
||||||
spotify_backend = self.backend.actor_ref.proxy()
|
|
||||||
need_data_callback_bound = functools.partial(
|
|
||||||
need_data_callback, spotify_backend)
|
|
||||||
enough_data_callback_bound = functools.partial(
|
|
||||||
enough_data_callback, spotify_backend)
|
|
||||||
seek_data_callback_bound = functools.partial(
|
|
||||||
seek_data_callback, spotify_backend)
|
|
||||||
|
|
||||||
self._first_seek = True
|
|
||||||
|
|
||||||
try:
|
|
||||||
self.backend.spotify.session.load(
|
|
||||||
Link.from_string(track.uri).as_track())
|
|
||||||
self.backend.spotify.session.play(1)
|
|
||||||
self.backend.spotify.buffer_timestamp = 0
|
|
||||||
|
|
||||||
self.audio.prepare_change()
|
|
||||||
self.audio.set_appsrc(
|
|
||||||
self._caps,
|
|
||||||
need_data=need_data_callback_bound,
|
|
||||||
enough_data=enough_data_callback_bound,
|
|
||||||
seek_data=seek_data_callback_bound)
|
|
||||||
self.audio.start_playback()
|
|
||||||
self.audio.set_metadata(track)
|
|
||||||
|
|
||||||
return True
|
|
||||||
except SpotifyError as e:
|
|
||||||
logger.info('Playback of %s failed: %s', track.uri, e)
|
|
||||||
return False
|
|
||||||
|
|
||||||
def stop(self):
|
|
||||||
self.backend.spotify.session.play(0)
|
|
||||||
return super(SpotifyPlaybackProvider, self).stop()
|
|
||||||
|
|
||||||
def on_need_data(self, length_hint):
|
|
||||||
logger.debug('playback.on_need_data(%d) called', length_hint)
|
|
||||||
self.backend.spotify.push_audio_data = True
|
|
||||||
|
|
||||||
def on_enough_data(self):
|
|
||||||
logger.debug('playback.on_enough_data() called')
|
|
||||||
self.backend.spotify.push_audio_data = False
|
|
||||||
|
|
||||||
def on_seek_data(self, time_position):
|
|
||||||
logger.debug('playback.on_seek_data(%d) called', time_position)
|
|
||||||
|
|
||||||
if time_position == 0 and self._first_seek:
|
|
||||||
self._first_seek = False
|
|
||||||
logger.debug('Skipping seek due to issue #300')
|
|
||||||
return
|
|
||||||
|
|
||||||
self.backend.spotify.buffer_timestamp = audio.millisecond_to_clocktime(
|
|
||||||
time_position)
|
|
||||||
self.backend.spotify.session.seek(time_position)
|
|
||||||
@ -1,105 +0,0 @@
|
|||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
import datetime
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from spotify.manager import SpotifyPlaylistManager as PyspotifyPlaylistManager
|
|
||||||
|
|
||||||
logger = logging.getLogger('mopidy.backends.spotify')
|
|
||||||
|
|
||||||
|
|
||||||
class SpotifyPlaylistManager(PyspotifyPlaylistManager):
|
|
||||||
def __init__(self, session_manager):
|
|
||||||
PyspotifyPlaylistManager.__init__(self)
|
|
||||||
self.session_manager = session_manager
|
|
||||||
|
|
||||||
def tracks_added(self, playlist, tracks, position, userdata):
|
|
||||||
"""Callback used by pyspotify"""
|
|
||||||
logger.debug(
|
|
||||||
'Callback called: '
|
|
||||||
'%d track(s) added to position %d in playlist "%s"',
|
|
||||||
len(tracks), position, playlist.name())
|
|
||||||
self.session_manager.refresh_playlists()
|
|
||||||
|
|
||||||
def tracks_moved(self, playlist, tracks, new_position, userdata):
|
|
||||||
"""Callback used by pyspotify"""
|
|
||||||
logger.debug(
|
|
||||||
'Callback called: '
|
|
||||||
'%d track(s) moved to position %d in playlist "%s"',
|
|
||||||
len(tracks), new_position, playlist.name())
|
|
||||||
self.session_manager.refresh_playlists()
|
|
||||||
|
|
||||||
def tracks_removed(self, playlist, tracks, userdata):
|
|
||||||
"""Callback used by pyspotify"""
|
|
||||||
logger.debug(
|
|
||||||
'Callback called: '
|
|
||||||
'%d track(s) removed from playlist "%s"',
|
|
||||||
len(tracks), playlist.name())
|
|
||||||
self.session_manager.refresh_playlists()
|
|
||||||
|
|
||||||
def playlist_renamed(self, playlist, userdata):
|
|
||||||
"""Callback used by pyspotify"""
|
|
||||||
logger.debug(
|
|
||||||
'Callback called: Playlist renamed to "%s"', playlist.name())
|
|
||||||
self.session_manager.refresh_playlists()
|
|
||||||
|
|
||||||
def playlist_state_changed(self, playlist, userdata):
|
|
||||||
"""Callback used by pyspotify"""
|
|
||||||
logger.debug(
|
|
||||||
'Callback called: The state of playlist "%s" changed',
|
|
||||||
playlist.name())
|
|
||||||
|
|
||||||
def playlist_update_in_progress(self, playlist, done, userdata):
|
|
||||||
"""Callback used by pyspotify"""
|
|
||||||
if done:
|
|
||||||
logger.debug(
|
|
||||||
'Callback called: Update of playlist "%s" done',
|
|
||||||
playlist.name())
|
|
||||||
else:
|
|
||||||
logger.debug(
|
|
||||||
'Callback called: Update of playlist "%s" in progress',
|
|
||||||
playlist.name())
|
|
||||||
|
|
||||||
def playlist_metadata_updated(self, playlist, userdata):
|
|
||||||
"""Callback used by pyspotify"""
|
|
||||||
logger.debug(
|
|
||||||
'Callback called: Metadata updated for playlist "%s"',
|
|
||||||
playlist.name())
|
|
||||||
|
|
||||||
def track_created_changed(self, playlist, position, user, when, userdata):
|
|
||||||
"""Callback used by pyspotify"""
|
|
||||||
when = datetime.datetime.fromtimestamp(when)
|
|
||||||
logger.debug(
|
|
||||||
'Callback called: Created by/when for track %d in playlist '
|
|
||||||
'"%s" changed to user "N/A" and time "%s"',
|
|
||||||
position, playlist.name(), when)
|
|
||||||
|
|
||||||
def track_message_changed(self, playlist, position, message, userdata):
|
|
||||||
"""Callback used by pyspotify"""
|
|
||||||
logger.debug(
|
|
||||||
'Callback called: Message for track %d in playlist '
|
|
||||||
'"%s" changed to "%s"', position, playlist.name(), message)
|
|
||||||
|
|
||||||
def track_seen_changed(self, playlist, position, seen, userdata):
|
|
||||||
"""Callback used by pyspotify"""
|
|
||||||
logger.debug(
|
|
||||||
'Callback called: Seen attribute for track %d in playlist '
|
|
||||||
'"%s" changed to "%s"', position, playlist.name(), seen)
|
|
||||||
|
|
||||||
def description_changed(self, playlist, description, userdata):
|
|
||||||
"""Callback used by pyspotify"""
|
|
||||||
logger.debug(
|
|
||||||
'Callback called: Description changed for playlist "%s" to "%s"',
|
|
||||||
playlist.name(), description)
|
|
||||||
|
|
||||||
def subscribers_changed(self, playlist, userdata):
|
|
||||||
"""Callback used by pyspotify"""
|
|
||||||
logger.debug(
|
|
||||||
'Callback called: Subscribers changed for playlist "%s"',
|
|
||||||
playlist.name())
|
|
||||||
|
|
||||||
def image_changed(self, playlist, image, userdata):
|
|
||||||
"""Callback used by pyspotify"""
|
|
||||||
logger.debug(
|
|
||||||
'Callback called: Image changed for playlist "%s"',
|
|
||||||
playlist.name())
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
from mopidy.backends import base
|
|
||||||
|
|
||||||
|
|
||||||
class SpotifyPlaylistsProvider(base.BasePlaylistsProvider):
|
|
||||||
def create(self, name):
|
|
||||||
pass # TODO
|
|
||||||
|
|
||||||
def delete(self, uri):
|
|
||||||
pass # TODO
|
|
||||||
|
|
||||||
def lookup(self, uri):
|
|
||||||
for playlist in self._playlists:
|
|
||||||
if playlist.uri == uri:
|
|
||||||
return playlist
|
|
||||||
|
|
||||||
def refresh(self):
|
|
||||||
pass # TODO
|
|
||||||
|
|
||||||
def save(self, playlist):
|
|
||||||
pass # TODO
|
|
||||||
@ -1,201 +0,0 @@
|
|||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import threading
|
|
||||||
|
|
||||||
from spotify.manager import SpotifySessionManager as PyspotifySessionManager
|
|
||||||
|
|
||||||
from mopidy import audio
|
|
||||||
from mopidy.backends.listener import BackendListener
|
|
||||||
from mopidy.utils import process, versioning
|
|
||||||
|
|
||||||
from . import translator
|
|
||||||
from .container_manager import SpotifyContainerManager
|
|
||||||
from .playlist_manager import SpotifyPlaylistManager
|
|
||||||
|
|
||||||
logger = logging.getLogger('mopidy.backends.spotify')
|
|
||||||
|
|
||||||
BITRATES = {96: 2, 160: 0, 320: 1}
|
|
||||||
|
|
||||||
|
|
||||||
class SpotifySessionManager(process.BaseThread, PyspotifySessionManager):
|
|
||||||
cache_location = None
|
|
||||||
settings_location = None
|
|
||||||
appkey_file = os.path.join(os.path.dirname(__file__), 'spotify_appkey.key')
|
|
||||||
user_agent = 'Mopidy %s' % versioning.get_version()
|
|
||||||
|
|
||||||
def __init__(self, config, audio, backend_ref):
|
|
||||||
|
|
||||||
self.cache_location = config['spotify']['cache_dir']
|
|
||||||
self.settings_location = config['spotify']['cache_dir']
|
|
||||||
|
|
||||||
full_proxy = ''
|
|
||||||
if config['proxy']['hostname']:
|
|
||||||
full_proxy = config['proxy']['hostname']
|
|
||||||
if config['proxy']['port']:
|
|
||||||
full_proxy += ':' + str(config['proxy']['port'])
|
|
||||||
if config['proxy']['scheme']:
|
|
||||||
full_proxy = config['proxy']['scheme'] + "://" + full_proxy
|
|
||||||
|
|
||||||
PyspotifySessionManager.__init__(
|
|
||||||
self, config['spotify']['username'], config['spotify']['password'],
|
|
||||||
proxy=full_proxy,
|
|
||||||
proxy_username=config['proxy']['username'],
|
|
||||||
proxy_password=config['proxy']['password'])
|
|
||||||
|
|
||||||
process.BaseThread.__init__(self)
|
|
||||||
self.name = 'SpotifyThread'
|
|
||||||
|
|
||||||
self.audio = audio
|
|
||||||
self.backend = None
|
|
||||||
self.backend_ref = backend_ref
|
|
||||||
|
|
||||||
self.bitrate = config['spotify']['bitrate']
|
|
||||||
|
|
||||||
self.connected = threading.Event()
|
|
||||||
self.push_audio_data = True
|
|
||||||
self.buffer_timestamp = 0
|
|
||||||
|
|
||||||
self.container_manager = None
|
|
||||||
self.playlist_manager = None
|
|
||||||
|
|
||||||
self._initial_data_receive_completed = False
|
|
||||||
|
|
||||||
def run_inside_try(self):
|
|
||||||
self.backend = self.backend_ref.proxy()
|
|
||||||
self.connect()
|
|
||||||
|
|
||||||
def logged_in(self, session, error):
|
|
||||||
"""Callback used by pyspotify"""
|
|
||||||
if error:
|
|
||||||
logger.error('Spotify login error: %s', error)
|
|
||||||
return
|
|
||||||
|
|
||||||
logger.info('Connected to Spotify')
|
|
||||||
|
|
||||||
# To work with both pyspotify 1.9 and 1.10
|
|
||||||
if not hasattr(self, 'session'):
|
|
||||||
self.session = session
|
|
||||||
|
|
||||||
logger.debug('Preferred Spotify bitrate is %d kbps', self.bitrate)
|
|
||||||
session.set_preferred_bitrate(BITRATES[self.bitrate])
|
|
||||||
|
|
||||||
self.container_manager = SpotifyContainerManager(self)
|
|
||||||
self.playlist_manager = SpotifyPlaylistManager(self)
|
|
||||||
|
|
||||||
self.container_manager.watch(session.playlist_container())
|
|
||||||
|
|
||||||
self.connected.set()
|
|
||||||
|
|
||||||
def logged_out(self, session):
|
|
||||||
"""Callback used by pyspotify"""
|
|
||||||
logger.info('Disconnected from Spotify')
|
|
||||||
self.connected.clear()
|
|
||||||
|
|
||||||
def metadata_updated(self, session):
|
|
||||||
"""Callback used by pyspotify"""
|
|
||||||
logger.debug('Callback called: Metadata updated')
|
|
||||||
|
|
||||||
def connection_error(self, session, error):
|
|
||||||
"""Callback used by pyspotify"""
|
|
||||||
if error is None:
|
|
||||||
logger.info('Spotify connection OK')
|
|
||||||
else:
|
|
||||||
logger.error('Spotify connection error: %s', error)
|
|
||||||
if self.audio.state.get() == audio.PlaybackState.PLAYING:
|
|
||||||
self.backend.playback.pause()
|
|
||||||
|
|
||||||
def message_to_user(self, session, message):
|
|
||||||
"""Callback used by pyspotify"""
|
|
||||||
logger.debug('User message: %s', message.strip())
|
|
||||||
|
|
||||||
def music_delivery(self, session, frames, frame_size, num_frames,
|
|
||||||
sample_type, sample_rate, channels):
|
|
||||||
"""Callback used by pyspotify"""
|
|
||||||
if not self.push_audio_data:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
assert sample_type == 0, '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
|
|
||||||
""" % {
|
|
||||||
'sample_rate': sample_rate,
|
|
||||||
'channels': channels,
|
|
||||||
}
|
|
||||||
|
|
||||||
duration = audio.calculate_duration(num_frames, sample_rate)
|
|
||||||
buffer_ = audio.create_buffer(bytes(frames),
|
|
||||||
capabilites=capabilites,
|
|
||||||
timestamp=self.buffer_timestamp,
|
|
||||||
duration=duration)
|
|
||||||
|
|
||||||
self.buffer_timestamp += duration
|
|
||||||
|
|
||||||
if self.audio.emit_data(buffer_).get():
|
|
||||||
return num_frames
|
|
||||||
else:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
def play_token_lost(self, session):
|
|
||||||
"""Callback used by pyspotify"""
|
|
||||||
logger.debug('Play token lost')
|
|
||||||
self.backend.playback.pause()
|
|
||||||
|
|
||||||
def log_message(self, session, data):
|
|
||||||
"""Callback used by pyspotify"""
|
|
||||||
logger.debug('System message: %s' % data.strip())
|
|
||||||
if 'offline-mgr' in data and 'files unlocked' in data:
|
|
||||||
# XXX This is a very very fragile and ugly hack, but we get no
|
|
||||||
# proper event when libspotify is done with initial data loading.
|
|
||||||
# We delay the expensive refresh of Mopidy's playlists until this
|
|
||||||
# message arrives. This way, we avoid doing the refresh once for
|
|
||||||
# every playlist or other change. This reduces the time from
|
|
||||||
# startup until the Spotify backend is ready from 35s to 12s in one
|
|
||||||
# test with clean Spotify cache. In cases with an outdated cache
|
|
||||||
# the time improvements should be a lot greater.
|
|
||||||
if not self._initial_data_receive_completed:
|
|
||||||
self._initial_data_receive_completed = True
|
|
||||||
self.refresh_playlists()
|
|
||||||
|
|
||||||
def end_of_track(self, session):
|
|
||||||
"""Callback used by pyspotify"""
|
|
||||||
logger.debug('End of data stream reached')
|
|
||||||
self.audio.emit_end_of_stream()
|
|
||||||
|
|
||||||
def refresh_playlists(self):
|
|
||||||
"""Refresh the playlists in the backend with data from Spotify"""
|
|
||||||
if not self._initial_data_receive_completed:
|
|
||||||
logger.debug('Still getting data; skipped refresh of playlists')
|
|
||||||
return
|
|
||||||
playlists = []
|
|
||||||
folders = []
|
|
||||||
for spotify_playlist in self.session.playlist_container():
|
|
||||||
if spotify_playlist.type() == 'folder_start':
|
|
||||||
folders.append(spotify_playlist)
|
|
||||||
if spotify_playlist.type() == 'folder_end':
|
|
||||||
folders.pop()
|
|
||||||
playlists.append(translator.to_mopidy_playlist(
|
|
||||||
spotify_playlist, folders=folders,
|
|
||||||
bitrate=self.bitrate, username=self.username))
|
|
||||||
playlists.append(translator.to_mopidy_playlist(
|
|
||||||
self.session.starred(),
|
|
||||||
bitrate=self.bitrate, username=self.username))
|
|
||||||
playlists = filter(None, playlists)
|
|
||||||
self.backend.playlists.playlists = playlists
|
|
||||||
logger.info('Loaded %d Spotify playlists', len(playlists))
|
|
||||||
BackendListener.send('playlists_loaded')
|
|
||||||
|
|
||||||
def logout(self):
|
|
||||||
"""Log out from spotify"""
|
|
||||||
logger.debug('Logging out from Spotify')
|
|
||||||
|
|
||||||
# To work with both pyspotify 1.9 and 1.10
|
|
||||||
if getattr(self, 'session', None):
|
|
||||||
self.session.logout()
|
|
||||||
Binary file not shown.
@ -1,97 +0,0 @@
|
|||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
import logging
|
|
||||||
|
|
||||||
import spotify
|
|
||||||
|
|
||||||
from mopidy.models import Artist, Album, Track, Playlist
|
|
||||||
|
|
||||||
logger = logging.getLogger('mopidy.backends.spotify')
|
|
||||||
|
|
||||||
|
|
||||||
artist_cache = {}
|
|
||||||
album_cache = {}
|
|
||||||
track_cache = {}
|
|
||||||
|
|
||||||
|
|
||||||
def to_mopidy_artist(spotify_artist):
|
|
||||||
if spotify_artist is None:
|
|
||||||
return
|
|
||||||
uri = str(spotify.Link.from_artist(spotify_artist))
|
|
||||||
if uri in artist_cache:
|
|
||||||
return artist_cache[uri]
|
|
||||||
if not spotify_artist.is_loaded():
|
|
||||||
return Artist(uri=uri, name='[loading...]')
|
|
||||||
artist_cache[uri] = Artist(uri=uri, name=spotify_artist.name())
|
|
||||||
return artist_cache[uri]
|
|
||||||
|
|
||||||
|
|
||||||
def to_mopidy_album(spotify_album):
|
|
||||||
if spotify_album is None:
|
|
||||||
return
|
|
||||||
uri = str(spotify.Link.from_album(spotify_album))
|
|
||||||
if uri in album_cache:
|
|
||||||
return album_cache[uri]
|
|
||||||
if not spotify_album.is_loaded():
|
|
||||||
return Album(uri=uri, name='[loading...]')
|
|
||||||
album_cache[uri] = Album(
|
|
||||||
uri=uri,
|
|
||||||
name=spotify_album.name(),
|
|
||||||
artists=[to_mopidy_artist(spotify_album.artist())],
|
|
||||||
date=spotify_album.year())
|
|
||||||
return album_cache[uri]
|
|
||||||
|
|
||||||
|
|
||||||
def to_mopidy_track(spotify_track, bitrate=None):
|
|
||||||
if spotify_track is None:
|
|
||||||
return
|
|
||||||
uri = str(spotify.Link.from_track(spotify_track, 0))
|
|
||||||
if uri in track_cache:
|
|
||||||
return track_cache[uri]
|
|
||||||
if not spotify_track.is_loaded():
|
|
||||||
return Track(uri=uri, name='[loading...]')
|
|
||||||
spotify_album = spotify_track.album()
|
|
||||||
if spotify_album is not None and spotify_album.is_loaded():
|
|
||||||
date = spotify_album.year()
|
|
||||||
else:
|
|
||||||
date = None
|
|
||||||
track_cache[uri] = Track(
|
|
||||||
uri=uri,
|
|
||||||
name=spotify_track.name(),
|
|
||||||
artists=[to_mopidy_artist(a) for a in spotify_track.artists()],
|
|
||||||
album=to_mopidy_album(spotify_track.album()),
|
|
||||||
track_no=spotify_track.index(),
|
|
||||||
date=date,
|
|
||||||
length=spotify_track.duration(),
|
|
||||||
bitrate=bitrate)
|
|
||||||
return track_cache[uri]
|
|
||||||
|
|
||||||
|
|
||||||
def to_mopidy_playlist(
|
|
||||||
spotify_playlist, folders=None, bitrate=None, username=None):
|
|
||||||
if spotify_playlist is None or spotify_playlist.type() != 'playlist':
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
uri = str(spotify.Link.from_playlist(spotify_playlist))
|
|
||||||
except spotify.SpotifyError as e:
|
|
||||||
logger.debug('Spotify playlist translation error: %s', e)
|
|
||||||
return
|
|
||||||
if not spotify_playlist.is_loaded():
|
|
||||||
return Playlist(uri=uri, name='[loading...]')
|
|
||||||
name = spotify_playlist.name()
|
|
||||||
if folders:
|
|
||||||
folder_names = '/'.join(folder.name() for folder in folders)
|
|
||||||
name = folder_names + '/' + name
|
|
||||||
tracks = [
|
|
||||||
to_mopidy_track(spotify_track, bitrate=bitrate)
|
|
||||||
for spotify_track in spotify_playlist
|
|
||||||
if not spotify_track.is_local()
|
|
||||||
]
|
|
||||||
if not name:
|
|
||||||
name = 'Starred'
|
|
||||||
# Tracks in the Starred playlist are in reverse order from the official
|
|
||||||
# client.
|
|
||||||
tracks.reverse()
|
|
||||||
if spotify_playlist.owner().canonical_name() != username:
|
|
||||||
name += ' by ' + spotify_playlist.owner().canonical_name()
|
|
||||||
return Playlist(uri=uri, name=name, tracks=tracks)
|
|
||||||
@ -1,8 +0,0 @@
|
|||||||
pyspotify >= 1.9, < 2
|
|
||||||
# The libspotify Python wrapper
|
|
||||||
# Available as the python-spotify package from apt.mopidy.com
|
|
||||||
|
|
||||||
# libspotify >= 12, < 13
|
|
||||||
# The libspotify C library from
|
|
||||||
# https://developer.spotify.com/technologies/libspotify/
|
|
||||||
# Available as the libspotify12 package from apt.mopidy.com
|
|
||||||
3
setup.py
3
setup.py
@ -28,8 +28,6 @@ setup(
|
|||||||
'Pykka >= 1.1',
|
'Pykka >= 1.1',
|
||||||
],
|
],
|
||||||
extras_require={
|
extras_require={
|
||||||
'spotify': ['pyspotify >= 1.9, < 2'],
|
|
||||||
'scrobbler': ['Mopidy-Scrobbler'],
|
|
||||||
'http': ['cherrypy >= 3.2.2', 'ws4py >= 0.2.3'],
|
'http': ['cherrypy >= 3.2.2', 'ws4py >= 0.2.3'],
|
||||||
},
|
},
|
||||||
test_suite='nose.collector',
|
test_suite='nose.collector',
|
||||||
@ -47,7 +45,6 @@ setup(
|
|||||||
'http = mopidy.frontends.http:Extension [http]',
|
'http = mopidy.frontends.http:Extension [http]',
|
||||||
'local = mopidy.backends.local:Extension',
|
'local = mopidy.backends.local:Extension',
|
||||||
'mpd = mopidy.frontends.mpd:Extension',
|
'mpd = mopidy.frontends.mpd:Extension',
|
||||||
'spotify = mopidy.backends.spotify:Extension [spotify]',
|
|
||||||
'stream = mopidy.backends.stream:Extension',
|
'stream = mopidy.backends.stream:Extension',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user