spotify: Move to external extension
This commit is contained in:
parent
f9a6fa525a
commit
e7d6a995e8
@ -2,7 +2,6 @@ include *.rst
|
||||
include LICENSE
|
||||
include MANIFEST.in
|
||||
include data/mopidy.desktop
|
||||
include mopidy/backends/spotify/spotify_appkey.key
|
||||
include pylintrc
|
||||
|
||||
recursive-include docs *
|
||||
|
||||
@ -9,15 +9,18 @@ v0.16.0 (UNRELEASED)
|
||||
|
||||
**Dependencies**
|
||||
|
||||
- The Last.fm scrobbler has been moved to its own external extension,
|
||||
`Mopidy-Scrobbler <https://github.com/mopidy/mopidy-scrobbler>`_. You'll need
|
||||
to install it in addition to Mopidy if you want it to continue to work as it
|
||||
used to.
|
||||
Parts of Mopidy have been moved to their own external extensions. If you want
|
||||
Mopidy to continue to work like it used to, you may have to install one or more
|
||||
of the following extensions as well:
|
||||
|
||||
- The MPRIS frontend has been moved to its own external extension,
|
||||
`Mopidy-MPRIS <https://github.com/mopidy/mopidy-mpris>`_. You'll need to
|
||||
install it in addition to Mopidy if you want it to continue to work as it
|
||||
used to.
|
||||
- The Spotify backend has been moved to
|
||||
`Mopidy-Scrobbler <https://github.com/mopidy/mopidy-spotify>`_.
|
||||
|
||||
- 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**
|
||||
|
||||
|
||||
@ -87,10 +87,19 @@ 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.
|
||||
|
||||
|
||||
Mopidy-Spotify
|
||||
--------------
|
||||
|
||||
https://github.com/mopidy/mopidy-spotify
|
||||
|
||||
Extension for playing music from the `Spotify <http://www.spotify.com/>`_ music
|
||||
streaming service.
|
||||
|
||||
|
||||
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
|
||||
your :ref:`local hard drive <ext-local>`, :ref:`radio streams <ext-stream>`,
|
||||
and from :ref:`Spotify <ext-spotify>` and SoundCloud. Searches combines results
|
||||
from all music sources, and you can mix tracks from all sources in your play
|
||||
queue. Your playlists from Spotify or SoundCloud are also available for use.
|
||||
and from Spotify and SoundCloud. Searches combines results from all music
|
||||
sources, and you can mix tracks from all sources in your play queue. Your
|
||||
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
|
||||
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',
|
||||
],
|
||||
extras_require={
|
||||
'spotify': ['pyspotify >= 1.9, < 2'],
|
||||
'scrobbler': ['Mopidy-Scrobbler'],
|
||||
'http': ['cherrypy >= 3.2.2', 'ws4py >= 0.2.3'],
|
||||
},
|
||||
test_suite='nose.collector',
|
||||
@ -47,7 +45,6 @@ setup(
|
||||
'http = mopidy.frontends.http:Extension [http]',
|
||||
'local = mopidy.backends.local:Extension',
|
||||
'mpd = mopidy.frontends.mpd:Extension',
|
||||
'spotify = mopidy.backends.spotify:Extension [spotify]',
|
||||
'stream = mopidy.backends.stream:Extension',
|
||||
],
|
||||
},
|
||||
|
||||
Loading…
Reference in New Issue
Block a user