From f73ba3bd62eb2bb13db36d4c3e0c30196becd29a Mon Sep 17 00:00:00 2001 From: Antoine Pierlot-Garcin Date: Mon, 30 May 2011 22:22:23 -0400 Subject: [PATCH 01/65] backend-spotify: implement a container manager (fixes GH59) --- mopidy/backends/spotify/container_manager.py | 16 ++++++++++++++++ mopidy/backends/spotify/session_manager.py | 5 +++++ 2 files changed, 21 insertions(+) create mode 100644 mopidy/backends/spotify/container_manager.py diff --git a/mopidy/backends/spotify/container_manager.py b/mopidy/backends/spotify/container_manager.py new file mode 100644 index 00000000..29360d79 --- /dev/null +++ b/mopidy/backends/spotify/container_manager.py @@ -0,0 +1,16 @@ +import logging + +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') + self.session_manager.refresh_stored_playlists() diff --git a/mopidy/backends/spotify/session_manager.py b/mopidy/backends/spotify/session_manager.py index f34283c6..388b29c3 100644 --- a/mopidy/backends/spotify/session_manager.py +++ b/mopidy/backends/spotify/session_manager.py @@ -12,6 +12,7 @@ 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') @@ -35,6 +36,8 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager): self.connected = threading.Event() self.session = None + self.container_manager = None + def run_inside_try(self): self.setup() self.connect() @@ -61,6 +64,8 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager): else: logger.debug(u'Preferring normal bitrate from Spotify') self.session.set_preferred_bitrate(0) + self.container_manager = SpotifyContainerManager(self) + self.container_manager.watch(self.session.playlist_container()) self.connected.set() def logged_out(self, session): From acad477c8a2debe60f6bddd40cd37ac2277ea3cc Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 2 Jun 2011 18:41:43 +0200 Subject: [PATCH 02/65] Make it possible to stop scanner more cleanly --- bin/mopidy-scan | 5 ++++- mopidy/scanner.py | 7 +++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/bin/mopidy-scan b/bin/mopidy-scan index 84cfee57..718deb73 100755 --- a/bin/mopidy-scan +++ b/bin/mopidy-scan @@ -20,7 +20,10 @@ if __name__ == '__main__': print >> sys.stderr, 'Scanning %s' % settings.LOCAL_MUSIC_PATH scanner = Scanner(settings.LOCAL_MUSIC_PATH, store, debug) - scanner.start() + try: + scanner.start() + except KeyboardInterrupt: + scanner.stop() print >> sys.stderr, 'Done' diff --git a/mopidy/scanner.py b/mopidy/scanner.py index c603c578..b9c770de 100644 --- a/mopidy/scanner.py +++ b/mopidy/scanner.py @@ -82,8 +82,11 @@ class Scanner(object): data = dict([(k, data[k]) for k in data.keys()]) data['uri'] = unicode(self.uribin.get_property('uri')) data['duration'] = self.get_duration() - self.data_callback(data) - self.next_uri() + try: + self.data_callback(data) + self.next_uri() + except KeyboardInterrupt: + self.stop() def process_error(self, bus, message): if self.error_callback: From b6c196b8abf7d25ed3bd1a0b55afbff715ca48a0 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 4 Jun 2011 00:13:40 +0200 Subject: [PATCH 03/65] Ensure that date is not none before using it --- mopidy/scanner.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/scanner.py b/mopidy/scanner.py index b9c770de..d3c61fc7 100644 --- a/mopidy/scanner.py +++ b/mopidy/scanner.py @@ -24,7 +24,7 @@ def translator(data): _retrieve(gst.TAG_TRACK_COUNT, 'num_tracks', album_kwargs) _retrieve(gst.TAG_ARTIST, 'name', artist_kwargs) - if gst.TAG_DATE in data: + if gst.TAG_DATE in data and data[gst.TAG_DATE]: date = data[gst.TAG_DATE] date = datetime.date(date.year, date.month, date.day) track_kwargs['date'] = date From 48d7cd986577081a1b951f4ead695f65fc08b650 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 4 Jun 2011 00:14:04 +0200 Subject: [PATCH 04/65] Use TAG_DURATION constant instead of 'duration' --- mopidy/scanner.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/scanner.py b/mopidy/scanner.py index d3c61fc7..695cda27 100644 --- a/mopidy/scanner.py +++ b/mopidy/scanner.py @@ -81,7 +81,7 @@ class Scanner(object): data = message.parse_tag() data = dict([(k, data[k]) for k in data.keys()]) data['uri'] = unicode(self.uribin.get_property('uri')) - data['duration'] = self.get_duration() + data[gst.TAG_DURATION] = self.get_duration() try: self.data_callback(data) self.next_uri() From e25fbb35dc47bdfbbf8d610c61e2a0cc61fdd3c1 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 4 Jun 2011 00:14:17 +0200 Subject: [PATCH 05/65] Note why get_state is needed --- mopidy/scanner.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/scanner.py b/mopidy/scanner.py index 695cda27..c3eda9ae 100644 --- a/mopidy/scanner.py +++ b/mopidy/scanner.py @@ -96,7 +96,7 @@ class Scanner(object): self.next_uri() def get_duration(self): - self.pipe.get_state() + self.pipe.get_state() # Block untill state change is done. try: return self.pipe.query_duration( gst.FORMAT_TIME, None)[0] // gst.MSECOND From ea8b4fc2b0fa0f8044bca28925adc8145ef1e5f9 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 4 Jun 2011 00:17:27 +0200 Subject: [PATCH 06/65] Remove if __name__ = '__main__' as it was redundant in mopidy-scanner --- bin/mopidy-scan | 47 +++++++++++++++++++++++------------------------ 1 file changed, 23 insertions(+), 24 deletions(-) diff --git a/bin/mopidy-scan b/bin/mopidy-scan index 718deb73..962c402a 100755 --- a/bin/mopidy-scan +++ b/bin/mopidy-scan @@ -1,34 +1,33 @@ #!/usr/bin/env python -if __name__ == '__main__': - import sys +import sys - from mopidy import settings - from mopidy.scanner import Scanner, translator - from mopidy.frontends.mpd.translator import tracks_to_tag_cache_format +from mopidy import settings +from mopidy.scanner import Scanner, translator +from mopidy.frontends.mpd.translator import tracks_to_tag_cache_format - tracks = [] +tracks = [] - def store(data): - track = translator(data) - tracks.append(track) - print >> sys.stderr, 'Added %s' % track.uri +def store(data): + track = translator(data) + tracks.append(track) + print >> sys.stderr, 'Added %s' % track.uri - def debug(uri, error): - print >> sys.stderr, 'Failed %s: %s' % (uri, error) +def debug(uri, error): + print >> sys.stderr, 'Failed %s: %s' % (uri, error) - print >> sys.stderr, 'Scanning %s' % settings.LOCAL_MUSIC_PATH +print >> sys.stderr, 'Scanning %s' % settings.LOCAL_MUSIC_PATH - scanner = Scanner(settings.LOCAL_MUSIC_PATH, store, debug) - try: - scanner.start() - except KeyboardInterrupt: - scanner.stop() +scanner = Scanner(settings.LOCAL_MUSIC_PATH, store, debug) +try: + scanner.start() +except KeyboardInterrupt: + scanner.stop() - print >> sys.stderr, 'Done' +print >> sys.stderr, 'Done' - for a in tracks_to_tag_cache_format(tracks): - if len(a) == 1: - print (u'%s' % a).encode('utf-8') - else: - print (u'%s: %s' % a).encode('utf-8') +for a in tracks_to_tag_cache_format(tracks): + if len(a) == 1: + print (u'%s' % a).encode('utf-8') + else: + print (u'%s: %s' % a).encode('utf-8') From 710f434455ffab01d665d97d6fa0a0e7e5592c16 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 4 Jun 2011 00:32:55 +0200 Subject: [PATCH 07/65] Switch to proper logging for mopidy scanner --- bin/mopidy-scan | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/bin/mopidy-scan b/bin/mopidy-scan index 962c402a..b87e8eb9 100755 --- a/bin/mopidy-scan +++ b/bin/mopidy-scan @@ -1,22 +1,26 @@ #!/usr/bin/env python import sys +import logging from mopidy import settings +from mopidy.utils.log import setup_console_logging from mopidy.scanner import Scanner, translator from mopidy.frontends.mpd.translator import tracks_to_tag_cache_format +setup_console_logging(2) + tracks = [] def store(data): track = translator(data) tracks.append(track) - print >> sys.stderr, 'Added %s' % track.uri + logging.debug('Added %s', track.uri) def debug(uri, error): - print >> sys.stderr, 'Failed %s: %s' % (uri, error) + logging.error('Failed %s: %s', uri, error) -print >> sys.stderr, 'Scanning %s' % settings.LOCAL_MUSIC_PATH +logging.info('Scanning %s', settings.LOCAL_MUSIC_PATH) scanner = Scanner(settings.LOCAL_MUSIC_PATH, store, debug) try: @@ -24,7 +28,7 @@ try: except KeyboardInterrupt: scanner.stop() -print >> sys.stderr, 'Done' +logging.info('Done') for a in tracks_to_tag_cache_format(tracks): if len(a) == 1: From 503c98b98ee99196088d7e0f88dab7e7d5d98165 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 4 Jun 2011 01:29:28 +0200 Subject: [PATCH 08/65] Cleanup of gst code for scanner --- mopidy/scanner.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/mopidy/scanner.py b/mopidy/scanner.py index c3eda9ae..eade1ce5 100644 --- a/mopidy/scanner.py +++ b/mopidy/scanner.py @@ -57,17 +57,16 @@ class Scanner(object): self.error_callback = error_callback self.loop = gobject.MainLoop() - caps = gst.Caps('audio/x-raw-int') fakesink = gst.element_factory_make('fakesink') - pad = fakesink.get_pad('sink') self.uribin = gst.element_factory_make('uridecodebin') - self.uribin.connect('pad-added', self.process_new_pad, pad) - self.uribin.set_property('caps', caps) + self.uribin.set_property('caps', gst.Caps('audio/x-raw-int')) + self.uribin.connect('pad-added', self.process_new_pad, + fakesink.get_pad('sink')) self.pipe = gst.element_factory_make('pipeline') - self.pipe.add(fakesink) self.pipe.add(self.uribin) + self.pipe.add(fakesink) bus = self.pipe.get_bus() bus.add_signal_watch() From f091433a53c8b5ac3c27b4d35affb26daa673ee4 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 4 Jun 2011 01:47:48 +0200 Subject: [PATCH 09/65] Cleanup error feedback --- bin/mopidy-scan | 10 +++++----- mopidy/scanner.py | 4 ++-- tests/scanner_test.py | 4 ++-- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/bin/mopidy-scan b/bin/mopidy-scan index b87e8eb9..b8b4fd3e 100755 --- a/bin/mopidy-scan +++ b/bin/mopidy-scan @@ -15,12 +15,12 @@ tracks = [] def store(data): track = translator(data) tracks.append(track) - logging.debug('Added %s', track.uri) + logging.debug(u'Added %s', track.uri) -def debug(uri, error): - logging.error('Failed %s: %s', uri, error) +def debug(uri, error, debug): + logging.error(u'Failed %s: %s - %s', uri, error, debug) -logging.info('Scanning %s', settings.LOCAL_MUSIC_PATH) +logging.info(u'Scanning %s', settings.LOCAL_MUSIC_PATH) scanner = Scanner(settings.LOCAL_MUSIC_PATH, store, debug) try: @@ -28,7 +28,7 @@ try: except KeyboardInterrupt: scanner.stop() -logging.info('Done') +logging.info(u'Done') for a in tracks_to_tag_cache_format(tracks): if len(a) == 1: diff --git a/mopidy/scanner.py b/mopidy/scanner.py index eade1ce5..17e8127d 100644 --- a/mopidy/scanner.py +++ b/mopidy/scanner.py @@ -90,8 +90,8 @@ class Scanner(object): def process_error(self, bus, message): if self.error_callback: uri = self.uribin.get_property('uri') - errors = message.parse_error() - self.error_callback(uri, errors) + error, debug = message.parse_error() + self.error_callback(uri, error, debug) self.next_uri() def get_duration(self): diff --git a/tests/scanner_test.py b/tests/scanner_test.py index b98c5aa9..b2f2f2fd 100644 --- a/tests/scanner_test.py +++ b/tests/scanner_test.py @@ -144,9 +144,9 @@ class ScannerTest(unittest.TestCase): uri = data['uri'][len('file://'):] self.data[uri] = data - def error_callback(self, uri, errors): + def error_callback(self, uri, error, debug): uri = uri[len('file://'):] - self.errors[uri] = errors + self.errors[uri] = (error, debug) def test_data_is_set(self): self.scan('scanner/simple') From 89104bd326587b84b8af5dd6151e836baa20ae22 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 4 Jun 2011 01:52:11 +0200 Subject: [PATCH 10/65] Add skipped test case for date missing in scanner tests --- tests/scanner_test.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/scanner_test.py b/tests/scanner_test.py index b2f2f2fd..f403a221 100644 --- a/tests/scanner_test.py +++ b/tests/scanner_test.py @@ -4,7 +4,7 @@ from datetime import date from mopidy.scanner import Scanner, translator from mopidy.models import Track, Artist, Album -from tests import path_to_data_dir +from tests import path_to_data_dir, SkipTest class FakeGstDate(object): def __init__(self, year, month, day): @@ -184,3 +184,7 @@ class ScannerTest(unittest.TestCase): def test_other_media_is_ignored(self): self.scan('scanner/image') self.assert_(self.errors) + + @SkipTest + def test_song_without_time_is_handeled(self): + pass From eba5cff9d10170e0dca63368e5b3ae2236fb6360 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 4 Jun 2011 02:21:42 +0200 Subject: [PATCH 11/65] Work around strange wma issue in scanner --- bin/mopidy-scan | 3 ++- mopidy/scanner.py | 19 +++++++++++++++---- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/bin/mopidy-scan b/bin/mopidy-scan index b8b4fd3e..869aa662 100755 --- a/bin/mopidy-scan +++ b/bin/mopidy-scan @@ -4,10 +4,11 @@ import sys import logging from mopidy import settings -from mopidy.utils.log import setup_console_logging +from mopidy.utils.log import setup_console_logging, setup_root_logger from mopidy.scanner import Scanner, translator from mopidy.frontends.mpd.translator import tracks_to_tag_cache_format +setup_root_logger() setup_console_logging(2) tracks = [] diff --git a/mopidy/scanner.py b/mopidy/scanner.py index 17e8127d..b2e254da 100644 --- a/mopidy/scanner.py +++ b/mopidy/scanner.py @@ -77,10 +77,21 @@ class Scanner(object): pad.link(target_pad) def process_tags(self, bus, message): - data = message.parse_tag() - data = dict([(k, data[k]) for k in data.keys()]) - data['uri'] = unicode(self.uribin.get_property('uri')) - data[gst.TAG_DURATION] = self.get_duration() + taglist = message.parse_tag() + data = { + 'uri': unicode(self.uribin.get_property('uri')), + gst.TAG_DURATION: self.get_duration(), + } + + for key in taglist.keys(): + # XXX: For some crazy reason some wma files spit out lists here, + # not sure if this is due to better data in headers or wma being + # stupid. So ugly hack for now :/ + if type(taglist[key]) is list: + data[key] = taglist[key][0] + else: + data[key] = taglist[key] + try: self.data_callback(data) self.next_uri() From 20d3b48bb5ffb8d2fbbc2ee7c4b14b3ecc751d65 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 7 Jun 2011 16:02:29 +0200 Subject: [PATCH 12/65] Update changelog with fix for GH-59 (fixes: #59) --- docs/changes.rst | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/changes.rst b/docs/changes.rst index b4d56711..b13dad26 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -29,6 +29,13 @@ No description yet. - 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. +- Spotify backend: + + - Thanks to Antoine Pierlot-Garcin's recent work on updating and improving + pyspotify, stored playlists will again load when Mopidy starts. The + workaround of searching and reconnecting to make the playlists appear are + no longer necessary. (Fixes: :issue:`59`) + - MPD frontend: - Refactoring and cleanup. Most notably, all request handlers now get an From f35eb4aa9b45d93e134e384426ea6cfd8ce74c2a Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 7 Jun 2011 16:04:26 +0200 Subject: [PATCH 13/65] Require libspotify 0.0.8 and pyspotify 1.2 --- docs/changes.rst | 8 ++++++++ docs/installation/libspotify.rst | 28 ++++++++++++---------------- 2 files changed, 20 insertions(+), 16 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index b13dad26..a3a8f1ce 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -10,8 +10,16 @@ v0.5.0 (in development) No description yet. +Please note that 0.5.0 requires some updated dependencies, as listed under +*Important changes* below. + **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 + automatically be upgraded. If you are not installing from APT, follow the + instructions at :doc:`/installation/libspotify/`. + - Mopidy now supports running with 1-n outputs at the same time. This feature was mainly added to facilitate Shoutcast support, which Mopidy has also gained. In its current state outputs can not be toggled during runtime. diff --git a/docs/installation/libspotify.rst b/docs/installation/libspotify.rst index ca0ad87d..2728be94 100644 --- a/docs/installation/libspotify.rst +++ b/docs/installation/libspotify.rst @@ -4,8 +4,8 @@ libspotify installation Mopidy uses `libspotify `_ for playing music from -the Spotify music service. To use :mod:`mopidy.backends.libspotify` you must -install libspotify and `pyspotify `_. +the Spotify music service. To use :mod:`mopidy.backends.spotify` you must +install libspotify and `pyspotify `_. .. note:: @@ -30,7 +30,7 @@ If you run a Debian based Linux distribution, like Ubuntu, see http://apt.mopidy.com/ for how to the Mopidy APT archive as a software source on your installation. Then, simply run:: - sudo apt-get install libspotify7 + sudo apt-get install libspotify8 When libspotify has been installed, continue with :ref:`pyspotify_installation`. @@ -39,14 +39,14 @@ When libspotify has been installed, continue with On Linux from source -------------------- -Download and install libspotify 0.0.7 for your OS and CPU architecture from +Download and install libspotify 0.0.8 for your OS and CPU architecture from https://developer.spotify.com/en/libspotify/. For 64-bit Linux the process is as follows:: - wget http://developer.spotify.com/download/libspotify/libspotify-0.0.7-linux6-x86_64.tar.gz - tar zxfv libspotify-0.0.7-linux6-x86_64.tar.gz - cd libspotify-0.0.7-linux6-x86_64/ + wget http://developer.spotify.com/download/libspotify/libspotify-0.0.8-linux6-x86_64.tar.gz + tar zxfv libspotify-0.0.8-linux6-x86_64.tar.gz + cd libspotify-0.0.8-linux6-x86_64/ sudo make install prefix=/usr/local sudo ldconfig @@ -103,14 +103,10 @@ Debian/Ubuntu systems run:: On OS X no additional dependencies are needed. -Get the pyspotify code, and install it:: +Then get, build, and install the latest releast of pyspotify using ``pip``:: - wget --no-check-certificate -O pyspotify.tar.gz https://github.com/mopidy/pyspotify/tarball/mopidy - tar zxfv pyspotify.tar.gz - cd pyspotify/ - sudo python setup.py install + sudo pip install -U pyspotify -It is important that you install pyspotify from the ``mopidy`` branch of the -``mopidy/pyspotify`` repository, as the upstream repository at -``winjer/pyspotify`` is not updated with changes needed to support e.g. -libspotify 0.0.7 and high bitrate audio. +Or using the older ``easy_install``:: + + sudo easy_install pyspotify From 474805c9bee6178875e0936dd9ce899f222d5857 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 7 Jun 2011 23:43:45 +0200 Subject: [PATCH 14/65] UFixMPD server by correctly giving the socket to asyncore.dispatcher --- mopidy/frontends/mpd/server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/frontends/mpd/server.py b/mopidy/frontends/mpd/server.py index 927e2a00..aa1f98ac 100644 --- a/mopidy/frontends/mpd/server.py +++ b/mopidy/frontends/mpd/server.py @@ -20,7 +20,7 @@ class MpdServer(asyncore.dispatcher): def start(self): """Start MPD server.""" try: - self.socket = network.create_socket() + self.set_socket(network.create_socket()) self.set_reuse_addr() hostname = network.format_hostname(settings.MPD_SERVER_HOSTNAME) port = settings.MPD_SERVER_PORT From e09729fe7742dd62e6e94b00db4e2ad40951d516 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 7 Jun 2011 23:48:00 +0200 Subject: [PATCH 15/65] No need to close the socket when you're told that the socket is closed --- mopidy/frontends/mpd/server.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/mopidy/frontends/mpd/server.py b/mopidy/frontends/mpd/server.py index aa1f98ac..980e497d 100644 --- a/mopidy/frontends/mpd/server.py +++ b/mopidy/frontends/mpd/server.py @@ -39,7 +39,3 @@ class MpdServer(asyncore.dispatcher): logger.info(u'MPD client connection from [%s]:%s', client_socket_address[0], client_socket_address[1]) MpdSession(self, client_socket, client_socket_address) - - def handle_close(self): - """Called by asyncore when the socket is closed.""" - self.close() From a1932b3e9889f1f4650ad32f88cad05a6e80e5b0 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 7 Jun 2011 23:50:32 +0200 Subject: [PATCH 16/65] We deal with tracks, not songs --- mopidy/backends/local/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index cc039ce0..5da80a18 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -174,7 +174,7 @@ class LocalLibraryProvider(BaseLibraryProvider): tracks = parse_mpd_tag_cache(tag_cache, music_folder) - logger.info('Loading songs in %s from %s', music_folder, tag_cache) + logger.info('Loading tracks in %s from %s', music_folder, tag_cache) for track in tracks: self._uri_mapping[track.uri] = track From 3b21b7bf7d26978041ed49d7d3c7d4ce95a381fa Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 8 Jun 2011 02:28:41 +0200 Subject: [PATCH 17/65] This is kind of redundant --- mopidy/frontends/mpd/server.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/mopidy/frontends/mpd/server.py b/mopidy/frontends/mpd/server.py index 980e497d..62e443fb 100644 --- a/mopidy/frontends/mpd/server.py +++ b/mopidy/frontends/mpd/server.py @@ -14,9 +14,6 @@ class MpdServer(asyncore.dispatcher): for each client connection. """ - def __init__(self): - asyncore.dispatcher.__init__(self) - def start(self): """Start MPD server.""" try: From f6a66604b8841254069f0b86c0474e2e7a943102 Mon Sep 17 00:00:00 2001 From: Antoine Pierlot-Garcin Date: Tue, 7 Jun 2011 23:46:28 -0400 Subject: [PATCH 18/65] pyspotify now returns unicode objects --- mopidy/backends/spotify/translator.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/mopidy/backends/spotify/translator.py b/mopidy/backends/spotify/translator.py index 21abdf78..0ab4def9 100644 --- a/mopidy/backends/spotify/translator.py +++ b/mopidy/backends/spotify/translator.py @@ -4,7 +4,6 @@ import logging from spotify import Link, SpotifyError from mopidy import settings -from mopidy.backends.spotify import ENCODING from mopidy.models import Artist, Album, Track, Playlist logger = logging.getLogger('mopidy.backends.spotify.translator') @@ -16,7 +15,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 +23,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 +37,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 +56,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))], From b6d1ff2b7463c9fae38ca545b36b1c608074e4f3 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 9 Jun 2011 13:54:12 +0200 Subject: [PATCH 19/65] Add scanner improvements to changelog --- docs/changes.rst | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/changes.rst b/docs/changes.rst index b4d56711..c1061a37 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -44,6 +44,17 @@ No description yet. authentication is turned on, but the connected user has not been authenticated yet. +- Tag cache generator: + + - Made it possible to CTRL^c mopidy-scan. + + - Fixed bug with bad dates. + + - Use logging not print statements. + + - Found and worked around strange WMA metadata behaviour, should be fixed + properly. + v0.4.1 (2011-05-06) =================== From 06bee1cd214fd959021ff40c0e0e30cf1bfc47e4 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 9 Jun 2011 13:55:15 +0200 Subject: [PATCH 20/65] Typo fix --- mopidy/scanner.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/scanner.py b/mopidy/scanner.py index b2e254da..3bcf03d9 100644 --- a/mopidy/scanner.py +++ b/mopidy/scanner.py @@ -106,7 +106,7 @@ class Scanner(object): self.next_uri() def get_duration(self): - self.pipe.get_state() # Block untill state change is done. + self.pipe.get_state() # Block until state change is done. try: return self.pipe.query_duration( gst.FORMAT_TIME, None)[0] // gst.MSECOND From a9bcfaa805948f6a94c3466c010722ebc6457af1 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 9 Jun 2011 15:17:07 +0200 Subject: [PATCH 21/65] Rename SHOUTCAST_OUTPUT_SERVER to SHOUTCAST_OUTPUT_HOSTNAME to be consistent with MPD_SERVER_HOSTNAME --- mopidy/outputs/shoutcast.py | 4 ++-- mopidy/settings.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/mopidy/outputs/shoutcast.py b/mopidy/outputs/shoutcast.py index 4298bba5..d2605514 100644 --- a/mopidy/outputs/shoutcast.py +++ b/mopidy/outputs/shoutcast.py @@ -21,9 +21,9 @@ class ShoutcastOutput(BaseOutput): def modify_bin(self): self.set_properties(self.bin.get_by_name('shoutcast'), { - u'ip': settings.SHOUTCAST_OUTPUT_SERVER, - u'mount': settings.SHOUTCAST_OUTPUT_MOUNT, + u'ip': settings.SHOUTCAST_OUTPUT_HOSTNAME, u'port': settings.SHOUTCAST_OUTPUT_PORT, + u'mount': settings.SHOUTCAST_OUTPUT_MOUNT, u'username': settings.SHOUTCAST_OUTPUT_USERNAME, u'password': settings.SHOUTCAST_OUTPUT_PASSWORD, }) diff --git a/mopidy/settings.py b/mopidy/settings.py index 414c79ee..f92f51ca 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -183,8 +183,8 @@ OUTPUTS = ( #: #: Default:: #: -#: SHOUTCAST_OUTPUT_SERVER = u'127.0.0.1' -SHOUTCAST_OUTPUT_SERVER = u'127.0.0.1' +#: SHOUTCAST_OUTPUT_HOSTNAME = u'127.0.0.1' +SHOUTCAST_OUTPUT_HOSTNAME = u'127.0.0.1' #: User to authenticate as against Shoutcast server. #: From a7a686f4ad662cf50ede65cee8712721c324cf9d Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 9 Jun 2011 15:18:29 +0200 Subject: [PATCH 22/65] docs: How to use the SHOUTcast output --- docs/settings.rst | 26 +++++++++++++++++++++ mopidy/settings.py | 56 ++++++++++++++++++++++++++++------------------ 2 files changed, 60 insertions(+), 22 deletions(-) diff --git a/docs/settings.rst b/docs/settings.rst index 1d4a4972..917a71ba 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -92,6 +92,7 @@ To make a ``tag_cache`` of your local music available for Mopidy: .. _use_mpd_on_a_network: + Connecting from other machines on the network ============================================= @@ -119,6 +120,31 @@ file:: LASTFM_PASSWORD = u'mysecret' +Streaming audio through a SHOUTcast/Icecast server +================================================== + +If you want to play the audio on another computer than the one running Mopidy, +you can stream the audio from Mopidy through an SHOUTcast or Icecast audio +streaming server. Multiple media players can then be connected to the streaming +server simultaneously. To use the SHOUTcast output, do the following: + +#. Install, configure and start the Icecast server. It can be found in the + ``icecast2`` package in Debian/Ubuntu. + +#. Add ``mopidy.outputs.shoutcast.ShoutcastOutput`` output to the + :attr:`mopidy.settings.OUTPUTS` setting. + +#. Check the default values for the following settings, and alter them to match + your Icecast setup if needed: + + - :attr:`mopidy.settings.SHOUTCAST_OUTPUT_HOSTNAME` + - :attr:`mopidy.settings.SHOUTCAST_OUTPUT_PORT` + - :attr:`mopidy.settings.SHOUTCAST_OUTPUT_USERNAME` + - :attr:`mopidy.settings.SHOUTCAST_OUTPUT_PASSWORD` + - :attr:`mopidy.settings.SHOUTCAST_OUTPUT_MOUNT` + - :attr:`mopidy.settings.SHOUTCAST_OUTPUT_ENCODER` + + Available settings ================== diff --git a/mopidy/settings.py b/mopidy/settings.py index f92f51ca..6721e0a6 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -157,16 +157,16 @@ MIXER_MAX_VOLUME = 100 #: Listens on all interfaces, both IPv4 and IPv6. MPD_SERVER_HOSTNAME = u'127.0.0.1' -#: The password required for connecting to the MPD server. -#: -#: Default: :class:`None`, which means no password required. -MPD_SERVER_PASSWORD = None - #: Which TCP port Mopidy's MPD server should listen to. #: #: Default: 6600 MPD_SERVER_PORT = 6600 +#: The password required for connecting to the MPD server. +#: +#: Default: :class:`None`, which means no password required. +MPD_SERVER_PASSWORD = None + #: List of outputs to use. See :mod:`mopidy.outputs` for all available #: backends #: @@ -179,42 +179,54 @@ OUTPUTS = ( u'mopidy.outputs.local.LocalOutput', ) -#: Servar that runs Shoutcast server to send stream to. +#: Hostname of the SHOUTcast server which Mopidy should stream audio to. +#: +#: Used by :mod:`mopidy.outputs.shoutcast`. #: #: Default:: #: #: SHOUTCAST_OUTPUT_HOSTNAME = u'127.0.0.1' SHOUTCAST_OUTPUT_HOSTNAME = u'127.0.0.1' -#: User to authenticate as against Shoutcast server. +#: Port of the SHOUTcast server. #: -#: Default:: -#: -#: SHOUTCAST_OUTPUT_USERNAME = u'source' -SHOUTCAST_OUTPUT_USERNAME = u'source' - -#: Password to authenticate with against Shoutcast server. -#: -#: Default:: -#: -#: SHOUTCAST_OUTPUT_PASSWORD = u'hackme' -SHOUTCAST_OUTPUT_PASSWORD = u'hackme' - -#: Port to use for streaming to Shoutcast server. +#: Used by :mod:`mopidy.outputs.shoutcast`. #: #: Default:: #: #: SHOUTCAST_OUTPUT_PORT = 8000 SHOUTCAST_OUTPUT_PORT = 8000 -#: Mountpoint to use for the stream on the Shoutcast server. +#: User to authenticate as against SHOUTcast server. +#: +#: Used by :mod:`mopidy.outputs.shoutcast`. +#: +#: Default:: +#: +#: SHOUTCAST_OUTPUT_USERNAME = u'source' +SHOUTCAST_OUTPUT_USERNAME = u'source' + +#: Password to authenticate with against SHOUTcast server. +#: +#: Used by :mod:`mopidy.outputs.shoutcast`. +#: +#: Default:: +#: +#: SHOUTCAST_OUTPUT_PASSWORD = u'hackme' +SHOUTCAST_OUTPUT_PASSWORD = u'hackme' + +#: Mountpoint to use for the stream on the SHOUTcast server. +#: +#: Used by :mod:`mopidy.outputs.shoutcast`. #: #: Default:: #: #: SHOUTCAST_OUTPUT_MOUNT = u'/stream' SHOUTCAST_OUTPUT_MOUNT = u'/stream' -#: Encoder to use to process audio data before streaming. +#: Encoder to use to process audio data before streaming to SHOUTcast server. +#: +#: Used by :mod:`mopidy.outputs.shoutcast`. #: #: Default:: #: From ed59030a50a4a8450d1e708624a5f469f5a3424f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 9 Jun 2011 16:10:28 +0200 Subject: [PATCH 23/65] Update changelog for 0.5.0 release --- docs/changes.rst | 39 ++++++++++++++++++++++----------------- 1 file changed, 22 insertions(+), 17 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index a01ff931..65add741 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -8,7 +8,9 @@ This change log is used to track all major changes to Mopidy. v0.5.0 (in development) ======================= -No description yet. +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. Please note that 0.5.0 requires some updated dependencies, as listed under *Important changes* below. @@ -20,22 +22,16 @@ Please note that 0.5.0 requires some updated dependencies, as listed under automatically be upgraded. If you are not installing from APT, follow the instructions at :doc:`/installation/libspotify/`. -- Mopidy now supports running with 1-n outputs at the same time. This feature - was mainly added to facilitate Shoutcast support, which Mopidy has also +- Mopidy now supports running with 1 to N outputs at the same time. This feature + was mainly added to facilitate SHOUTcast support, which Mopidy has also gained. In its current state outputs can not be toggled during runtime. **Changes** -- Fix local backend time query errors that where coming from stopped pipeline. - (Fixes: :issue:`87`) +- Local backend: -- Support passing options to GStreamer. See :option:`--help-gst` for a list of - available options. (Fixes: :issue:`95`) - -- Improve :option:`--list-settings` output. (Fixes: :issue:`91`) - -- 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. + - Fix local backend time query errors that where coming from stopped + pipeline. (Fixes: :issue:`87`) - Spotify backend: @@ -44,6 +40,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. + - MPD frontend: - Refactoring and cleanup. Most notably, all request handlers now get an @@ -59,16 +58,22 @@ Please note that 0.5.0 requires some updated dependencies, as listed under authentication is turned on, but the connected user has not been authenticated yet. +- Command line usage: + + - Support passing options to GStreamer. See :option:`--help-gst` for a list + of available options. (Fixes: :issue:`95`) + + - Improve :option:`--list-settings` output. (Fixes: :issue:`91`) + - Tag cache generator: - - Made it possible to CTRL^c mopidy-scan. + - Made it possible to abort :command:`mopidy-scan` with CTRL+C. - - Fixed bug with bad dates. + - Fixed bug regarding handling of bad dates. - - Use logging not print statements. + - Use :mod:`logging` instead of ``print`` statements. - - Found and worked around strange WMA metadata behaviour, should be fixed - properly. + - Found and worked around strange WMA metadata behaviour. v0.4.1 (2011-05-06) From 35cc1dcb34a8c96d1fcf50ee6467907e8d56addc Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 9 Jun 2011 17:00:48 +0200 Subject: [PATCH 24/65] Do not print stack trace on settings validation error --- mopidy/core.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/mopidy/core.py b/mopidy/core.py index e510b698..ca5b92a1 100644 --- a/mopidy/core.py +++ b/mopidy/core.py @@ -16,7 +16,8 @@ sys.argv[1:] = gstreamer_args from pykka.registry import ActorRegistry -from mopidy import get_version, settings, OptionalDependencyError +from mopidy import (get_version, settings, OptionalDependencyError, + SettingsError) from mopidy.gstreamer import GStreamer from mopidy.utils import get_class from mopidy.utils.log import setup_logging @@ -65,7 +66,11 @@ def parse_options(): def setup_settings(): get_or_create_folder('~/.mopidy/') get_or_create_file('~/.mopidy/settings.py') - settings.validate() + try: + settings.validate() + except SettingsError, e: + logger.error(e.message) + sys.exit(1) def setup_gobject_loop(): GObjectEventThread().start() From e97b32d041496d42b55e9d56c5439919bae5fc05 Mon Sep 17 00:00:00 2001 From: Johannes Knutsen Date: Thu, 9 Jun 2011 17:18:35 +0200 Subject: [PATCH 25/65] rename SPOTIFY_HIGH_BITRATE setting to SPOTIFY_BITRATE, and use actual bitrate value to define preferred bitrate --- mopidy/backends/spotify/__init__.py | 1 + mopidy/backends/spotify/session_manager.py | 11 +++++------ mopidy/backends/spotify/translator.py | 4 ++-- mopidy/settings.py | 8 +++++--- mopidy/utils/settings.py | 7 +++++++ tests/utils/settings_test.py | 8 ++++++++ 6 files changed, 28 insertions(+), 11 deletions(-) diff --git a/mopidy/backends/spotify/__init__.py b/mopidy/backends/spotify/__init__.py index 9dababc0..da839b26 100644 --- a/mopidy/backends/spotify/__init__.py +++ b/mopidy/backends/spotify/__init__.py @@ -11,6 +11,7 @@ from mopidy.gstreamer import GStreamer logger = logging.getLogger('mopidy.backends.spotify') ENCODING = 'utf-8' +BITRATES = {96: 2, 160: 0, 320: 1} class SpotifyBackend(ThreadingActor, Backend): """ diff --git a/mopidy/backends/spotify/session_manager.py b/mopidy/backends/spotify/session_manager.py index 388b29c3..4b6abe85 100644 --- a/mopidy/backends/spotify/session_manager.py +++ b/mopidy/backends/spotify/session_manager.py @@ -8,6 +8,7 @@ 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.translator import SpotifyTranslator from mopidy.models import Playlist from mopidy.gstreamer import GStreamer @@ -58,12 +59,10 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager): return logger.info(u'Connected to Spotify') self.session = session - if settings.SPOTIFY_HIGH_BITRATE: - logger.debug(u'Preferring high bitrate from Spotify') - self.session.set_preferred_bitrate(1) - else: - logger.debug(u'Preferring normal bitrate from Spotify') - self.session.set_preferred_bitrate(0) + + 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.container_manager.watch(self.session.playlist_container()) self.connected.set() diff --git a/mopidy/backends/spotify/translator.py b/mopidy/backends/spotify/translator.py index 21abdf78..91a2a9ae 100644 --- a/mopidy/backends/spotify/translator.py +++ b/mopidy/backends/spotify/translator.py @@ -4,7 +4,7 @@ import logging from spotify import Link, SpotifyError from mopidy import settings -from mopidy.backends.spotify import ENCODING +from mopidy.backends.spotify import ENCODING, BITRATES from mopidy.models import Artist, Album, Track, Playlist logger = logging.getLogger('mopidy.backends.spotify.translator') @@ -44,7 +44,7 @@ class SpotifyTranslator(object): track_no=spotify_track.index(), date=date, length=spotify_track.duration(), - bitrate=(settings.SPOTIFY_HIGH_BITRATE and 320 or 160), + bitrate=BITRATES[settings.SPOTIFY_BITRATE], ) @classmethod diff --git a/mopidy/settings.py b/mopidy/settings.py index 6721e0a6..9ac63719 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -248,11 +248,13 @@ SPOTIFY_USERNAME = u'' #: Used by :mod:`mopidy.backends.spotify`. SPOTIFY_PASSWORD = u'' -#: Do you prefer high bitrate (320k)? +#: Spotify preferred bitrate. +#: +#: Available values are 96, 160, and 320. #: #: Used by :mod:`mopidy.backends.spotify`. # #: Default:: #: -#: SPOTIFY_HIGH_BITRATE = False # 160k -SPOTIFY_HIGH_BITRATE = False +#: SPOTIFY_BITRATE = 160 +SPOTIFY_BITRATE = 160 diff --git a/mopidy/utils/settings.py b/mopidy/utils/settings.py index 2bd6e6f3..05d40a4a 100644 --- a/mopidy/utils/settings.py +++ b/mopidy/utils/settings.py @@ -8,6 +8,7 @@ import sys from mopidy import SettingsError from mopidy.utils.log import indent +from mopidy.backends.spotify import BITRATES as SPOTIFY_BITRATES logger = logging.getLogger('mopidy.utils.settings') @@ -107,6 +108,7 @@ def validate_settings(defaults, settings): 'SERVER': None, 'SERVER_HOSTNAME': 'MPD_SERVER_HOSTNAME', 'SERVER_PORT': 'MPD_SERVER_PORT', + 'SPOTIFY_HIGH_BITRATE': 'SPOTIFY_BITRATE', 'SPOTIFY_LIB_APPKEY': None, 'SPOTIFY_LIB_CACHE': 'SPOTIFY_CACHE_PATH', } @@ -127,6 +129,11 @@ def validate_settings(defaults, settings): 'longer available.') continue + if setting == 'SPOTIFY_BITRATE': + if value not in SPOTIFY_BITRATES.keys(): + errors[setting] = (u'Unavailable Spotify bitrate. ' + + u'Available bitrates are 96, 160, and 320.') + if setting not in defaults: errors[setting] = u'Unknown setting. Is it misspelled?' continue diff --git a/tests/utils/settings_test.py b/tests/utils/settings_test.py index 1ffff9a6..748eae85 100644 --- a/tests/utils/settings_test.py +++ b/tests/utils/settings_test.py @@ -10,6 +10,7 @@ class ValidateSettingsTest(unittest.TestCase): self.defaults = { 'MPD_SERVER_HOSTNAME': '::', 'MPD_SERVER_PORT': 6600, + 'SPOTIFY_BITRATE': 160, } def test_no_errors_yields_empty_dict(self): @@ -42,6 +43,13 @@ class ValidateSettingsTest(unittest.TestCase): '"mopidy.backends.despotify.DespotifyBackend" is no longer ' + 'available.') + def test_unavailable_bitrate_setting_returns_error(self): + result = validate_settings(self.defaults, + {'SPOTIFY_BITRATE': 50}) + self.assertEqual(result['SPOTIFY_BITRATE'], + u'Unavailable Spotify bitrate. ' + + u'Available bitrates are 96, 160, and 320.') + def test_two_errors_are_both_reported(self): result = validate_settings(self.defaults, {'FOO': '', 'BAR': ''}) From 232155007999dd7b2a8388da5f64ec1073c1ce76 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 9 Jun 2011 17:35:22 +0200 Subject: [PATCH 26/65] Fix import cycle --- mopidy/utils/settings.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/mopidy/utils/settings.py b/mopidy/utils/settings.py index 05d40a4a..01fee23d 100644 --- a/mopidy/utils/settings.py +++ b/mopidy/utils/settings.py @@ -8,7 +8,6 @@ import sys from mopidy import SettingsError from mopidy.utils.log import indent -from mopidy.backends.spotify import BITRATES as SPOTIFY_BITRATES logger = logging.getLogger('mopidy.utils.settings') @@ -130,7 +129,7 @@ def validate_settings(defaults, settings): continue if setting == 'SPOTIFY_BITRATE': - if value not in SPOTIFY_BITRATES.keys(): + if value not in (96, 160, 320): errors[setting] = (u'Unavailable Spotify bitrate. ' + u'Available bitrates are 96, 160, and 320.') From 3432c84e6869abbf11c5b0d387a1fe2d807c90af Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 9 Jun 2011 17:38:33 +0200 Subject: [PATCH 27/65] Add new SPOTIFY_BITRATE setting to changelog --- docs/changes.rst | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 65add741..4b6f74ca 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -22,9 +22,14 @@ Please note that 0.5.0 requires some updated dependencies, as listed under automatically be upgraded. If you are not installing from APT, follow the instructions at :doc:`/installation/libspotify/`. -- Mopidy now supports running with 1 to N outputs at the same time. This feature - was mainly added to facilitate SHOUTcast support, which Mopidy has also - gained. In its current state outputs can not be toggled during runtime. +- If you have explicitly set the :attr:`mopidy.settings.SPOTIFY_HIGH_BITRATE` + setting, you must update your settings file. The new setting is named + :attr:`mopidy.settings.SPOTIFY_BITRATE` and accepts the integer values 96, + 160, and 320. + +- Mopidy now supports running with 1 to N outputs at the same time. This + feature was mainly added to facilitate SHOUTcast support, which Mopidy has + also gained. In its current state outputs can not be toggled during runtime. **Changes** From a58257a653700b98eb2496c7a47d310b78b94a0b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 9 Jun 2011 17:40:54 +0200 Subject: [PATCH 28/65] Do not install mopidy.desktop as it is really hacky and not that useful --- setup.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/setup.py b/setup.py index 3d9d4fdf..a8cf8ed1 100644 --- a/setup.py +++ b/setup.py @@ -69,11 +69,6 @@ for dirpath, dirnames, filenames in os.walk(project_dir): data_files.append([dirpath, [os.path.join(dirpath, f) for f in filenames]]) -if os.geteuid() == 0: - # Only try to install this file if we are root - data_files.append( - ('/usr/local/share/applications', ['data/mopidy.desktop'])) - setup( name='Mopidy', version=get_version(), From 74dbc0ba8830e6a0246ba67633a9ca26d8554510 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 9 Jun 2011 17:49:31 +0200 Subject: [PATCH 29/65] docs: v0.3 suddenly became v0.6+ --- docs/settings.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/settings.rst b/docs/settings.rst index 917a71ba..f0888670 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -56,7 +56,7 @@ You may also want to change some of the ``LOCAL_*`` settings. See Currently, Mopidy supports using Spotify *or* local storage as a music source. We're working on using both sources simultaneously, and will - hopefully have support for this in the 0.3 release. + hopefully have support for this in the 0.6 release. .. _generating_a_tag_cache: From 9f552465e782db9212ee07b1c086e10d081b4edf Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 9 Jun 2011 17:49:44 +0200 Subject: [PATCH 30/65] docs: Update link to wishlist label --- docs/development/roadmap.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/development/roadmap.rst b/docs/development/roadmap.rst index cec8e9c7..6280762c 100644 --- a/docs/development/roadmap.rst +++ b/docs/development/roadmap.rst @@ -26,7 +26,7 @@ Feature wishlist We maintain our collection of sane or less sane ideas for future Mopidy features as `issues `_ at GitHub labeled with `the "wishlist" label -`_. Feel free to vote +`_. Feel free to vote up any feature you would love to see in Mopidy, but please refrain from adding a comment just to say "I want this too!". You are of course free to add comments if you have suggestions for how the feature should work or be From 11e85dd479dbc529fa5a0a7f808e78e942e18d6a Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 9 Jun 2011 17:56:10 +0200 Subject: [PATCH 31/65] docs: Remove outdated internals docs --- docs/_static/thread_communication.png | Bin 46887 -> 0 bytes docs/development/index.rst | 1 - docs/development/internals.rst | 113 -------------------------- 3 files changed, 114 deletions(-) delete mode 100644 docs/_static/thread_communication.png delete mode 100644 docs/development/internals.rst diff --git a/docs/_static/thread_communication.png b/docs/_static/thread_communication.png deleted file mode 100644 index 95bf1892dff82ebfc806f4595ded3abfec6061fe..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 46887 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 z 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 diff --git a/docs/development/index.rst b/docs/development/index.rst index 14c49dbd..321b3242 100644 --- a/docs/development/index.rst +++ b/docs/development/index.rst @@ -7,4 +7,3 @@ Development roadmap contributing - internals diff --git a/docs/development/internals.rst b/docs/development/internals.rst deleted file mode 100644 index 4b4d3b14..00000000 --- a/docs/development/internals.rst +++ /dev/null @@ -1,113 +0,0 @@ -********* -Internals -********* - -Some of the following notes and details will hopefully be useful when you start -developing on Mopidy, while some may only be useful when you get deeper into -specific parts of Mopidy. - -In addition to what you'll find here, don't forget the :doc:`/api/index`. - - -Class instantiation and usage -============================= - -The following diagram shows how Mopidy is wired together with the MPD client, -the Spotify service, and the speakers. - -**Legend** - -- Filled red boxes are the key external systems. -- Gray boxes are external dependencies. -- Blue circles lives in the ``main`` process, also known as ``CoreProcess``. - It is processing messages put on the core queue. -- Purple circles lives in a process named ``MpdProcess``, running an - :mod:`asyncore` loop. -- Green circles lives in a process named ``GStreamerProcess``. -- Brown circle is a thread living in the ``CoreProcess``. - -.. digraph:: class_instantiation_and_usage - - "main" [ color="blue" ] - "CoreProcess" [ color="blue" ] - - # Frontend - "MPD client" [ color="red", style="filled", shape="box" ] - "MpdFrontend" [ color="blue" ] - "MpdProcess" [ color="purple" ] - "MpdServer" [ color="purple" ] - "MpdSession" [ color="purple" ] - "MpdDispatcher" [ color="blue" ] - - # Backend - "Libspotify\nBackend" [ color="blue" ] - "Libspotify\nSessionManager" [ color="brown" ] - "pyspotify" [ color="gray", shape="box" ] - "libspotify" [ color="gray", shape="box" ] - "Spotify" [ color="red", style="filled", shape="box" ] - - # Output/mixer - "GStreamer\nOutput" [ color="blue" ] - "GStreamer\nSoftwareMixer" [ color="blue" ] - "GStreamer\nProcess" [ color="green" ] - "GStreamer" [ color="gray", shape="box" ] - "Speakers" [ color="red", style="filled", shape="box" ] - - "main" -> "CoreProcess" [ label="create" ] - - # Frontend - "CoreProcess" -> "MpdFrontend" [ label="create" ] - "MpdFrontend" -> "MpdProcess" [ label="create" ] - "MpdFrontend" -> "MpdDispatcher" [ label="create" ] - "MpdProcess" -> "MpdServer" [ label="create" ] - "MpdServer" -> "MpdSession" [ label="create one\nper client" ] - "MpdSession" -> "MpdDispatcher" [ - label="pass requests\nvia core_queue" ] - "MpdDispatcher" -> "MpdSession" [ - label="pass response\nvia reply_to pipe" ] - "MpdDispatcher" -> "Libspotify\nBackend" [ label="use backend API" ] - "MPD client" -> "MpdServer" [ label="connect" ] - "MPD client" -> "MpdSession" [ label="request" ] - "MpdSession" -> "MPD client" [ label="response" ] - - # Backend - "CoreProcess" -> "Libspotify\nBackend" [ label="create" ] - "Libspotify\nBackend" -> "Libspotify\nSessionManager" [ - label="creates and uses" ] - "Libspotify\nSessionManager" -> "Libspotify\nBackend" [ - label="pass commands\nvia core_queue" ] - "Libspotify\nSessionManager" -> "pyspotify" [ label="use Python\nwrapper" ] - "pyspotify" -> "Libspotify\nSessionManager" [ label="use callbacks" ] - "pyspotify" -> "libspotify" [ label="use C library" ] - "libspotify" -> "Spotify" [ label="use service" ] - "Libspotify\nSessionManager" -> "GStreamer\nProcess" [ - label="pass commands\nand audio data\nvia output_queue" ] - - # Output/mixer - "Libspotify\nBackend" -> "GStreamer\nSoftwareMixer" [ - label="create and\nuse mixer API" ] - "GStreamer\nSoftwareMixer" -> "GStreamer\nProcess" [ - label="pass commands\nvia output_queue" ] - "CoreProcess" -> "GStreamer\nOutput" [ label="create" ] - "GStreamer\nOutput" -> "GStreamer\nProcess" [ label="create" ] - "GStreamer\nProcess" -> "GStreamer" [ label="use library" ] - "GStreamer" -> "Speakers" [ label="play audio" ] - - -Thread/process communication -============================ - -.. warning:: - This section is currently outdated. - -- Everything starts with ``Main``. -- ``Main`` creates a ``Core`` process which runs the frontend, backend, and - mixer. -- Mixers *may* create an additional process for communication with external - devices, like ``NadTalker`` in this example. -- Backend libraries *may* have threads of their own, like ``despotify`` here - which has additional threads in the ``Core`` process. -- ``Server`` part currently runs in the same process and thread as ``Main``. -- ``Client`` is some external client talking to ``Server`` over a socket. - -.. image:: /_static/thread_communication.png From 23121d4a1587b0f05fbb5f8e829d1c5c5dea5a9a Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 9 Jun 2011 18:08:19 +0200 Subject: [PATCH 32/65] docs: Update how to use a custom audio sink --- docs/installation/gstreamer.rst | 11 +++++++++-- docs/installation/index.rst | 4 ++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/docs/installation/gstreamer.rst b/docs/installation/gstreamer.rst index 72d55908..08e16378 100644 --- a/docs/installation/gstreamer.rst +++ b/docs/installation/gstreamer.rst @@ -73,5 +73,12 @@ Using a custom audio sink ========================= If you for some reason want to use some other GStreamer audio sink than -``autoaudiosink``, you can change :attr:`mopidy.settings.GSTREAMER_AUDIO_SINK` -in your ``settings.py`` file. +``autoaudiosink``, you can add ``mopidy.outputs.custom.CustomOutput`` to the +:attr:`mopidy.settings.OUTPUTS` setting, and set the +:attr:`mopidy.settings.CUSTOM_OUTPUT` setting to a partial GStreamer pipeline +description describing the GStreamer sink you want to use. + +Example of ``settings.py`` for OSS4:: + + OUTPUTS = (u'mopidy.outputs.custom.CustomOutput',) + CUSTOM_OUTPUT = u'oss4sink' diff --git a/docs/installation/index.rst b/docs/installation/index.rst index 1f497e3a..5101cc84 100644 --- a/docs/installation/index.rst +++ b/docs/installation/index.rst @@ -9,8 +9,8 @@ setup and whether you want to use stable releases or less stable development versions. -Install dependencies -==================== +Requirements +============ .. toctree:: :hidden: From 48943321608beb65a9fe6deb57afbb7b16a6fdca Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 9 Jun 2011 18:12:51 +0200 Subject: [PATCH 33/65] docs: New email address for knutz3n --- docs/authors.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/authors.rst b/docs/authors.rst index 01e810e4..af84f842 100644 --- a/docs/authors.rst +++ b/docs/authors.rst @@ -5,7 +5,7 @@ Authors Contributors to Mopidy in the order of appearance: - Stein Magnus Jodal -- Johannes Knutsen +- Johannes Knutsen - Thomas Adamcik - Kristian Klette From 7bd281942ad8b38858c0a2ea45fdf0ea9d2948e7 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 9 Jun 2011 18:16:50 +0200 Subject: [PATCH 34/65] docs: Add new state transition from STOPPED to PAUSED in graph --- mopidy/backends/base/playback.py | 1 + 1 file changed, 1 insertion(+) diff --git a/mopidy/backends/base/playback.py b/mopidy/backends/base/playback.py index 4ea7a13f..530c4840 100644 --- a/mopidy/backends/base/playback.py +++ b/mopidy/backends/base/playback.py @@ -263,6 +263,7 @@ class PlaybackController(object): .. digraph:: state_transitions "STOPPED" -> "PLAYING" [ label="play" ] + "STOPPED" -> "PAUSED" [ label="pause" ] "PLAYING" -> "STOPPED" [ label="stop" ] "PLAYING" -> "PAUSED" [ label="pause" ] "PLAYING" -> "PLAYING" [ label="play" ] From d6b3d51d3e88ace641f1d9935713918c42c11a07 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 9 Jun 2011 18:55:10 +0200 Subject: [PATCH 35/65] docs: Add dependencies and settings to all backends, frontends and outputs --- mopidy/backends/local/__init__.py | 6 +++++- mopidy/backends/spotify/__init__.py | 7 ++++++- mopidy/frontends/mpd/__init__.py | 6 +++++- mopidy/outputs/custom.py | 8 ++++++++ mopidy/outputs/local.py | 8 ++++++++ mopidy/outputs/shoutcast.py | 13 +++++++++++++ 6 files changed, 45 insertions(+), 3 deletions(-) diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index 5da80a18..93cf3534 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -22,7 +22,11 @@ class LocalBackend(ThreadingActor, Backend): """ A backend for playing music from a local music archive. - **Issues:** http://github.com/mopidy/mopidy/issues/labels/backend-local + **Issues:** https://github.com/mopidy/mopidy/issues?labels=backend-local + + **Dependencies:** + + - None **Settings:** diff --git a/mopidy/backends/spotify/__init__.py b/mopidy/backends/spotify/__init__.py index da839b26..87997059 100644 --- a/mopidy/backends/spotify/__init__.py +++ b/mopidy/backends/spotify/__init__.py @@ -28,7 +28,12 @@ class SpotifyBackend(ThreadingActor, Backend): trade mark of the Spotify Group. **Issues:** - http://github.com/mopidy/mopidy/issues/labels/backend-spotify + https://github.com/mopidy/mopidy/issues?labels=backend-spotify + + **Dependencies:** + + - libspotify == 0.0.8 (libspotify8 package from apt.mopidy.com) + - pyspotify == 1.2 (python-spotify package from apt.mopidy.com) **Settings:** diff --git a/mopidy/frontends/mpd/__init__.py b/mopidy/frontends/mpd/__init__.py index 24c21c38..175aa0ee 100644 --- a/mopidy/frontends/mpd/__init__.py +++ b/mopidy/frontends/mpd/__init__.py @@ -13,11 +13,15 @@ class MpdFrontend(ThreadingActor, BaseFrontend): """ The MPD frontend. + **Dependencies:** + + - None + **Settings:** - :attr:`mopidy.settings.MPD_SERVER_HOSTNAME` - - :attr:`mopidy.settings.MPD_SERVER_PASSWORD` - :attr:`mopidy.settings.MPD_SERVER_PORT` + - :attr:`mopidy.settings.MPD_SERVER_PASSWORD` """ def __init__(self): diff --git a/mopidy/outputs/custom.py b/mopidy/outputs/custom.py index c5ca30bb..09239a44 100644 --- a/mopidy/outputs/custom.py +++ b/mopidy/outputs/custom.py @@ -21,6 +21,14 @@ class CustomOutput(BaseOutput): these cases setup :attr:`mopidy.settings.CUSTOM_OUTPUT` with a :command:`gst-launch` compatible string describing the target setup. + **Dependencies:** + + - None + + **Settings:** + + - :attr:`mopidy.settings.CUSTOM_OUTPUT` """ + def describe_bin(self): return settings.CUSTOM_OUTPUT diff --git a/mopidy/outputs/local.py b/mopidy/outputs/local.py index e004a076..8101e026 100644 --- a/mopidy/outputs/local.py +++ b/mopidy/outputs/local.py @@ -6,6 +6,14 @@ class LocalOutput(BaseOutput): This output will normally tell GStreamer to choose whatever it thinks is best for your system. In other words this is usually a sane choice. + + **Dependencies:** + + - None + + **Settings:** + + - None """ def describe_bin(self): diff --git a/mopidy/outputs/shoutcast.py b/mopidy/outputs/shoutcast.py index d2605514..ffe09aae 100644 --- a/mopidy/outputs/shoutcast.py +++ b/mopidy/outputs/shoutcast.py @@ -13,6 +13,19 @@ class ShoutcastOutput(BaseOutput): supports Shoutcast. The output supports setting for: server address, port, mount point, user, password and encoder to use. Please see :class:`mopidy.settings` for details about settings. + + **Dependencies:** + + - A SHOUTcast/Icecast server + + **Settings:** + + - :attr:`mopidy.settings.SHOUTCAST_OUTPUT_HOSTNAME` + - :attr:`mopidy.settings.SHOUTCAST_OUTPUT_PORT` + - :attr:`mopidy.settings.SHOUTCAST_OUTPUT_USERNAME` + - :attr:`mopidy.settings.SHOUTCAST_OUTPUT_PASSWORD` + - :attr:`mopidy.settings.SHOUTCAST_OUTPUT_MOUNT` + - :attr:`mopidy.settings.SHOUTCAST_OUTPUT_ENCODER` """ def describe_bin(self): From 9c7bf9fc73da3b44aabd92b133d8753a43302bbc Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 9 Jun 2011 18:55:50 +0200 Subject: [PATCH 36/65] docs: Add inheritance diagrams for outputs --- docs/modules/outputs.rst | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/modules/outputs.rst b/docs/modules/outputs.rst index 37ff0390..87d23dab 100644 --- a/docs/modules/outputs.rst +++ b/docs/modules/outputs.rst @@ -4,10 +4,11 @@ The following GStreamer audio outputs implements the :ref:`output-api`. -.. inheritance-diagram:: mopidy.outputs - +.. inheritance-diagram:: mopidy.outputs.custom .. autoclass:: mopidy.outputs.custom.CustomOutput +.. inheritance-diagram:: mopidy.outputs.local .. autoclass:: mopidy.outputs.local.LocalOutput +.. inheritance-diagram:: mopidy.outputs.shoutcast .. autoclass:: mopidy.outputs.shoutcast.ShoutcastOutput From 8d240ea686901e78fc58de10c0634b28ce079c07 Mon Sep 17 00:00:00 2001 From: Johannes Knutsen Date: Thu, 9 Jun 2011 19:47:41 +0200 Subject: [PATCH 37/65] Read missing local settings from stdin by default --- mopidy/utils/settings.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/mopidy/utils/settings.py b/mopidy/utils/settings.py index 01fee23d..9516b334 100644 --- a/mopidy/utils/settings.py +++ b/mopidy/utils/settings.py @@ -1,6 +1,7 @@ # Absolute import needed to import ~/.mopidy/settings.py and not ourselves from __future__ import absolute_import from copy import copy +import getpass import logging import os from pprint import pformat @@ -16,8 +17,23 @@ class SettingsProxy(object): self.default = self._get_settings_dict_from_module( default_settings_module) self.local = self._get_local_settings() + self._read_missing_settings_from_stdin(self.default, self.local) self.runtime = {} + def _read_missing_settings_from_stdin(self, default, local): + for setting, value in default.iteritems(): + if isinstance(value, basestring) and len(value) == 0: + local[setting] = self._read_from_stdin(setting + u': ') + + def _read_from_stdin(self, prompt): + if u'_PASSWORD' in prompt: + return (getpass.getpass(prompt) + .decode(getpass.sys.stdin.encoding, 'ignore')) + else: + sys.stdout.write(prompt) + return (sys.stdin.readline().strip() + .decode(sys.stdin.encoding, 'ignore')) + def _get_local_settings(self): dotdir = os.path.expanduser(u'~/.mopidy/') settings_file = os.path.join(dotdir, u'settings.py') From efa38d2449c6e390890ba8850fea21f147af3c89 Mon Sep 17 00:00:00 2001 From: Johannes Knutsen Date: Thu, 9 Jun 2011 20:03:02 +0200 Subject: [PATCH 38/65] Read interactive settings optional by adding --interactive option --- mopidy/core.py | 9 ++++++--- mopidy/utils/settings.py | 5 +++-- tests/help_test.py | 1 + 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/mopidy/core.py b/mopidy/core.py index ca5b92a1..e8857b94 100644 --- a/mopidy/core.py +++ b/mopidy/core.py @@ -30,7 +30,7 @@ logger = logging.getLogger('mopidy.core') def main(): options = parse_options() setup_logging(options.verbosity_level, options.save_debug_log) - setup_settings() + setup_settings(options.interactive) setup_gobject_loop() setup_gstreamer() setup_mixer() @@ -49,6 +49,9 @@ def parse_options(): parser.add_option('--help-gst', action='store_true', dest='help_gst', help='show GStreamer help options') + parser.add_option('-i', '--interactive', + action='store_true', dest='interactive', + help='ask interactively for required settings which is missing') parser.add_option('-q', '--quiet', action='store_const', const=0, dest='verbosity_level', help='less output (warning level)') @@ -63,11 +66,11 @@ def parse_options(): help='list current settings') return parser.parse_args(args=mopidy_args)[0] -def setup_settings(): +def setup_settings(interactive): get_or_create_folder('~/.mopidy/') get_or_create_file('~/.mopidy/settings.py') try: - settings.validate() + settings.validate(interactive) except SettingsError, e: logger.error(e.message) sys.exit(1) diff --git a/mopidy/utils/settings.py b/mopidy/utils/settings.py index 9516b334..a5b3bed2 100644 --- a/mopidy/utils/settings.py +++ b/mopidy/utils/settings.py @@ -17,7 +17,6 @@ class SettingsProxy(object): self.default = self._get_settings_dict_from_module( default_settings_module) self.local = self._get_local_settings() - self._read_missing_settings_from_stdin(self.default, self.local) self.runtime = {} def _read_missing_settings_from_stdin(self, default, local): @@ -79,7 +78,9 @@ class SettingsProxy(object): else: super(SettingsProxy, self).__setattr__(attr, value) - def validate(self): + def validate(self, interactive): + if interactive: + self._read_missing_settings_from_stdin(self.default, self.local) if self.get_errors(): logger.error(u'Settings validation errors: %s', indent(self.get_errors_as_string())) diff --git a/tests/help_test.py b/tests/help_test.py index dccccc9c..25f534c2 100644 --- a/tests/help_test.py +++ b/tests/help_test.py @@ -14,6 +14,7 @@ class HelpTest(unittest.TestCase): self.assert_('--version' in output) self.assert_('--help' in output) self.assert_('--help-gst' in output) + self.assert_('--interactive' in output) self.assert_('--quiet' in output) self.assert_('--verbose' in output) self.assert_('--save-debug-log' in output) From ded513faed372b9df6fad80b1482408b61b10132 Mon Sep 17 00:00:00 2001 From: Johannes Knutsen Date: Thu, 9 Jun 2011 20:04:05 +0200 Subject: [PATCH 39/65] Catch keyboard interrupts in all core setup methods --- mopidy/core.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/mopidy/core.py b/mopidy/core.py index e8857b94..98575478 100644 --- a/mopidy/core.py +++ b/mopidy/core.py @@ -28,15 +28,15 @@ from mopidy.utils.settings import list_settings_optparse_callback logger = logging.getLogger('mopidy.core') def main(): - options = parse_options() - setup_logging(options.verbosity_level, options.save_debug_log) - setup_settings(options.interactive) - setup_gobject_loop() - setup_gstreamer() - setup_mixer() - setup_backend() - setup_frontends() try: + options = parse_options() + setup_logging(options.verbosity_level, options.save_debug_log) + setup_settings(options.interactive) + setup_gobject_loop() + setup_gstreamer() + setup_mixer() + setup_backend() + setup_frontends() while ActorRegistry.get_all(): time.sleep(1) logger.info(u'No actors left. Exiting...') From c31db049791bf4fbffc4882d190ea4f9d613f1c4 Mon Sep 17 00:00:00 2001 From: Johannes Knutsen Date: Thu, 9 Jun 2011 20:08:50 +0200 Subject: [PATCH 40/65] Move private methods to be closer it's caller --- mopidy/utils/settings.py | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/mopidy/utils/settings.py b/mopidy/utils/settings.py index a5b3bed2..73268345 100644 --- a/mopidy/utils/settings.py +++ b/mopidy/utils/settings.py @@ -19,20 +19,6 @@ class SettingsProxy(object): self.local = self._get_local_settings() self.runtime = {} - def _read_missing_settings_from_stdin(self, default, local): - for setting, value in default.iteritems(): - if isinstance(value, basestring) and len(value) == 0: - local[setting] = self._read_from_stdin(setting + u': ') - - def _read_from_stdin(self, prompt): - if u'_PASSWORD' in prompt: - return (getpass.getpass(prompt) - .decode(getpass.sys.stdin.encoding, 'ignore')) - else: - sys.stdout.write(prompt) - return (sys.stdin.readline().strip() - .decode(sys.stdin.encoding, 'ignore')) - def _get_local_settings(self): dotdir = os.path.expanduser(u'~/.mopidy/') settings_file = os.path.join(dotdir, u'settings.py') @@ -86,6 +72,20 @@ class SettingsProxy(object): indent(self.get_errors_as_string())) raise SettingsError(u'Settings validation failed.') + def _read_missing_settings_from_stdin(self, default, local): + for setting, value in default.iteritems(): + if isinstance(value, basestring) and len(value) == 0: + local[setting] = self._read_from_stdin(setting + u': ') + + def _read_from_stdin(self, prompt): + if u'_PASSWORD' in prompt: + return (getpass.getpass(prompt) + .decode(getpass.sys.stdin.encoding, 'ignore')) + else: + sys.stdout.write(prompt) + return (sys.stdin.readline().strip() + .decode(sys.stdin.encoding, 'ignore')) + def get_errors(self): return validate_settings(self.default, self.local) From 33e70de66147d83702b7382a4a617a0e2e4a2eac Mon Sep 17 00:00:00 2001 From: Johannes Knutsen Date: Thu, 9 Jun 2011 20:28:38 +0200 Subject: [PATCH 41/65] Test interactive input --- tests/utils/settings_test.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/utils/settings_test.py b/tests/utils/settings_test.py index 748eae85..d1481ce5 100644 --- a/tests/utils/settings_test.py +++ b/tests/utils/settings_test.py @@ -149,6 +149,12 @@ class SettingsProxyTest(unittest.TestCase): actual = self.settings.TEST self.assertEqual(actual, './test') + def test_interactive_input_of_missing_defaults(self): + self.settings.default['TEST'] = '' + interactive_input = 'input' + self.settings._read_from_stdin = lambda _: interactive_input + self.settings.validate(interactive=True) + self.assertEqual(interactive_input, self.settings.TEST) class FormatSettingListTest(unittest.TestCase): def setUp(self): From 5e0a85c1b6195949c1cd9953db689d0a81a2326d Mon Sep 17 00:00:00 2001 From: Johannes Knutsen Date: Thu, 9 Jun 2011 20:38:52 +0200 Subject: [PATCH 42/65] Get encoding from sys module directly instead of getpass.sys --- mopidy/utils/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/utils/settings.py b/mopidy/utils/settings.py index 73268345..a10b3a78 100644 --- a/mopidy/utils/settings.py +++ b/mopidy/utils/settings.py @@ -80,7 +80,7 @@ class SettingsProxy(object): def _read_from_stdin(self, prompt): if u'_PASSWORD' in prompt: return (getpass.getpass(prompt) - .decode(getpass.sys.stdin.encoding, 'ignore')) + .decode(sys.stdin.encoding, 'ignore')) else: sys.stdout.write(prompt) return (sys.stdin.readline().strip() From 32915b6832f2eed418d370944ae99e041f75099b Mon Sep 17 00:00:00 2001 From: Johannes Knutsen Date: Thu, 9 Jun 2011 20:47:20 +0200 Subject: [PATCH 43/65] Add --interactive feature to changelog --- docs/changes.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/changes.rst b/docs/changes.rst index 4b6f74ca..2c240bfa 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -70,6 +70,9 @@ Please note that 0.5.0 requires some updated dependencies, as listed under - Improve :option:`--list-settings` output. (Fixes: :issue:`91`) + - Added :option:`--interactive` for reading missing local settings from + ``stdin``. (Fixes: :issue:`96`) + - Tag cache generator: - Made it possible to abort :command:`mopidy-scan` with CTRL+C. From 3cdf1aa35deec2623d90929789efbc72b583ae97 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 10 Jun 2011 00:33:14 +0200 Subject: [PATCH 44/65] Add util function for stopping all actors which also tries to stop actors that was started after the function was called --- mopidy/core.py | 4 ++-- mopidy/utils/process.py | 7 +++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/mopidy/core.py b/mopidy/core.py index 98575478..6ed9a601 100644 --- a/mopidy/core.py +++ b/mopidy/core.py @@ -22,7 +22,7 @@ from mopidy.gstreamer import GStreamer from mopidy.utils import get_class from mopidy.utils.log import setup_logging from mopidy.utils.path import get_or_create_folder, get_or_create_file -from mopidy.utils.process import GObjectEventThread +from mopidy.utils.process import GObjectEventThread, stop_all_actors from mopidy.utils.settings import list_settings_optparse_callback logger = logging.getLogger('mopidy.core') @@ -42,7 +42,7 @@ def main(): logger.info(u'No actors left. Exiting...') except KeyboardInterrupt: logger.info(u'User interrupt. Exiting...') - ActorRegistry.stop_all() + stop_all_actors() def parse_options(): parser = optparse.OptionParser(version=u'Mopidy %s' % get_version()) diff --git a/mopidy/utils/process.py b/mopidy/utils/process.py index 7f6cf664..47ae6856 100644 --- a/mopidy/utils/process.py +++ b/mopidy/utils/process.py @@ -5,11 +5,18 @@ import gobject gobject.threads_init() from pykka import ActorDeadError +from pykka.registry import ActorRegistry from mopidy import SettingsError logger = logging.getLogger('mopidy.utils.process') +def stop_all_actors(): + num_actors = len(ActorRegistry.get_all()) + while num_actors: + logger.debug(u'Stopping %d actor(s)...', num_actors) + ActorRegistry.stop_all() + num_actors = len(ActorRegistry.get_all()) class BaseThread(threading.Thread): def __init__(self): From f8965e053b72c7817b46a10986d733cf015a44b8 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 10 Jun 2011 00:34:06 +0200 Subject: [PATCH 45/65] Register SIGTERM handler --- mopidy/core.py | 5 ++++- mopidy/utils/process.py | 8 ++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/mopidy/core.py b/mopidy/core.py index 6ed9a601..b89a5456 100644 --- a/mopidy/core.py +++ b/mopidy/core.py @@ -1,5 +1,6 @@ import logging import optparse +import signal import sys import time @@ -22,12 +23,14 @@ from mopidy.gstreamer import GStreamer from mopidy.utils import get_class from mopidy.utils.log import setup_logging from mopidy.utils.path import get_or_create_folder, get_or_create_file -from mopidy.utils.process import GObjectEventThread, stop_all_actors +from mopidy.utils.process import (GObjectEventThread, exit_handler, + stop_all_actors) from mopidy.utils.settings import list_settings_optparse_callback logger = logging.getLogger('mopidy.core') def main(): + signal.signal(signal.SIGTERM, exit_handler) try: options = parse_options() setup_logging(options.verbosity_level, options.save_debug_log) diff --git a/mopidy/utils/process.py b/mopidy/utils/process.py index 47ae6856..5b09148d 100644 --- a/mopidy/utils/process.py +++ b/mopidy/utils/process.py @@ -1,4 +1,5 @@ import logging +import signal import threading import gobject @@ -11,6 +12,13 @@ from mopidy import SettingsError logger = logging.getLogger('mopidy.utils.process') +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() + def stop_all_actors(): num_actors = len(ActorRegistry.get_all()) while num_actors: From 05c533014e17cf73e2a1121e769aca83edd04cb0 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 10 Jun 2011 09:54:04 +0200 Subject: [PATCH 46/65] Ensure tests are not affected by local settings --- tests/utils/settings_test.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/utils/settings_test.py b/tests/utils/settings_test.py index d1481ce5..dd0fe89b 100644 --- a/tests/utils/settings_test.py +++ b/tests/utils/settings_test.py @@ -71,6 +71,7 @@ class ValidateSettingsTest(unittest.TestCase): class SettingsProxyTest(unittest.TestCase): def setUp(self): self.settings = SettingsProxy(default_settings_module) + self.settings.local.clear() def test_set_and_get_attr(self): self.settings.TEST = 'test' @@ -156,6 +157,7 @@ class SettingsProxyTest(unittest.TestCase): self.settings.validate(interactive=True) self.assertEqual(interactive_input, self.settings.TEST) + class FormatSettingListTest(unittest.TestCase): def setUp(self): self.settings = SettingsProxy(default_settings_module) From d46c22dca380f301115d053e9ba8506f54e522f2 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 10 Jun 2011 13:46:50 +0200 Subject: [PATCH 47/65] Make Spotify backend fail early if settings is incomplete --- mopidy/backends/spotify/__init__.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/mopidy/backends/spotify/__init__.py b/mopidy/backends/spotify/__init__.py index 87997059..774273e3 100644 --- a/mopidy/backends/spotify/__init__.py +++ b/mopidy/backends/spotify/__init__.py @@ -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 From ce8cc55f7932092b0153359d5effd0ab0e8c57f3 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 10 Jun 2011 13:48:50 +0200 Subject: [PATCH 48/65] Log GStreamer output addition at debug level --- mopidy/gstreamer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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): """ From fbc47a041a75d10cebd35576abe98a10005e3054 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 10 Jun 2011 14:15:17 +0200 Subject: [PATCH 49/65] Add more debug logging to stop_all_actors --- mopidy/utils/process.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/mopidy/utils/process.py b/mopidy/utils/process.py index 5b09148d..2e18280e 100644 --- a/mopidy/utils/process.py +++ b/mopidy/utils/process.py @@ -22,9 +22,13 @@ def exit_handler(signum, frame): 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): From 2453e6826f93301a0003b869e46d4cb41aeb81b7 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 10 Jun 2011 14:17:29 +0200 Subject: [PATCH 50/65] Add exit_process() function for shutting down Mopidy instead of ActorRegistry.stop_all() --- mopidy/core.py | 5 ++--- mopidy/utils/process.py | 10 ++++++++-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/mopidy/core.py b/mopidy/core.py index b89a5456..8138f198 100644 --- a/mopidy/core.py +++ b/mopidy/core.py @@ -40,11 +40,10 @@ 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 KeyboardInterrupt: - logger.info(u'User interrupt. Exiting...') + logger.info(u'Interrupted. Exiting...') stop_all_actors() def parse_options(): diff --git a/mopidy/utils/process.py b/mopidy/utils/process.py index 2e18280e..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,12 +13,17 @@ 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()) From 51e43487c37faafe236ac67234d99cc0591d0862 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 10 Jun 2011 14:18:09 +0200 Subject: [PATCH 51/65] Log traceback for exceptions popping up to main() --- mopidy/core.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/mopidy/core.py b/mopidy/core.py index 8138f198..8c6e9833 100644 --- a/mopidy/core.py +++ b/mopidy/core.py @@ -44,6 +44,9 @@ def main(): time.sleep(1) except KeyboardInterrupt: logger.info(u'Interrupted. Exiting...') + except Exception as e: + logger.exception(e) + finally: stop_all_actors() def parse_options(): From 8e983c337f4569547c70da9cb7a5f82554d693d9 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 10 Jun 2011 14:18:38 +0200 Subject: [PATCH 52/65] Only log the error message for SettingsError --- mopidy/core.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/mopidy/core.py b/mopidy/core.py index 8c6e9833..65472a29 100644 --- a/mopidy/core.py +++ b/mopidy/core.py @@ -42,6 +42,8 @@ def main(): setup_frontends() while True: time.sleep(1) + except SettingsError as e: + logger.error(e.message) except KeyboardInterrupt: logger.info(u'Interrupted. Exiting...') except Exception as e: From c0a39afa31d5e060d9fb2862f0d6a3a23317cd53 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 10 Jun 2011 14:30:32 +0200 Subject: [PATCH 53/65] Do not interactively ask for settings when they are already set locally --- mopidy/utils/settings.py | 8 ++++---- tests/utils/settings_test.py | 7 +++++++ 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/mopidy/utils/settings.py b/mopidy/utils/settings.py index a10b3a78..500477e2 100644 --- a/mopidy/utils/settings.py +++ b/mopidy/utils/settings.py @@ -66,16 +66,16 @@ class SettingsProxy(object): def validate(self, interactive): if interactive: - self._read_missing_settings_from_stdin(self.default, self.local) + self._read_missing_settings_from_stdin(self.current, self.runtime) if self.get_errors(): logger.error(u'Settings validation errors: %s', indent(self.get_errors_as_string())) raise SettingsError(u'Settings validation failed.') - def _read_missing_settings_from_stdin(self, default, local): - for setting, value in default.iteritems(): + def _read_missing_settings_from_stdin(self, current, runtime): + for setting, value in current.iteritems(): if isinstance(value, basestring) and len(value) == 0: - local[setting] = self._read_from_stdin(setting + u': ') + runtime[setting] = self._read_from_stdin(setting + u': ') def _read_from_stdin(self, prompt): if u'_PASSWORD' in prompt: diff --git a/tests/utils/settings_test.py b/tests/utils/settings_test.py index dd0fe89b..973c2280 100644 --- a/tests/utils/settings_test.py +++ b/tests/utils/settings_test.py @@ -157,6 +157,13 @@ class SettingsProxyTest(unittest.TestCase): self.settings.validate(interactive=True) self.assertEqual(interactive_input, self.settings.TEST) + def test_interactive_input_not_needed_when_setting_is_set_locally(self): + self.settings.default['TEST'] = '' + self.settings.local['TEST'] = 'test' + self.settings._read_from_stdin = lambda _: self.fail( + 'Should not read from stdin') + self.settings.validate(interactive=True) + class FormatSettingListTest(unittest.TestCase): def setUp(self): From f311dd1e77b0aa16433effc1ff2f3f7046117a37 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 10 Jun 2011 15:14:34 +0200 Subject: [PATCH 54/65] Do not refresh playlists on every metadata update, but just when the playlist container is loaded --- mopidy/backends/spotify/session_manager.py | 1 - 1 file changed, 1 deletion(-) diff --git a/mopidy/backends/spotify/session_manager.py b/mopidy/backends/spotify/session_manager.py index 4b6abe85..04398751 100644 --- a/mopidy/backends/spotify/session_manager.py +++ b/mopidy/backends/spotify/session_manager.py @@ -74,7 +74,6 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager): def metadata_updated(self, session): """Callback used by pyspotify""" logger.debug(u'Metadata updated') - self.refresh_stored_playlists() def connection_error(self, session, error): """Callback used by pyspotify""" From 98d410545fd6d7ffff40f88198afde57b6729c6e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 12 Jun 2011 01:39:57 +0200 Subject: [PATCH 55/65] Add MANIFEST.in to MANIFEST.in --- MANIFEST.in | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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 * From 8107c77bcf805ff3ec7e3a64068d13d5bea6287b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 12 Jun 2011 02:25:51 +0200 Subject: [PATCH 56/65] Require pyspotify 1.3 --- docs/changes.rst | 2 +- mopidy/backends/spotify/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 2c240bfa..2a36fbbc 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -18,7 +18,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/`. diff --git a/mopidy/backends/spotify/__init__.py b/mopidy/backends/spotify/__init__.py index 774273e3..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:** From 4090c14ae18246d2b14fcd80a31ffd67c563c503 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 12 Jun 2011 11:37:15 +0200 Subject: [PATCH 57/65] pyspotify 1.3 adds autolinking of tracks. Fixes #34. --- docs/changes.rst | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 2a36fbbc..c93e0ee8 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -45,8 +45,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: From a6c85710050556dd6f3a2893d0a9a42230c0e81c Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 12 Jun 2011 14:19:42 +0200 Subject: [PATCH 58/65] Fix error/reconnect during retrieval of command list. MpdDispatcher returns [] instead of None after the filter refactoring --- mopidy/frontends/mpd/session.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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)) From cd5886cc7a3f042365077ac0d218be3dcf688fa1 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 13 Jun 2011 16:50:17 +0200 Subject: [PATCH 59/65] Rename Spotify thread to simply 'SpotifyThread' --- mopidy/backends/spotify/session_manager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/backends/spotify/session_manager.py b/mopidy/backends/spotify/session_manager.py index 04398751..ba7782b2 100644 --- a/mopidy/backends/spotify/session_manager.py +++ b/mopidy/backends/spotify/session_manager.py @@ -29,7 +29,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 From 174e0082689c8461483f4ebfeabff97b1c78a3b1 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 13 Jun 2011 16:51:02 +0200 Subject: [PATCH 60/65] Change wording of Spotify's 'no error' error so it makes sense without a preceeding error message --- mopidy/backends/spotify/session_manager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/backends/spotify/session_manager.py b/mopidy/backends/spotify/session_manager.py index ba7782b2..552fa2a2 100644 --- a/mopidy/backends/spotify/session_manager.py +++ b/mopidy/backends/spotify/session_manager.py @@ -78,7 +78,7 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager): 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() From 6f4117729ab83e7a254c3515e0d9b7f5dd98a2fe Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 14 Jun 2011 00:00:38 +0200 Subject: [PATCH 61/65] Formatting --- mopidy/backends/spotify/container_manager.py | 4 ++-- mopidy/backends/spotify/session_manager.py | 8 ++++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/mopidy/backends/spotify/container_manager.py b/mopidy/backends/spotify/container_manager.py index 29360d79..9ae524e1 100644 --- a/mopidy/backends/spotify/container_manager.py +++ b/mopidy/backends/spotify/container_manager.py @@ -1,11 +1,11 @@ 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 diff --git a/mopidy/backends/spotify/session_manager.py b/mopidy/backends/spotify/session_manager.py index 552fa2a2..d581c7c1 100644 --- a/mopidy/backends/spotify/session_manager.py +++ b/mopidy/backends/spotify/session_manager.py @@ -45,7 +45,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 +58,17 @@ 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.container_manager.watch(self.session.playlist_container()) + self.connected.set() def logged_out(self, session): From 371fb9b90d452f8893446e4968659d2e1ff58676 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 14 Jun 2011 00:03:23 +0200 Subject: [PATCH 62/65] Add missing container callbacks with debug log statements --- mopidy/backends/spotify/container_manager.py | 28 ++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/mopidy/backends/spotify/container_manager.py b/mopidy/backends/spotify/container_manager.py index 9ae524e1..bd9035aa 100644 --- a/mopidy/backends/spotify/container_manager.py +++ b/mopidy/backends/spotify/container_manager.py @@ -11,6 +11,30 @@ class SpotifyContainerManager(PyspotifyContainerManager): 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() + + def playlist_added(self, container, playlist, position, userdata): + """Callback used by pyspotify""" + logger.debug(u'Callback called: playlist "%s" added at position %d', + playlist.name(), 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. From 82ba04408c606c915f8df5795ee7cc3f4451adcb Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 14 Jun 2011 00:04:08 +0200 Subject: [PATCH 63/65] Add playlist callbacks with debug log statements --- mopidy/backends/spotify/container_manager.py | 5 ++ mopidy/backends/spotify/playlist_manager.py | 94 ++++++++++++++++++++ mopidy/backends/spotify/session_manager.py | 8 +- 3 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 mopidy/backends/spotify/playlist_manager.py diff --git a/mopidy/backends/spotify/container_manager.py b/mopidy/backends/spotify/container_manager.py index bd9035aa..5166cacb 100644 --- a/mopidy/backends/spotify/container_manager.py +++ b/mopidy/backends/spotify/container_manager.py @@ -15,6 +15,11 @@ class SpotifyContainerManager(PyspotifyContainerManager): 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 "%s" added at position %d', diff --git a/mopidy/backends/spotify/playlist_manager.py b/mopidy/backends/spotify/playlist_manager.py new file mode 100644 index 00000000..5f4f1fd7 --- /dev/null +++ b/mopidy/backends/spotify/playlist_manager.py @@ -0,0 +1,94 @@ +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()) + # TODO Partially update 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()) + # TODO Partially update 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()) + # TODO Partially update stored playlists? + + def playlist_renamed(self, playlist, userdata): + """Callback used by pyspotify""" + logger.debug(u'Callback called: Playlist renamed to "%s"', + playlist.name()) + # TODO Partially update 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()) + # TODO Update stored playlists? + + 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 d581c7c1..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') @@ -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() @@ -67,6 +69,8 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager): 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() @@ -77,7 +81,7 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager): def metadata_updated(self, session): """Callback used by pyspotify""" - logger.debug(u'Metadata updated') + logger.debug(u'Callback called: Metadata updated') def connection_error(self, session, error): """Callback used by pyspotify""" From 3c68c8f9ead9a8c04e7cefa022c2ce453efa2b26 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 14 Jun 2011 00:19:59 +0200 Subject: [PATCH 64/65] The playlist name is not available when playlist_added is called --- mopidy/backends/spotify/container_manager.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/mopidy/backends/spotify/container_manager.py b/mopidy/backends/spotify/container_manager.py index 5166cacb..520cfb68 100644 --- a/mopidy/backends/spotify/container_manager.py +++ b/mopidy/backends/spotify/container_manager.py @@ -18,12 +18,13 @@ class SpotifyContainerManager(PyspotifyContainerManager): 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)) + 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 "%s" added at position %d', - playlist.name(), position) + 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. From 4516767372f9026d84c1a9b8a562a5d0f9f61f47 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 14 Jun 2011 00:24:54 +0200 Subject: [PATCH 65/65] Refresh stored playlists when tracks are added to, moved in, or removed from playlists --- mopidy/backends/spotify/playlist_manager.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/mopidy/backends/spotify/playlist_manager.py b/mopidy/backends/spotify/playlist_manager.py index 5f4f1fd7..f72ac4ca 100644 --- a/mopidy/backends/spotify/playlist_manager.py +++ b/mopidy/backends/spotify/playlist_manager.py @@ -15,26 +15,26 @@ class SpotifyPlaylistManager(PyspotifyPlaylistManager): logger.debug(u'Callback called: ' u'%d track(s) added to position %d in playlist "%s"', len(tracks), position, playlist.name()) - # TODO Partially update stored playlists? + 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()) - # TODO Partially update stored playlists? + 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()) - # TODO Partially update stored playlists? + 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()) - # TODO Partially update stored playlists? + self.session_manager.refresh_stored_playlists() def playlist_state_changed(self, playlist, userdata): """Callback used by pyspotify""" @@ -54,7 +54,6 @@ class SpotifyPlaylistManager(PyspotifyPlaylistManager): """Callback used by pyspotify""" logger.debug(u'Callback called: Metadata updated for playlist "%s"', playlist.name()) - # TODO Update stored playlists? def track_created_changed(self, playlist, position, user, when, userdata): """Callback used by pyspotify"""