diff --git a/MANIFEST.in b/MANIFEST.in index 033c51f2..f3723ecd 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,5 +1,10 @@ -include LICENSE pylintrc *.rst *.ini data/mopidy.desktop +include *.ini +include *.rst +include LICENSE +include MANIFEST.in +include data/mopidy.desktop include mopidy/backends/spotify/spotify_appkey.key +include pylintrc recursive-include docs * prune docs/_build recursive-include requirements * diff --git a/docs/_static/thread_communication.txt b/docs/_static/thread_communication.txt deleted file mode 100644 index 4119004e..00000000 --- a/docs/_static/thread_communication.txt +++ /dev/null @@ -1,37 +0,0 @@ -Script for use with www.websequencediagrams.com -=============================================== - -Main -> Core: create -activate Core -note over Core: create NadMixer -Core -> NadTalker: create -activate NadTalker -note over NadTalker: calibrate device -note over Core: create DespotifyBackend -Core -> despotify: connect to Spotify -activate despotify -note over Core: create MpdFrontend -Main -> Server: create -activate Server -note over Server: open port -Client -> Server: connect -note over Server: open session -Client -> Server: play 1 -Server -> Core: play 1 -Core -> despotify: play first track -Client -> Server: setvol 50 -Server -> Core: setvol 50 -Core -> NadTalker: volume = 50 -Client -> Server: status -Server -> Core: status -Core -> NadTalker: volume? -NadTalker -> Core: volume = 50 -Core -> Server: status response -Server -> Client: status response -despotify -> Core: end of track callback -Core -> despotify: play second track -Client -> Server: stop -Server -> Core: stop -Core -> despotify: stop -Client -> Server: disconnect -note over Server: close session diff --git a/docs/changes.rst b/docs/changes.rst index 375d2da1..a2fd73d5 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -5,12 +5,20 @@ Changes This change log is used to track all major changes to Mopidy. -v0.5.0 (in development) +v0.6.0 (in development) ======================= +**Changes** + +- None yet + + +v0.5.0 (2011-06-15) +=================== + Since last time we've added support for audio streaming to SHOUTcast servers and fixed the longstanding playlist loading issue in the Spotify backend. As -always the release has a bunch of bug fixes. +always the release has a bunch of bug fixes and minor improvements. Please note that 0.5.0 requires some updated dependencies, as listed under *Important changes* below. @@ -18,7 +26,7 @@ Please note that 0.5.0 requires some updated dependencies, as listed under **Important changes** - If you use the Spotify backend, you *must* upgrade to libspotify 0.0.8 and - pyspotify 1.2. If you install from APT, libspotify and pyspotify will + pyspotify 1.3. If you install from APT, libspotify and pyspotify will automatically be upgraded. If you are not installing from APT, follow the instructions at :doc:`/installation/libspotify/`. @@ -45,8 +53,9 @@ Please note that 0.5.0 requires some updated dependencies, as listed under workaround of searching and reconnecting to make the playlists appear are no longer necessary. (Fixes: :issue:`59`) - - Replace not decodable characters returned from Spotify instead of throwing - an exception, as we won't try to figure out the encoding of non-UTF-8-data. + - Track's that are no longer available in Spotify's archives are now + "autolinked" to corresponding tracks in other albums, just like the + official Spotify clients do. (Fixes: :issue:`34`) - MPD frontend: @@ -73,6 +82,9 @@ Please note that 0.5.0 requires some updated dependencies, as listed under - Added :option:`--interactive` for reading missing local settings from ``stdin``. (Fixes: :issue:`96`) + - Improve shutdown procedure at CTRL+C. Add signal handler for ``SIGTERM``, + which initiates the same shutdown procedure as CTRL+C does. + - Tag cache generator: - Made it possible to abort :command:`mopidy-scan` with CTRL+C. diff --git a/mopidy/__init__.py b/mopidy/__init__.py index 79a0aa29..7b25c525 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -5,7 +5,7 @@ if not (2, 6) <= sys.version_info < (3,): from subprocess import PIPE, Popen -VERSION = (0, 5, 0) +VERSION = (0, 6, 0) def get_version(): try: diff --git a/mopidy/backends/spotify/__init__.py b/mopidy/backends/spotify/__init__.py index 87997059..66bcffd4 100644 --- a/mopidy/backends/spotify/__init__.py +++ b/mopidy/backends/spotify/__init__.py @@ -33,7 +33,7 @@ class SpotifyBackend(ThreadingActor, Backend): **Dependencies:** - libspotify == 0.0.8 (libspotify8 package from apt.mopidy.com) - - pyspotify == 1.2 (python-spotify package from apt.mopidy.com) + - pyspotify == 1.3 (python-spotify package from apt.mopidy.com) **Settings:** @@ -72,19 +72,22 @@ class SpotifyBackend(ThreadingActor, Backend): self.gstreamer = None self.spotify = None + # Fail early if settings are not present + self.username = settings.SPOTIFY_USERNAME + self.password = settings.SPOTIFY_PASSWORD + def on_start(self): gstreamer_refs = ActorRegistry.get_by_class(GStreamer) assert len(gstreamer_refs) == 1, 'Expected exactly one running gstreamer.' self.gstreamer = gstreamer_refs[0].proxy() + logger.info(u'Mopidy uses SPOTIFY(R) CORE') self.spotify = self._connect() def _connect(self): from .session_manager import SpotifySessionManager - logger.info(u'Mopidy uses SPOTIFY(R) CORE') logger.debug(u'Connecting to Spotify') - spotify = SpotifySessionManager( - settings.SPOTIFY_USERNAME, settings.SPOTIFY_PASSWORD) + spotify = SpotifySessionManager(self.username, self.password) spotify.start() return spotify diff --git a/mopidy/backends/spotify/container_manager.py b/mopidy/backends/spotify/container_manager.py index 29360d79..520cfb68 100644 --- a/mopidy/backends/spotify/container_manager.py +++ b/mopidy/backends/spotify/container_manager.py @@ -1,16 +1,46 @@ import logging -from spotify.manager import SpotifyContainerManager as PyspotifyContainerManager +from spotify.manager import SpotifyContainerManager as \ + PyspotifyContainerManager logger = logging.getLogger('mopidy.backends.spotify.container_manager') 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(u'Container loaded') + """Callback used by pyspotify""" + logger.debug(u'Callback called: playlist container loaded') self.session_manager.refresh_stored_playlists() + + playlist_container = self.session_manager.session.playlist_container() + for playlist in playlist_container: + self.session_manager.playlist_manager.watch(playlist) + logger.debug(u'Watching %d playlist(s) for changes', + len(playlist_container)) + + def playlist_added(self, container, playlist, position, userdata): + """Callback used by pyspotify""" + logger.debug(u'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( + u'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( + u'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. diff --git a/mopidy/backends/spotify/playlist_manager.py b/mopidy/backends/spotify/playlist_manager.py new file mode 100644 index 00000000..f72ac4ca --- /dev/null +++ b/mopidy/backends/spotify/playlist_manager.py @@ -0,0 +1,93 @@ +import datetime +import logging + +from spotify.manager import SpotifyPlaylistManager as PyspotifyPlaylistManager + +logger = logging.getLogger('mopidy.backends.spotify.playlist_manager') + +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(u'Callback called: ' + u'%d track(s) added to position %d in playlist "%s"', + len(tracks), position, playlist.name()) + self.session_manager.refresh_stored_playlists() + + def tracks_moved(self, playlist, tracks, new_position, userdata): + """Callback used by pyspotify""" + logger.debug(u'Callback called: ' + u'%d track(s) moved to position %d in playlist "%s"', + len(tracks), new_position, playlist.name()) + self.session_manager.refresh_stored_playlists() + + def tracks_removed(self, playlist, tracks, userdata): + """Callback used by pyspotify""" + logger.debug(u'Callback called: ' + u'%d track(s) removed from playlist "%s"', len(tracks), playlist.name()) + self.session_manager.refresh_stored_playlists() + + def playlist_renamed(self, playlist, userdata): + """Callback used by pyspotify""" + logger.debug(u'Callback called: Playlist renamed to "%s"', + playlist.name()) + self.session_manager.refresh_stored_playlists() + + def playlist_state_changed(self, playlist, userdata): + """Callback used by pyspotify""" + logger.debug(u'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(u'Callback called: ' + u'Update of playlist "%s" done', playlist.name()) + else: + logger.debug(u'Callback called: ' + u'Update of playlist "%s" in progress', playlist.name()) + + def playlist_metadata_updated(self, playlist, userdata): + """Callback used by pyspotify""" + logger.debug(u'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( + u'Callback called: Created by/when for track %d in playlist ' + u'"%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( + u'Callback called: Message for track %d in playlist ' + u'"%s" changed to "%s"', position, playlist.name(), message) + + def track_seen_changed(self, playlist, position, seen, userdata): + """Callback used by pyspotify""" + logger.debug( + u'Callback called: Seen attribute for track %d in playlist ' + u'"%s" changed to "%s"', position, playlist.name(), seen) + + def description_changed(self, playlist, description, userdata): + """Callback used by pyspotify""" + logger.debug( + u'Callback called: Description changed for playlist "%s" to "%s"', + playlist.name(), description) + + def subscribers_changed(self, playlist, userdata): + """Callback used by pyspotify""" + logger.debug( + u'Callback called: Subscribers changed for playlist "%s"', + playlist.name()) + + def image_changed(self, playlist, image, userdata): + """Callback used by pyspotify""" + logger.debug(u'Callback called: Image changed for playlist "%s"', + playlist.name()) diff --git a/mopidy/backends/spotify/session_manager.py b/mopidy/backends/spotify/session_manager.py index 4b6abe85..fd71d861 100644 --- a/mopidy/backends/spotify/session_manager.py +++ b/mopidy/backends/spotify/session_manager.py @@ -9,11 +9,12 @@ from pykka.registry import ActorRegistry from mopidy import get_version, settings from mopidy.backends.base import Backend from mopidy.backends.spotify import BITRATES +from mopidy.backends.spotify.container_manager import SpotifyContainerManager +from mopidy.backends.spotify.playlist_manager import SpotifyPlaylistManager from mopidy.backends.spotify.translator import SpotifyTranslator from mopidy.models import Playlist from mopidy.gstreamer import GStreamer from mopidy.utils.process import BaseThread -from mopidy.backends.spotify.container_manager import SpotifyContainerManager logger = logging.getLogger('mopidy.backends.spotify.session_manager') @@ -29,7 +30,7 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager): def __init__(self, username, password): PyspotifySessionManager.__init__(self, username, password) BaseThread.__init__(self) - self.name = 'SpotifySMThread' + self.name = 'SpotifyThread' self.gstreamer = None self.backend = None @@ -38,6 +39,7 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager): self.session = None self.container_manager = None + self.playlist_manager = None def run_inside_try(self): self.setup() @@ -45,7 +47,8 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager): def setup(self): gstreamer_refs = ActorRegistry.get_by_class(GStreamer) - assert len(gstreamer_refs) == 1, 'Expected exactly one running gstreamer.' + assert len(gstreamer_refs) == 1, \ + 'Expected exactly one running gstreamer.' self.gstreamer = gstreamer_refs[0].proxy() backend_refs = ActorRegistry.get_by_class(Backend) @@ -57,14 +60,19 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager): if error: logger.error(u'Spotify login error: %s', error) return + logger.info(u'Connected to Spotify') self.session = session - logger.debug(u'Preferred Spotify bitrate is %s kbps.', settings.SPOTIFY_BITRATE) + logger.debug(u'Preferred Spotify bitrate is %s kbps', + settings.SPOTIFY_BITRATE) self.session.set_preferred_bitrate(BITRATES[settings.SPOTIFY_BITRATE]) self.container_manager = SpotifyContainerManager(self) + self.playlist_manager = SpotifyPlaylistManager(self) + self.container_manager.watch(self.session.playlist_container()) + self.connected.set() def logged_out(self, session): @@ -73,13 +81,12 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager): def metadata_updated(self, session): """Callback used by pyspotify""" - logger.debug(u'Metadata updated') - self.refresh_stored_playlists() + logger.debug(u'Callback called: Metadata updated') def connection_error(self, session, error): """Callback used by pyspotify""" if error is None: - logger.info(u'Spotify connection error resolved') + logger.info(u'Spotify connection OK') else: logger.error(u'Spotify connection error: %s', error) self.backend.playback.pause() diff --git a/mopidy/backends/spotify/translator.py b/mopidy/backends/spotify/translator.py index 91a2a9ae..1bf7e5aa 100644 --- a/mopidy/backends/spotify/translator.py +++ b/mopidy/backends/spotify/translator.py @@ -16,7 +16,7 @@ class SpotifyTranslator(object): return Artist(name=u'[loading...]') return Artist( uri=str(Link.from_artist(spotify_artist)), - name=spotify_artist.name().decode(ENCODING, 'replace'), + name=spotify_artist.name() ) @classmethod @@ -24,7 +24,7 @@ class SpotifyTranslator(object): if spotify_album is None or not spotify_album.is_loaded(): return Album(name=u'[loading...]') # TODO pyspotify got much more data on albums than this - return Album(name=spotify_album.name().decode(ENCODING, 'replace')) + return Album(name=spotify_album.name()) @classmethod def to_mopidy_track(cls, spotify_track): @@ -38,7 +38,7 @@ class SpotifyTranslator(object): date = None return Track( uri=uri, - name=spotify_track.name().decode(ENCODING, 'replace'), + name=spotify_track.name(), artists=[cls.to_mopidy_artist(a) for a in spotify_track.artists()], album=cls.to_mopidy_album(spotify_track.album()), track_no=spotify_track.index(), @@ -57,7 +57,7 @@ class SpotifyTranslator(object): try: return Playlist( uri=str(Link.from_playlist(spotify_playlist)), - name=spotify_playlist.name().decode(ENCODING, 'replace'), + name=spotify_playlist.name(), # FIXME if check on link is a hackish workaround for is_local tracks=[cls.to_mopidy_track(t) for t in spotify_playlist if str(Link.from_track(t, 0))], diff --git a/mopidy/core.py b/mopidy/core.py index b89a5456..65472a29 100644 --- a/mopidy/core.py +++ b/mopidy/core.py @@ -40,11 +40,15 @@ def main(): setup_mixer() setup_backend() setup_frontends() - while ActorRegistry.get_all(): + while True: time.sleep(1) - logger.info(u'No actors left. Exiting...') + except SettingsError as e: + logger.error(e.message) except KeyboardInterrupt: - logger.info(u'User interrupt. Exiting...') + logger.info(u'Interrupted. Exiting...') + except Exception as e: + logger.exception(e) + finally: stop_all_actors() def parse_options(): diff --git a/mopidy/frontends/mpd/session.py b/mopidy/frontends/mpd/session.py index 53f4cab7..ce5d3be7 100644 --- a/mopidy/frontends/mpd/session.py +++ b/mopidy/frontends/mpd/session.py @@ -49,7 +49,7 @@ class MpdSession(asynchat.async_chat): Format a response from the MPD command handlers and send it to the client. """ - if response is not None: + if response: response = LINE_TERMINATOR.join(response) logger.debug(u'Response to [%s]:%s: %s', self.client_address, self.client_port, indent(response)) diff --git a/mopidy/gstreamer.py b/mopidy/gstreamer.py index f52292d2..166c487e 100644 --- a/mopidy/gstreamer.py +++ b/mopidy/gstreamer.py @@ -298,7 +298,7 @@ class GStreamer(ThreadingActor): output.sync_state_with_parent() # Required to add to running pipe gst.element_link_many(self._tee, output) self._outputs.append(output) - logger.info('Added %s', output.get_name()) + logger.debug('GStreamer added %s', output.get_name()) def list_outputs(self): """ diff --git a/mopidy/utils/process.py b/mopidy/utils/process.py index 5b09148d..c1d1c9f5 100644 --- a/mopidy/utils/process.py +++ b/mopidy/utils/process.py @@ -1,5 +1,6 @@ import logging import signal +import thread import threading import gobject @@ -12,19 +13,28 @@ from mopidy import SettingsError logger = logging.getLogger('mopidy.utils.process') +def exit_process(): + logger.debug(u'Interrupting main...') + thread.interrupt_main() + logger.debug(u'Interrupted main') + def exit_handler(signum, frame): """A :mod:`signal` handler which will exit the program on signal.""" signals = dict((k, v) for v, k in signal.__dict__.iteritems() if v.startswith('SIG') and not v.startswith('SIG_')) - logger.info(u'Got %s. Exiting...', signals[signum]) - stop_all_actors() + logger.info(u'Got %s signal', signals[signum]) + exit_process() def stop_all_actors(): num_actors = len(ActorRegistry.get_all()) while num_actors: + logger.debug(u'Seeing %d actor and %d non-actor thread(s): %s', + num_actors, threading.active_count() - num_actors, + ', '.join([t.name for t in threading.enumerate()])) logger.debug(u'Stopping %d actor(s)...', num_actors) ActorRegistry.stop_all() num_actors = len(ActorRegistry.get_all()) + logger.debug(u'All actors stopped.') class BaseThread(threading.Thread): def __init__(self): diff --git a/mopidy/utils/settings.py b/mopidy/utils/settings.py index 500477e2..cab94089 100644 --- a/mopidy/utils/settings.py +++ b/mopidy/utils/settings.py @@ -73,7 +73,7 @@ class SettingsProxy(object): raise SettingsError(u'Settings validation failed.') def _read_missing_settings_from_stdin(self, current, runtime): - for setting, value in current.iteritems(): + for setting, value in sorted(current.iteritems()): if isinstance(value, basestring) and len(value) == 0: runtime[setting] = self._read_from_stdin(setting + u': ') diff --git a/tests/version_test.py b/tests/version_test.py index 7bfb540e..9b53c63f 100644 --- a/tests/version_test.py +++ b/tests/version_test.py @@ -19,8 +19,9 @@ class VersionTest(unittest.TestCase): self.assert_(SV('0.3.0') < SV('0.3.1')) self.assert_(SV('0.3.1') < SV('0.4.0')) self.assert_(SV('0.4.0') < SV('0.4.1')) - self.assert_(SV('0.4.1') < SV(get_plain_version())) - self.assert_(SV(get_plain_version()) < SV('0.5.1')) + self.assert_(SV('0.4.1') < SV('0.5.0')) + self.assert_(SV('0.5.0') < SV(get_plain_version())) + self.assert_(SV(get_plain_version()) < SV('0.6.1')) def test_get_platform_contains_platform(self): self.assert_(platform.platform() in get_platform()) diff --git a/tox.ini b/tox.ini index 8b91c6b7..48676e46 100644 --- a/tox.ini +++ b/tox.ini @@ -4,6 +4,7 @@ envlist = py26,py27,docs [testenv] deps = nose commands = nosetests [] +sitepackages = True [testenv:docs] basepython = python