From 9f77f801ba2d7cdd71b77ac81999f174f1d5aba7 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 23 Apr 2011 21:45:58 +0200 Subject: [PATCH 001/218] Swith to tee based pipeline to allow for multiple outputs --- mopidy/outputs/gstreamer.py | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/mopidy/outputs/gstreamer.py b/mopidy/outputs/gstreamer.py index a6d1e9dd..0a4c0d85 100644 --- a/mopidy/outputs/gstreamer.py +++ b/mopidy/outputs/gstreamer.py @@ -35,21 +35,28 @@ class GStreamerOutput(ThreadingActor, BaseOutput): :class:`mopidy.utils.process.GObjectEventThread` to be running. This is not enforced by :class:`GStreamerOutput` itself. """ - - logger.debug(u'Setting up GStreamer pipeline') - - self.gst_pipeline = gst.parse_launch(' ! '.join([ + base_pipeline = ' ! '.join([ 'audioconvert name=convert', 'volume name=volume', - settings.GSTREAMER_AUDIO_SINK, - ])) + 'taginject name=tag', + 'tee name=tee', + ]) - pad = self.gst_pipeline.get_by_name('convert').get_pad('sink') + logger.debug(u'Setting up base GStreamer pipeline: %s', base_pipeline) + + self.gst_pipeline = gst.parse_launch(base_pipeline) + + tee = self.gst_pipeline.get_by_name('tee') + convert = self.gst_pipeline.get_by_name('convert') uridecodebin = gst.element_factory_make('uridecodebin', 'uri') uridecodebin.connect('pad-added', self._process_new_pad, pad) self.gst_pipeline.add(uridecodebin) + output_bin = gst.parse_bin_from_description('queue ! %s' % + settings.GSTREAMER_AUDIO_SINK, True) + gst.element_link_many(tee, output_bin) + # Setup bus and message processor gst_bus = self.gst_pipeline.get_bus() gst_bus.add_signal_watch() From ecdf689301895e82a786ef6462723a5e809af46b Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 23 Apr 2011 23:43:29 +0200 Subject: [PATCH 002/218] Factor out method to add output bin --- mopidy/outputs/gstreamer.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/mopidy/outputs/gstreamer.py b/mopidy/outputs/gstreamer.py index 0a4c0d85..7f1d35eb 100644 --- a/mopidy/outputs/gstreamer.py +++ b/mopidy/outputs/gstreamer.py @@ -46,22 +46,28 @@ class GStreamerOutput(ThreadingActor, BaseOutput): self.gst_pipeline = gst.parse_launch(base_pipeline) - tee = self.gst_pipeline.get_by_name('tee') - convert = self.gst_pipeline.get_by_name('convert') + self.gst_tee = self.gst_pipeline.get_by_name('tee') + self.gst_convert = self.gst_pipeline.get_by_name('convert') uridecodebin = gst.element_factory_make('uridecodebin', 'uri') uridecodebin.connect('pad-added', self._process_new_pad, pad) self.gst_pipeline.add(uridecodebin) - output_bin = gst.parse_bin_from_description('queue ! %s' % - settings.GSTREAMER_AUDIO_SINK, True) - gst.element_link_many(tee, output_bin) + self._add_output(settings.GSTREAMER_AUDIO_SINK) # Setup bus and message processor gst_bus = self.gst_pipeline.get_bus() gst_bus.add_signal_watch() gst_bus.connect('message', self._process_gstreamer_message) + def _add_output(self, description): + bin = 'queue ! %s' % description + logger.debug('Adding output bin to tee: %s', bin) + output = gst.parse_bin_from_description(bin, True) + self.gst_pipeline.add(output) + output.sync_state_with_parent() + gst.element_link_many(self.gst_tee, output) + def _process_new_pad(self, source, pad, target_pad): pad.link(target_pad) From a81113e1a7a3117ef25b77ee2c17ab030c7e833d Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 24 Apr 2011 02:15:26 +0200 Subject: [PATCH 003/218] Add _build_shoutcast_description to construct shoutcast bin --- mopidy/outputs/gstreamer.py | 23 ++++++++++++++ mopidy/settings.py | 45 ++++++++++++++++++++++++++ tests/outputs/gstreamer_test.py | 56 +++++++++++++++++++++++++++++++++ 3 files changed, 124 insertions(+) diff --git a/mopidy/outputs/gstreamer.py b/mopidy/outputs/gstreamer.py index 7f1d35eb..cdf3829e 100644 --- a/mopidy/outputs/gstreamer.py +++ b/mopidy/outputs/gstreamer.py @@ -68,6 +68,29 @@ class GStreamerOutput(ThreadingActor, BaseOutput): output.sync_state_with_parent() gst.element_link_many(self.gst_tee, output) + def _build_shoutcast_description(self): + if settings.SHOUTCAST_OVERRIDE: + return settings.SHOUTCAST_OVERRIDE + + if not settings.SHOUTCAST_SERVER: + return None + + description = ['%s ! shout2send' % settings.SHOUTCAST_ENCODER] + options = { + u'ip': settings.SHOUTCAST_SERVER, + u'mount': settings.SHOUTCAST_MOUNT, + u'port': settings.SHOUTCAST_PORT, + u'username': settings.SHOUTCAST_USER, + u'password': settings.SHOUTCAST_PASSWORD, + } + + for key, value in sorted(options.items()): + if value: + description.append('%s="%s"' % (key, value)) + + return u' '.join(description) + + def _process_new_pad(self, source, pad, target_pad): pad.link(target_pad) diff --git a/mopidy/settings.py b/mopidy/settings.py index 6e33ffaa..cd8f445e 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -174,6 +174,51 @@ MPD_SERVER_PASSWORD = None #: Default: 6600 MPD_SERVER_PORT = 6600 +#: Servar that runs Shoutcast server to send stream to. +#: +#: Default: :class:`None`, disables shoutcast if set to :class:`None` +SHOUTCAST_SERVER = None + +#: User to authenticate as against Shoutcast server. +#: +#: Default: 'source' +SHOUTCAST_USER = u'source' + +#: Password to authenticate with against Shoutcast server. +#: +#: Default: 'hackme' +SHOUTCAST_PASSWORD = u'hackme' + +#: Port to use for streaming to Shoutcast server. +#: +#: Default: 8000 +SHOUTCAST_PORT = 8000 + +#: Mountpoint to use for the stream on the Shoutcast server. +#: +#: Default: /stream +SHOUTCAST_MOUNT = u'/stream' + +#: Encoder to use to process audio data before streaming. +#: +#: Default: vorbisenc ! oggmux +SHOUTCAST_ENCODER = u'vorbisenc ! oggmux' + +#: Overrides to allow advanced setup of shoutcast. Using this settings implies +#: that all other SHOUTCAST_* settings will be ignored. +#: +#: Examples: +#: +#: ``vorbisenc ! oggmux ! shout2send mount=/stream port=8000`` +#: Encode with vorbis and use ogg mux. +#: ``lame bitrate=320 ! shout2send mount=/stream port=8000`` +#: Encode with lame to bitrate=320. +#: +#: For all options see gst-inspect-0.10 lame, vorbisenc and shout2send. +#: +#: Default: :class:`None` +SHOUTCAST_OVERRIDE = None + #: Path to the Spotify cache. #: #: Used by :mod:`mopidy.backends.spotify`. diff --git a/tests/outputs/gstreamer_test.py b/tests/outputs/gstreamer_test.py index 31a16756..b39e5583 100644 --- a/tests/outputs/gstreamer_test.py +++ b/tests/outputs/gstreamer_test.py @@ -60,3 +60,59 @@ class GStreamerOutputTest(unittest.TestCase): @SkipTest def test_set_position(self): pass # TODO + + def test_build_shoutcast_description_without_server(self): + self.assertEqual(None, self.output._build_shoutcast_description()) + + def test_build_shoutcast_description_with_server(self): + settings.SHOUTCAST_SERVER = '127.0.0.1' + + expected = u'%s ! ' % settings.SHOUTCAST_ENCODER + \ + u'shout2send ip="127.0.0.1" mount="/stream" ' \ + u'password="hackme" port="8000" username="source"' + result = self.output._build_shoutcast_description() + self.assertEqual(expected, result) + + def test_build_shoutcast_description_with_mount(self): + settings.SHOUTCAST_SERVER = '127.0.0.1' + settings.SHOUTCAST_MOUNT = '/stream.mp3' + + expected = u'%s ! ' % settings.SHOUTCAST_ENCODER + \ + u'shout2send ip="127.0.0.1" mount="/stream.mp3" ' \ + u'password="hackme" port="8000" username="source"' + result = self.output._build_shoutcast_description() + self.assertEqual(expected, result) + + def test_build_shoutcast_description_with_user_and_passwod(self): + settings.SHOUTCAST_SERVER = '127.0.0.1' + settings.SHOUTCAST_USER = 'john' + settings.SHOUTCAST_PASSWORD = 'doe' + + expected = u'%s ! ' % settings.SHOUTCAST_ENCODER + \ + u'shout2send ip="127.0.0.1" mount="/stream" ' \ + u'password="doe" port="8000" username="john"' + result = self.output._build_shoutcast_description() + self.assertEqual(expected, result) + + def test_build_shoutcast_description_unset_user_and_pass(self): + settings.SHOUTCAST_SERVER = '127.0.0.1' + settings.SHOUTCAST_USER = None + settings.SHOUTCAST_PASSWORD = None + + expected = u'%s ! shout2send ' % settings.SHOUTCAST_ENCODER + \ + u'ip="127.0.0.1" mount="/stream" port="8000"' + result = self.output._build_shoutcast_description() + self.assertEqual(expected, result) + + def test_build_shoutcast_description_with_override(self): + settings.SHOUTCAST_OVERRIDE = 'foobar' + + result = self.output._build_shoutcast_description() + self.assertEqual('foobar', result) + + def test_build_shoutcast_description_with_override_and_server(self): + settings.SHOUTCAST_OVERRIDE = 'foobar' + settings.SHOUTCAST_SERVER = '127.0.0.1' + + result = self.output._build_shoutcast_description() + self.assertEqual('foobar', result) From 700792f4a0372495957325392fd87f93d236168b Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 24 Apr 2011 02:55:14 +0200 Subject: [PATCH 004/218] Add basic streaming support --- mopidy/outputs/gstreamer.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/mopidy/outputs/gstreamer.py b/mopidy/outputs/gstreamer.py index cdf3829e..984efef1 100644 --- a/mopidy/outputs/gstreamer.py +++ b/mopidy/outputs/gstreamer.py @@ -55,6 +55,10 @@ class GStreamerOutput(ThreadingActor, BaseOutput): self._add_output(settings.GSTREAMER_AUDIO_SINK) + shoutcast_bin = self._build_shoutcast_description() + if shoutcast_bin: + self._add_output(shoutcast_bin) + # Setup bus and message processor gst_bus = self.gst_pipeline.get_bus() gst_bus.add_signal_watch() From b58436aaf3b09a56bfe17df171a579188d98b8c4 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 24 Apr 2011 03:03:43 +0200 Subject: [PATCH 005/218] Use audioconvert to ensure that volume element is handeled --- mopidy/outputs/gstreamer.py | 2 +- tests/outputs/gstreamer_test.py | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/mopidy/outputs/gstreamer.py b/mopidy/outputs/gstreamer.py index 984efef1..9430f47c 100644 --- a/mopidy/outputs/gstreamer.py +++ b/mopidy/outputs/gstreamer.py @@ -79,7 +79,7 @@ class GStreamerOutput(ThreadingActor, BaseOutput): if not settings.SHOUTCAST_SERVER: return None - description = ['%s ! shout2send' % settings.SHOUTCAST_ENCODER] + description = ['audioconvert ! %s ! shout2send' % settings.SHOUTCAST_ENCODER] options = { u'ip': settings.SHOUTCAST_SERVER, u'mount': settings.SHOUTCAST_MOUNT, diff --git a/tests/outputs/gstreamer_test.py b/tests/outputs/gstreamer_test.py index b39e5583..14493665 100644 --- a/tests/outputs/gstreamer_test.py +++ b/tests/outputs/gstreamer_test.py @@ -67,7 +67,7 @@ class GStreamerOutputTest(unittest.TestCase): def test_build_shoutcast_description_with_server(self): settings.SHOUTCAST_SERVER = '127.0.0.1' - expected = u'%s ! ' % settings.SHOUTCAST_ENCODER + \ + expected = u'audioconvert ! %s ! ' % settings.SHOUTCAST_ENCODER + \ u'shout2send ip="127.0.0.1" mount="/stream" ' \ u'password="hackme" port="8000" username="source"' result = self.output._build_shoutcast_description() @@ -77,7 +77,7 @@ class GStreamerOutputTest(unittest.TestCase): settings.SHOUTCAST_SERVER = '127.0.0.1' settings.SHOUTCAST_MOUNT = '/stream.mp3' - expected = u'%s ! ' % settings.SHOUTCAST_ENCODER + \ + expected = u'audioconvert ! %s ! ' % settings.SHOUTCAST_ENCODER + \ u'shout2send ip="127.0.0.1" mount="/stream.mp3" ' \ u'password="hackme" port="8000" username="source"' result = self.output._build_shoutcast_description() @@ -88,7 +88,7 @@ class GStreamerOutputTest(unittest.TestCase): settings.SHOUTCAST_USER = 'john' settings.SHOUTCAST_PASSWORD = 'doe' - expected = u'%s ! ' % settings.SHOUTCAST_ENCODER + \ + expected = u'audioconvert ! %s ! ' % settings.SHOUTCAST_ENCODER + \ u'shout2send ip="127.0.0.1" mount="/stream" ' \ u'password="doe" port="8000" username="john"' result = self.output._build_shoutcast_description() @@ -99,7 +99,7 @@ class GStreamerOutputTest(unittest.TestCase): settings.SHOUTCAST_USER = None settings.SHOUTCAST_PASSWORD = None - expected = u'%s ! shout2send ' % settings.SHOUTCAST_ENCODER + \ + expected = u'audioconvert ! %s ! shout2send ' % settings.SHOUTCAST_ENCODER + \ u'ip="127.0.0.1" mount="/stream" port="8000"' result = self.output._build_shoutcast_description() self.assertEqual(expected, result) From 3a44f130aa264aa570afbd161394b480beacc674 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 24 Apr 2011 03:07:14 +0200 Subject: [PATCH 006/218] Refactor shoutcast tests --- tests/outputs/gstreamer_test.py | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/tests/outputs/gstreamer_test.py b/tests/outputs/gstreamer_test.py index 14493665..9f380815 100644 --- a/tests/outputs/gstreamer_test.py +++ b/tests/outputs/gstreamer_test.py @@ -77,32 +77,23 @@ class GStreamerOutputTest(unittest.TestCase): settings.SHOUTCAST_SERVER = '127.0.0.1' settings.SHOUTCAST_MOUNT = '/stream.mp3' - expected = u'audioconvert ! %s ! ' % settings.SHOUTCAST_ENCODER + \ - u'shout2send ip="127.0.0.1" mount="/stream.mp3" ' \ - u'password="hackme" port="8000" username="source"' - result = self.output._build_shoutcast_description() - self.assertEqual(expected, result) + self.check_shoutcast_options(u'ip="127.0.0.1" mount="/stream.mp3" ' + u'password="hackme" port="8000" username="source"') def test_build_shoutcast_description_with_user_and_passwod(self): settings.SHOUTCAST_SERVER = '127.0.0.1' settings.SHOUTCAST_USER = 'john' settings.SHOUTCAST_PASSWORD = 'doe' - expected = u'audioconvert ! %s ! ' % settings.SHOUTCAST_ENCODER + \ - u'shout2send ip="127.0.0.1" mount="/stream" ' \ - u'password="doe" port="8000" username="john"' - result = self.output._build_shoutcast_description() - self.assertEqual(expected, result) + self.check_shoutcast_options('ip="127.0.0.1" mount="/stream" ' + u'password="doe" port="8000" username="john"') def test_build_shoutcast_description_unset_user_and_pass(self): settings.SHOUTCAST_SERVER = '127.0.0.1' settings.SHOUTCAST_USER = None settings.SHOUTCAST_PASSWORD = None - expected = u'audioconvert ! %s ! shout2send ' % settings.SHOUTCAST_ENCODER + \ - u'ip="127.0.0.1" mount="/stream" port="8000"' - result = self.output._build_shoutcast_description() - self.assertEqual(expected, result) + self.check_shoutcast_options(u'ip="127.0.0.1" mount="/stream" port="8000"') def test_build_shoutcast_description_with_override(self): settings.SHOUTCAST_OVERRIDE = 'foobar' @@ -116,3 +107,10 @@ class GStreamerOutputTest(unittest.TestCase): result = self.output._build_shoutcast_description() self.assertEqual('foobar', result) + + def check_shoutcast_options(self, options): + expected = u'audioconvert ! %s ! shout2send ' % settings.SHOUTCAST_ENCODER + expected += options + + result = self.output._build_shoutcast_description() + self.assertEqual(expected, result) From 971132d539f017fe82e257ebdcb190ec49b2b800 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 24 Apr 2011 03:11:55 +0200 Subject: [PATCH 007/218] Support just local or shoutcast audio output --- mopidy/outputs/gstreamer.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/mopidy/outputs/gstreamer.py b/mopidy/outputs/gstreamer.py index 9430f47c..e4563744 100644 --- a/mopidy/outputs/gstreamer.py +++ b/mopidy/outputs/gstreamer.py @@ -53,11 +53,16 @@ class GStreamerOutput(ThreadingActor, BaseOutput): uridecodebin.connect('pad-added', self._process_new_pad, pad) self.gst_pipeline.add(uridecodebin) - self._add_output(settings.GSTREAMER_AUDIO_SINK) + localaudio = settings.GSTREAMER_AUDIO_SINK + shoutcast = self._build_shoutcast_description() - shoutcast_bin = self._build_shoutcast_description() - if shoutcast_bin: - self._add_output(shoutcast_bin) + if localaudio: + self._add_output(localaudio) + if shoutcast: + self._add_output(shoutcast) + if not localaudio and not shoutcast: + logger.error('No proper output channels have been setup.') + self._add_output('fakesink') # Setup bus and message processor gst_bus = self.gst_pipeline.get_bus() From 9f862fe1b1d63f38e011aa96efb948d11f7fa98b Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 24 Apr 2011 03:17:17 +0200 Subject: [PATCH 008/218] Update settings docs for shoutcast --- mopidy/settings.py | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/mopidy/settings.py b/mopidy/settings.py index cd8f445e..7daa2257 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -176,32 +176,44 @@ MPD_SERVER_PORT = 6600 #: Servar that runs Shoutcast server to send stream to. #: -#: Default: :class:`None`, disables shoutcast if set to :class:`None` +#: Default:: +#: +#: SHOUTCAST_SERVER = None # Must be set to enable shoutcase SHOUTCAST_SERVER = None #: User to authenticate as against Shoutcast server. #: -#: Default: 'source' +#: Default:: +#: +#: SHOUTCAST_USER = u'source' SHOUTCAST_USER = u'source' #: Password to authenticate with against Shoutcast server. #: -#: Default: 'hackme' +#: Default:: +#: +#: SHOUTCAST_PASSWORD = u'hackme' SHOUTCAST_PASSWORD = u'hackme' #: Port to use for streaming to Shoutcast server. #: -#: Default: 8000 +#: Default:: +#: +#: SHOUTCAST_PORT = 8000 SHOUTCAST_PORT = 8000 #: Mountpoint to use for the stream on the Shoutcast server. #: -#: Default: /stream +#: Default:: +#: +#: SHOUTCAST_MOUNT = u'/stream' SHOUTCAST_MOUNT = u'/stream' #: Encoder to use to process audio data before streaming. #: -#: Default: vorbisenc ! oggmux +#: Default:: +#: +#: SHOUTCAST_ENCODER = u'vorbisenc ! oggmux' SHOUTCAST_ENCODER = u'vorbisenc ! oggmux' #: Overrides to allow advanced setup of shoutcast. Using this settings implies @@ -216,7 +228,9 @@ SHOUTCAST_ENCODER = u'vorbisenc ! oggmux' #: #: For all options see gst-inspect-0.10 lame, vorbisenc and shout2send. #: -#: Default: :class:`None` +#: Default:: +#: +#: SHOUTCAST_OVERRIDE = None SHOUTCAST_OVERRIDE = None #: Path to the Spotify cache. From 44316c7cfc46908a41ad2e93d1d33c78c6dd3adb Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 24 Apr 2011 16:39:05 +0200 Subject: [PATCH 009/218] Reduce number of get_by_name lookups --- mopidy/outputs/gstreamer.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/mopidy/outputs/gstreamer.py b/mopidy/outputs/gstreamer.py index e4563744..f5e7a1a3 100644 --- a/mopidy/outputs/gstreamer.py +++ b/mopidy/outputs/gstreamer.py @@ -48,10 +48,13 @@ class GStreamerOutput(ThreadingActor, BaseOutput): self.gst_tee = self.gst_pipeline.get_by_name('tee') self.gst_convert = self.gst_pipeline.get_by_name('convert') + self.gst_volume = self.gst_pipeline.get_by_name('volume') + self.gst_taginject = self.gst_pipeline.get_by_name('tag') - uridecodebin = gst.element_factory_make('uridecodebin', 'uri') - uridecodebin.connect('pad-added', self._process_new_pad, pad) - self.gst_pipeline.add(uridecodebin) + self.gst_uridecodebin = gst.element_factory_make('uridecodebin', 'uri') + self.gst_uridecodebin.connect('pad-added', self._process_new_pad, + self.gst_convert.get_pad('sink')) + self.gst_pipeline.add(self.gst_uridecodebin) localaudio = settings.GSTREAMER_AUDIO_SINK shoutcast = self._build_shoutcast_description() @@ -124,7 +127,7 @@ class GStreamerOutput(ThreadingActor, BaseOutput): def play_uri(self, uri): """Play audio at URI""" self.set_state('READY') - self.gst_pipeline.get_by_name('uri').set_property('uri', uri) + self.gst_uridecodebin.set_property('uri', uri) return self.set_state('PLAYING') def deliver_data(self, caps_string, data): @@ -188,11 +191,9 @@ class GStreamerOutput(ThreadingActor, BaseOutput): def get_volume(self): """Get volume in range [0..100]""" - gst_volume = self.gst_pipeline.get_by_name('volume') - return int(gst_volume.get_property('volume') * 100) + return int(self.gst_volume.get_property('volume') * 100) def set_volume(self, volume): """Set volume in range [0..100]""" - gst_volume = self.gst_pipeline.get_by_name('volume') - gst_volume.set_property('volume', volume / 100.0) + self.gst_volume.set_property('volume', volume / 100.0) return True From 85970dbc3bcc52ae43b3de073cdc426f61cc1a5b Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 24 Apr 2011 16:39:42 +0200 Subject: [PATCH 010/218] Switch to lame encoding for shoutcast --- mopidy/settings.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/settings.py b/mopidy/settings.py index 7daa2257..7f41cf69 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -213,8 +213,8 @@ SHOUTCAST_MOUNT = u'/stream' #: #: Default:: #: -#: SHOUTCAST_ENCODER = u'vorbisenc ! oggmux' -SHOUTCAST_ENCODER = u'vorbisenc ! oggmux' +#: SHOUTCAST_ENCODER = u'lame mode=stereo bitrate=320' +SHOUTCAST_ENCODER = u'lame mode=stereo bitrate=320' #: Overrides to allow advanced setup of shoutcast. Using this settings implies #: that all other SHOUTCAST_* settings will be ignored. From f4db449f0e80a9d23974d0f7532bf4ba7cced006 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 24 Apr 2011 16:40:37 +0200 Subject: [PATCH 011/218] Add set_metadata to allow taginjection for shoutcast stream --- mopidy/backends/spotify/playback.py | 1 + mopidy/outputs/base.py | 14 ++++++++++++++ mopidy/outputs/gstreamer.py | 9 +++++++++ 3 files changed, 24 insertions(+) diff --git a/mopidy/backends/spotify/playback.py b/mopidy/backends/spotify/playback.py index b02c2d9f..f4db490c 100644 --- a/mopidy/backends/spotify/playback.py +++ b/mopidy/backends/spotify/playback.py @@ -21,6 +21,7 @@ class SpotifyPlaybackProvider(BasePlaybackProvider): Link.from_string(track.uri).as_track()) self.backend.spotify.session.play(1) self.backend.output.play_uri('appsrc://') + self.backend.output.set_metadata(track) return True except SpotifyError as e: logger.warning('Play %s failed: %s', track.uri, e) diff --git a/mopidy/outputs/base.py b/mopidy/outputs/base.py index fbc86688..f81fe905 100644 --- a/mopidy/outputs/base.py +++ b/mopidy/outputs/base.py @@ -89,3 +89,17 @@ class BaseOutput(object): :rtype: :class:`True` if successful, else :class:`False` """ raise NotImplementedError + + def set_metadata(self, track): + """ + Set track metadata for currently playing song. + + Only needs to be called by sources such as appsrc which don't already + inject tags in pipeline. + + *MUST be implemented by subclass.* + + :param track: Track containing metadata for current song. + :type track: :class:`mopidy.modes.Track` + """ + raise NotImplementedError diff --git a/mopidy/outputs/gstreamer.py b/mopidy/outputs/gstreamer.py index f5e7a1a3..c92e51d4 100644 --- a/mopidy/outputs/gstreamer.py +++ b/mopidy/outputs/gstreamer.py @@ -197,3 +197,12 @@ class GStreamerOutput(ThreadingActor, BaseOutput): """Set volume in range [0..100]""" self.gst_volume.set_property('volume', volume / 100.0) return True + + def set_metadata(self, track): + tags = u'artist="%(artist)s",title="%(title)s",album="%(album)s"' % { + 'artist': u', '.join([a.name for a in track.artists]), + 'title': track.name, + 'album': track.album.name, + } + logger.debug('Setting tags to: %s', tags) + self.gst_taginject.set_property('tags', tags) From 7016a2081179eacd6f9940727fac6de9e73d1813 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 24 Apr 2011 21:30:30 +0200 Subject: [PATCH 012/218] Kill of BaseOutput --- mopidy/backends/local/__init__.py | 4 +- mopidy/backends/spotify/__init__.py | 4 +- mopidy/outputs/base.py | 105 ------------------------ mopidy/outputs/dummy.py | 63 -------------- mopidy/outputs/gstreamer.py | 3 +- tests/backends/base/current_playlist.py | 4 +- tests/backends/base/playback.py | 4 +- 7 files changed, 9 insertions(+), 178 deletions(-) delete mode 100644 mopidy/outputs/base.py delete mode 100644 mopidy/outputs/dummy.py diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index 2fa96dab..3d10a63c 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -12,7 +12,7 @@ from mopidy.backends.base import (Backend, CurrentPlaylistController, BasePlaybackProvider, StoredPlaylistsController, BaseStoredPlaylistsProvider) from mopidy.models import Playlist, Track, Album -from mopidy.outputs.base import BaseOutput +from mopidy.outputs.gstreamer import GStreamerOutput from .translator import parse_m3u, parse_mpd_tag_cache @@ -53,7 +53,7 @@ class LocalBackend(ThreadingActor, Backend): self.output = None def on_start(self): - output_refs = ActorRegistry.get_by_class(BaseOutput) + output_refs = ActorRegistry.get_by_class(GStreamerOutput) assert len(output_refs) == 1, 'Expected exactly one running output.' self.output = output_refs[0].proxy() diff --git a/mopidy/backends/spotify/__init__.py b/mopidy/backends/spotify/__init__.py index 1ac5f0be..79750a5d 100644 --- a/mopidy/backends/spotify/__init__.py +++ b/mopidy/backends/spotify/__init__.py @@ -6,7 +6,7 @@ from pykka.registry import ActorRegistry from mopidy import settings from mopidy.backends.base import (Backend, CurrentPlaylistController, LibraryController, PlaybackController, StoredPlaylistsController) -from mopidy.outputs.base import BaseOutput +from mopidy.outputs.gstreamer import GStreamerOutput logger = logging.getLogger('mopidy.backends.spotify') @@ -67,7 +67,7 @@ class SpotifyBackend(ThreadingActor, Backend): self.spotify = None def on_start(self): - output_refs = ActorRegistry.get_by_class(BaseOutput) + output_refs = ActorRegistry.get_by_class(GStreamerOutput) assert len(output_refs) == 1, 'Expected exactly one running output.' self.output = output_refs[0].proxy() diff --git a/mopidy/outputs/base.py b/mopidy/outputs/base.py deleted file mode 100644 index f81fe905..00000000 --- a/mopidy/outputs/base.py +++ /dev/null @@ -1,105 +0,0 @@ -class BaseOutput(object): - """ - Base class for audio outputs. - """ - - def play_uri(self, uri): - """ - Play URI. - - *MUST be implemented by subclass.* - - :param uri: the URI to play - :type uri: string - :rtype: :class:`True` if successful, else :class:`False` - """ - raise NotImplementedError - - def deliver_data(self, capabilities, data): - """ - Deliver audio data to be played. - - *MUST be implemented by subclass.* - - :param capabilities: a GStreamer capabilities string - :type capabilities: string - """ - raise NotImplementedError - - def end_of_data_stream(self): - """ - Signal that the last audio data has been delivered. - - *MUST be implemented by subclass.* - """ - raise NotImplementedError - - def get_position(self): - """ - Get position in milliseconds. - - *MUST be implemented by subclass.* - - :rtype: int - """ - raise NotImplementedError - - def set_position(self, position): - """ - Set position in milliseconds. - - *MUST be implemented by subclass.* - - :param position: the position in milliseconds - :type volume: int - :rtype: :class:`True` if successful, else :class:`False` - """ - raise NotImplementedError - - def set_state(self, state): - """ - Set playback state. - - *MUST be implemented by subclass.* - - :param state: the state - :type state: string - :rtype: :class:`True` if successful, else :class:`False` - """ - raise NotImplementedError - - def get_volume(self): - """ - Get volume level for software mixer. - - *MUST be implemented by subclass.* - - :rtype: int in range [0..100] - """ - raise NotImplementedError - - def set_volume(self, volume): - """ - Set volume level for software mixer. - - *MUST be implemented by subclass.* - - :param volume: the volume in the range [0..100] - :type volume: int - :rtype: :class:`True` if successful, else :class:`False` - """ - raise NotImplementedError - - def set_metadata(self, track): - """ - Set track metadata for currently playing song. - - Only needs to be called by sources such as appsrc which don't already - inject tags in pipeline. - - *MUST be implemented by subclass.* - - :param track: Track containing metadata for current song. - :type track: :class:`mopidy.modes.Track` - """ - raise NotImplementedError diff --git a/mopidy/outputs/dummy.py b/mopidy/outputs/dummy.py deleted file mode 100644 index f09965f7..00000000 --- a/mopidy/outputs/dummy.py +++ /dev/null @@ -1,63 +0,0 @@ -from pykka.actor import ThreadingActor - -from mopidy.outputs.base import BaseOutput - -class DummyOutput(ThreadingActor, BaseOutput): - """ - Audio output used for testing. - """ - - # pylint: disable = R0902 - # Too many instance attributes (9/7) - - #: For testing. Contains the last URI passed to :meth:`play_uri`. - uri = None - - #: For testing. Contains the last capabilities passed to - #: :meth:`deliver_data`. - capabilities = None - - #: For testing. Contains the last data passed to :meth:`deliver_data`. - data = None - - #: For testing. :class:`True` if :meth:`end_of_data_stream` has been - #: called. - end_of_data_stream_called = False - - #: For testing. Contains the current position. - position = 0 - - #: For testing. Contains the current state. - state = 'NULL' - - #: For testing. Contains the current volume. - volume = 100 - - def play_uri(self, uri): - self.uri = uri - return True - - def deliver_data(self, capabilities, data): - self.capabilities = capabilities - self.data = data - - def end_of_data_stream(self): - self.end_of_data_stream_called = True - - def get_position(self): - return self.position - - def set_position(self, position): - self.position = position - return True - - def set_state(self, state): - self.state = state - return True - - def get_volume(self): - return self.volume - - def set_volume(self, volume): - self.volume = volume - return True diff --git a/mopidy/outputs/gstreamer.py b/mopidy/outputs/gstreamer.py index c92e51d4..738c346d 100644 --- a/mopidy/outputs/gstreamer.py +++ b/mopidy/outputs/gstreamer.py @@ -9,11 +9,10 @@ from pykka.registry import ActorRegistry from mopidy import settings from mopidy.backends.base import Backend -from mopidy.outputs.base import BaseOutput logger = logging.getLogger('mopidy.outputs.gstreamer') -class GStreamerOutput(ThreadingActor, BaseOutput): +class GStreamerOutput(ThreadingActor): """ Audio output through `GStreamer `_. diff --git a/tests/backends/base/current_playlist.py b/tests/backends/base/current_playlist.py index ee5e1111..fa5e760c 100644 --- a/tests/backends/base/current_playlist.py +++ b/tests/backends/base/current_playlist.py @@ -3,7 +3,7 @@ import multiprocessing import random from mopidy.models import Playlist, Track -from mopidy.outputs.base import BaseOutput +from mopidy.outputs.gstreamer import GStreamerOutput from tests.backends.base import populate_playlist @@ -12,7 +12,7 @@ class CurrentPlaylistControllerTest(object): def setUp(self): self.backend = self.backend_class() - self.backend.output = mock.Mock(spec=BaseOutput) + self.backend.output = mock.Mock(spec=GStreamerOutput) self.controller = self.backend.current_playlist self.playback = self.backend.playback diff --git a/tests/backends/base/playback.py b/tests/backends/base/playback.py index 8ea48a3a..d5f04655 100644 --- a/tests/backends/base/playback.py +++ b/tests/backends/base/playback.py @@ -4,7 +4,7 @@ import random import time from mopidy.models import Track -from mopidy.outputs.base import BaseOutput +from mopidy.outputs.gstreamer import GStreamerOutput from tests import SkipTest from tests.backends.base import populate_playlist @@ -16,7 +16,7 @@ class PlaybackControllerTest(object): def setUp(self): self.backend = self.backend_class() - self.backend.output = mock.Mock(spec=BaseOutput) + self.backend.output = mock.Mock(spec=GStreamerOutput) self.playback = self.backend.playback self.current_playlist = self.backend.current_playlist From 4a1df118fbd059d21134e048a10289545c1c1fca Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 24 Apr 2011 21:46:19 +0200 Subject: [PATCH 013/218] Move GStreamerOutput up one level to mopidy.gstreamer and rename to GStreamer --- mopidy/backends/local/__init__.py | 4 +-- mopidy/backends/spotify/__init__.py | 4 +-- mopidy/{outputs => }/gstreamer.py | 2 +- mopidy/outputs/__init__.py | 0 tests/backends/base/current_playlist.py | 4 +-- tests/backends/base/playback.py | 4 +-- tests/{outputs => }/gstreamer_test.py | 36 ++++++++++++------------- 7 files changed, 27 insertions(+), 27 deletions(-) rename mopidy/{outputs => }/gstreamer.py (99%) delete mode 100644 mopidy/outputs/__init__.py rename tests/{outputs => }/gstreamer_test.py (74%) diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index 3d10a63c..cc09271a 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -12,7 +12,7 @@ from mopidy.backends.base import (Backend, CurrentPlaylistController, BasePlaybackProvider, StoredPlaylistsController, BaseStoredPlaylistsProvider) from mopidy.models import Playlist, Track, Album -from mopidy.outputs.gstreamer import GStreamerOutput +from mopidy.gstreamer import GStreamer from .translator import parse_m3u, parse_mpd_tag_cache @@ -53,7 +53,7 @@ class LocalBackend(ThreadingActor, Backend): self.output = None def on_start(self): - output_refs = ActorRegistry.get_by_class(GStreamerOutput) + output_refs = ActorRegistry.get_by_class(GStreamer) assert len(output_refs) == 1, 'Expected exactly one running output.' self.output = output_refs[0].proxy() diff --git a/mopidy/backends/spotify/__init__.py b/mopidy/backends/spotify/__init__.py index 79750a5d..641f5377 100644 --- a/mopidy/backends/spotify/__init__.py +++ b/mopidy/backends/spotify/__init__.py @@ -6,7 +6,7 @@ from pykka.registry import ActorRegistry from mopidy import settings from mopidy.backends.base import (Backend, CurrentPlaylistController, LibraryController, PlaybackController, StoredPlaylistsController) -from mopidy.outputs.gstreamer import GStreamerOutput +from mopidy.gstreamer import GStreamer logger = logging.getLogger('mopidy.backends.spotify') @@ -67,7 +67,7 @@ class SpotifyBackend(ThreadingActor, Backend): self.spotify = None def on_start(self): - output_refs = ActorRegistry.get_by_class(GStreamerOutput) + output_refs = ActorRegistry.get_by_class(GStreamer) assert len(output_refs) == 1, 'Expected exactly one running output.' self.output = output_refs[0].proxy() diff --git a/mopidy/outputs/gstreamer.py b/mopidy/gstreamer.py similarity index 99% rename from mopidy/outputs/gstreamer.py rename to mopidy/gstreamer.py index 738c346d..e3edf69c 100644 --- a/mopidy/outputs/gstreamer.py +++ b/mopidy/gstreamer.py @@ -12,7 +12,7 @@ from mopidy.backends.base import Backend logger = logging.getLogger('mopidy.outputs.gstreamer') -class GStreamerOutput(ThreadingActor): +class GStreamer(ThreadingActor): """ Audio output through `GStreamer `_. diff --git a/mopidy/outputs/__init__.py b/mopidy/outputs/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/backends/base/current_playlist.py b/tests/backends/base/current_playlist.py index fa5e760c..a298817a 100644 --- a/tests/backends/base/current_playlist.py +++ b/tests/backends/base/current_playlist.py @@ -3,7 +3,7 @@ import multiprocessing import random from mopidy.models import Playlist, Track -from mopidy.outputs.gstreamer import GStreamerOutput +from mopidy.gstreamer import GStreamer from tests.backends.base import populate_playlist @@ -12,7 +12,7 @@ class CurrentPlaylistControllerTest(object): def setUp(self): self.backend = self.backend_class() - self.backend.output = mock.Mock(spec=GStreamerOutput) + self.backend.output = mock.Mock(spec=GStreamer) self.controller = self.backend.current_playlist self.playback = self.backend.playback diff --git a/tests/backends/base/playback.py b/tests/backends/base/playback.py index d5f04655..972e5b5e 100644 --- a/tests/backends/base/playback.py +++ b/tests/backends/base/playback.py @@ -4,7 +4,7 @@ import random import time from mopidy.models import Track -from mopidy.outputs.gstreamer import GStreamerOutput +from mopidy.gstreamer import GStreamer from tests import SkipTest from tests.backends.base import populate_playlist @@ -16,7 +16,7 @@ class PlaybackControllerTest(object): def setUp(self): self.backend = self.backend_class() - self.backend.output = mock.Mock(spec=GStreamerOutput) + self.backend.output = mock.Mock(spec=GStreamer) self.playback = self.backend.playback self.current_playlist = self.backend.current_playlist diff --git a/tests/outputs/gstreamer_test.py b/tests/gstreamer_test.py similarity index 74% rename from tests/outputs/gstreamer_test.py rename to tests/gstreamer_test.py index 9f380815..5601160e 100644 --- a/tests/outputs/gstreamer_test.py +++ b/tests/gstreamer_test.py @@ -9,26 +9,26 @@ if sys.platform == 'win32': raise SkipTest from mopidy import settings -from mopidy.outputs.gstreamer import GStreamerOutput +from mopidy.gstreamer import GStreamer from mopidy.utils.path import path_to_uri from tests import path_to_data_dir -class GStreamerOutputTest(unittest.TestCase): +class GStreamerTest(unittest.TestCase): def setUp(self): settings.BACKENDS = ('mopidy.backends.local.LocalBackend',) self.song_uri = path_to_uri(path_to_data_dir('song1.wav')) - self.output = GStreamerOutput() - self.output.on_start() + self.gstreamer = GStreamer() + self.gstreamer.on_start() def tearDown(self): settings.runtime.clear() def test_play_uri_existing_file(self): - self.assertTrue(self.output.play_uri(self.song_uri)) + self.assertTrue(self.gstreamer.play_uri(self.song_uri)) def test_play_uri_non_existing_file(self): - self.assertFalse(self.output.play_uri(self.song_uri + 'bogus')) + self.assertFalse(self.gstreamer.play_uri(self.song_uri + 'bogus')) @SkipTest def test_deliver_data(self): @@ -39,19 +39,19 @@ class GStreamerOutputTest(unittest.TestCase): pass # TODO def test_default_get_volume_result(self): - self.assertEqual(100, self.output.get_volume()) + self.assertEqual(100, self.gstreamer.get_volume()) def test_set_volume(self): - self.assertTrue(self.output.set_volume(50)) - self.assertEqual(50, self.output.get_volume()) + self.assertTrue(self.gstreamer.set_volume(50)) + self.assertEqual(50, self.gstreamer.get_volume()) def test_set_volume_to_zero(self): - self.assertTrue(self.output.set_volume(0)) - self.assertEqual(0, self.output.get_volume()) + self.assertTrue(self.gstreamer.set_volume(0)) + self.assertEqual(0, self.gstreamer.get_volume()) def test_set_volume_to_one_hundred(self): - self.assertTrue(self.output.set_volume(100)) - self.assertEqual(100, self.output.get_volume()) + self.assertTrue(self.gstreamer.set_volume(100)) + self.assertEqual(100, self.gstreamer.get_volume()) @SkipTest def test_set_state(self): @@ -62,7 +62,7 @@ class GStreamerOutputTest(unittest.TestCase): pass # TODO def test_build_shoutcast_description_without_server(self): - self.assertEqual(None, self.output._build_shoutcast_description()) + self.assertEqual(None, self.gstreamer._build_shoutcast_description()) def test_build_shoutcast_description_with_server(self): settings.SHOUTCAST_SERVER = '127.0.0.1' @@ -70,7 +70,7 @@ class GStreamerOutputTest(unittest.TestCase): expected = u'audioconvert ! %s ! ' % settings.SHOUTCAST_ENCODER + \ u'shout2send ip="127.0.0.1" mount="/stream" ' \ u'password="hackme" port="8000" username="source"' - result = self.output._build_shoutcast_description() + result = self.gstreamer._build_shoutcast_description() self.assertEqual(expected, result) def test_build_shoutcast_description_with_mount(self): @@ -98,19 +98,19 @@ class GStreamerOutputTest(unittest.TestCase): def test_build_shoutcast_description_with_override(self): settings.SHOUTCAST_OVERRIDE = 'foobar' - result = self.output._build_shoutcast_description() + result = self.gstreamer._build_shoutcast_description() self.assertEqual('foobar', result) def test_build_shoutcast_description_with_override_and_server(self): settings.SHOUTCAST_OVERRIDE = 'foobar' settings.SHOUTCAST_SERVER = '127.0.0.1' - result = self.output._build_shoutcast_description() + result = self.gstreamer._build_shoutcast_description() self.assertEqual('foobar', result) def check_shoutcast_options(self, options): expected = u'audioconvert ! %s ! shout2send ' % settings.SHOUTCAST_ENCODER expected += options - result = self.output._build_shoutcast_description() + result = self.gstreamer._build_shoutcast_description() self.assertEqual(expected, result) From c7ccd0c2d4d332c1d24c1f956c5863decdd37480 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 24 Apr 2011 23:06:33 +0200 Subject: [PATCH 014/218] More fixes with respect to refactoring --- mopidy/backends/spotify/session_manager.py | 4 ++-- mopidy/core.py | 7 ++++--- mopidy/mixers/gstreamer_software.py | 4 ++-- mopidy/settings.py | 7 ------- mopidy/utils/settings.py | 2 ++ 5 files changed, 10 insertions(+), 14 deletions(-) diff --git a/mopidy/backends/spotify/session_manager.py b/mopidy/backends/spotify/session_manager.py index e92fe89e..2b768b20 100644 --- a/mopidy/backends/spotify/session_manager.py +++ b/mopidy/backends/spotify/session_manager.py @@ -10,7 +10,7 @@ from mopidy import get_version, settings from mopidy.backends.base import Backend from mopidy.backends.spotify.translator import SpotifyTranslator from mopidy.models import Playlist -from mopidy.outputs.base import BaseOutput +from mopidy.gstreamer import GStreamer from mopidy.utils.process import BaseThread logger = logging.getLogger('mopidy.backends.spotify.session_manager') @@ -40,7 +40,7 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager): self.connect() def setup(self): - output_refs = ActorRegistry.get_by_class(BaseOutput) + output_refs = ActorRegistry.get_by_class(GStreamer) assert len(output_refs) == 1, 'Expected exactly one running output.' self.output = output_refs[0].proxy() diff --git a/mopidy/core.py b/mopidy/core.py index 093f783d..60e25ef1 100644 --- a/mopidy/core.py +++ b/mopidy/core.py @@ -5,6 +5,7 @@ import time from pykka.registry import ActorRegistry from mopidy import get_version, settings, OptionalDependencyError +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 @@ -18,7 +19,7 @@ def main(): setup_logging(options.verbosity_level, options.save_debug_log) setup_settings() setup_gobject_loop() - setup_output() + setup_gstreamer() setup_mixer() setup_backend() setup_frontends() @@ -54,8 +55,8 @@ def setup_gobject_loop(): gobject_loop.start() return gobject_loop -def setup_output(): - return get_class(settings.OUTPUT).start().proxy() +def setup_gstreamer(): + return GStreamer().start().proxy() def setup_mixer(): return get_class(settings.MIXER).start().proxy() diff --git a/mopidy/mixers/gstreamer_software.py b/mopidy/mixers/gstreamer_software.py index d6365b4b..87602772 100644 --- a/mopidy/mixers/gstreamer_software.py +++ b/mopidy/mixers/gstreamer_software.py @@ -2,7 +2,7 @@ from pykka.actor import ThreadingActor from pykka.registry import ActorRegistry from mopidy.mixers.base import BaseMixer -from mopidy.outputs.base import BaseOutput +from mopidy.gstreamer import GStreamer class GStreamerSoftwareMixer(ThreadingActor, BaseMixer): """Mixer which uses GStreamer to control volume in software.""" @@ -11,7 +11,7 @@ class GStreamerSoftwareMixer(ThreadingActor, BaseMixer): self.output = None def on_start(self): - output_refs = ActorRegistry.get_by_class(BaseOutput) + output_refs = ActorRegistry.get_by_class(GStreamer) assert len(output_refs) == 1, 'Expected exactly one running output.' self.output = output_refs[0].proxy() diff --git a/mopidy/settings.py b/mopidy/settings.py index 7f41cf69..e2d85c88 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -143,13 +143,6 @@ MIXER_EXT_SPEAKERS_B = None #: MIXER_MAX_VOLUME = 100 MIXER_MAX_VOLUME = 100 -#: Audio output handler to use. -#: -#: Default:: -#: -#: OUTPUT = u'mopidy.outputs.gstreamer.GStreamerOutput' -OUTPUT = u'mopidy.outputs.gstreamer.GStreamerOutput' - #: Which address Mopidy's MPD server should bind to. #: #:Examples: diff --git a/mopidy/utils/settings.py b/mopidy/utils/settings.py index 529c6fb1..b907e85f 100644 --- a/mopidy/utils/settings.py +++ b/mopidy/utils/settings.py @@ -97,9 +97,11 @@ def validate_settings(defaults, settings): 'DUMP_LOG_FILENAME': 'DEBUG_LOG_FILENAME', 'DUMP_LOG_FORMAT': 'DEBUG_LOG_FORMAT', 'FRONTEND': 'FRONTENDS', + 'GSTREAMER_AUDIO_SINK': 'CUSTOM_OUTPUT', 'LOCAL_MUSIC_FOLDER': 'LOCAL_MUSIC_PATH', 'LOCAL_PLAYLIST_FOLDER': 'LOCAL_PLAYLIST_PATH', 'LOCAL_TAG_CACHE': 'LOCAL_TAG_CACHE_FILE', + 'OUTPUT': None, 'SERVER': None, 'SERVER_HOSTNAME': 'MPD_SERVER_HOSTNAME', 'SERVER_PORT': 'MPD_SERVER_PORT', From f218bc060bbe88ff1e2f2bde854f8eec09306755 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 24 Apr 2011 23:30:03 +0200 Subject: [PATCH 015/218] Don't bother with album in taginject --- mopidy/gstreamer.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/mopidy/gstreamer.py b/mopidy/gstreamer.py index e3edf69c..3aeb7384 100644 --- a/mopidy/gstreamer.py +++ b/mopidy/gstreamer.py @@ -198,10 +198,9 @@ class GStreamer(ThreadingActor): return True def set_metadata(self, track): - tags = u'artist="%(artist)s",title="%(title)s",album="%(album)s"' % { + tags = u'artist="%(artist)s",title="%(title)s"' % { 'artist': u', '.join([a.name for a in track.artists]), 'title': track.name, - 'album': track.album.name, } logger.debug('Setting tags to: %s', tags) self.gst_taginject.set_property('tags', tags) From 4e232c981cdb9680375ae5f8cfefbf7ac04971f2 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 25 Apr 2011 00:50:15 +0200 Subject: [PATCH 016/218] Switch to modularised output for playback --- mopidy/gstreamer.py | 79 +++++++++++++++++++++------------------------ mopidy/outputs.py | 37 +++++++++++++++++++++ mopidy/settings.py | 18 +++++++++-- 3 files changed, 89 insertions(+), 45 deletions(-) create mode 100644 mopidy/outputs.py diff --git a/mopidy/gstreamer.py b/mopidy/gstreamer.py index 3aeb7384..ab70b0ba 100644 --- a/mopidy/gstreamer.py +++ b/mopidy/gstreamer.py @@ -8,9 +8,42 @@ from pykka.actor import ThreadingActor from pykka.registry import ActorRegistry from mopidy import settings +from mopidy.utils import get_class from mopidy.backends.base import Backend -logger = logging.getLogger('mopidy.outputs.gstreamer') +logger = logging.getLogger('mopidy.gstreamer') + +class BaseOutput(object): + def connect_bin(self, pipeline, element_to_link_to): + """ + Connect output bin to pipeline and given element. + """ + description = 'queue ! %s' % self.describe_bin() + logger.debug('Adding new output to tee: %s', description) + + output = self.parse_bin(description) + self.modify_bin(output) + + pipeline.add(output) + output.sync_state_with_parent() + gst.element_link_many(element_to_link_to, output) + + def parse_bin(self, description): + return gst.parse_bin_from_description(description, True) + + def modify_bin(self, output): + """ + Modifies bin before it is installed if needed + """ + pass + + def describe_bin(self): + """ + Describe bin to be parsed. + + Must be implemented by subclasses. + """ + raise NotImplementedError class GStreamer(ThreadingActor): """ @@ -55,53 +88,15 @@ class GStreamer(ThreadingActor): self.gst_convert.get_pad('sink')) self.gst_pipeline.add(self.gst_uridecodebin) - localaudio = settings.GSTREAMER_AUDIO_SINK - shoutcast = self._build_shoutcast_description() - - if localaudio: - self._add_output(localaudio) - if shoutcast: - self._add_output(shoutcast) - if not localaudio and not shoutcast: - logger.error('No proper output channels have been setup.') - self._add_output('fakesink') + for output in settings.OUTPUTS: + output_cls = get_class(output)() + output_cls.connect_bin(self.gst_pipeline, self.gst_tee) # Setup bus and message processor gst_bus = self.gst_pipeline.get_bus() gst_bus.add_signal_watch() gst_bus.connect('message', self._process_gstreamer_message) - def _add_output(self, description): - bin = 'queue ! %s' % description - logger.debug('Adding output bin to tee: %s', bin) - output = gst.parse_bin_from_description(bin, True) - self.gst_pipeline.add(output) - output.sync_state_with_parent() - gst.element_link_many(self.gst_tee, output) - - def _build_shoutcast_description(self): - if settings.SHOUTCAST_OVERRIDE: - return settings.SHOUTCAST_OVERRIDE - - if not settings.SHOUTCAST_SERVER: - return None - - description = ['audioconvert ! %s ! shout2send' % settings.SHOUTCAST_ENCODER] - options = { - u'ip': settings.SHOUTCAST_SERVER, - u'mount': settings.SHOUTCAST_MOUNT, - u'port': settings.SHOUTCAST_PORT, - u'username': settings.SHOUTCAST_USER, - u'password': settings.SHOUTCAST_PASSWORD, - } - - for key, value in sorted(options.items()): - if value: - description.append('%s="%s"' % (key, value)) - - return u' '.join(description) - - def _process_new_pad(self, source, pad, target_pad): pad.link(target_pad) diff --git a/mopidy/outputs.py b/mopidy/outputs.py new file mode 100644 index 00000000..60569a95 --- /dev/null +++ b/mopidy/outputs.py @@ -0,0 +1,37 @@ +from mopidy import settings +from mopidy.gstreamer import BaseOutput + +class LocalAudioOutput(BaseOutput): + def describe_bin(self): + return 'autoaudiosink' + +class CustomOutput(BaseOutput): + def describe_bin(self): + return settings.CUSTOM_OUTPUT + +class NullOutput(BaseOutput): + def describe_bin(self): + return 'fakesink' + +class ShoutcastOutput(BaseOutput): + def describe_bin(self): + if settings.SHOUTCAST_OVERRIDE: + return settings.SHOUTCAST_OVERRIDE + + if not settings.SHOUTCAST_SERVER: + return None + + description = ['audioconvert ! %s ! shout2send' % settings.SHOUTCAST_ENCODER] + options = { + u'ip': settings.SHOUTCAST_SERVER, + u'mount': settings.SHOUTCAST_MOUNT, + u'port': settings.SHOUTCAST_PORT, + u'username': settings.SHOUTCAST_USER, + u'password': settings.SHOUTCAST_PASSWORD, + } + + for key, value in sorted(options.items()): + if value: + description.append('%s="%s"' % (key, value)) + + return u' '.join(description) diff --git a/mopidy/settings.py b/mopidy/settings.py index e2d85c88..ab299eb6 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -20,6 +20,18 @@ BACKENDS = ( u'mopidy.backends.spotify.SpotifyBackend', ) +#: List of outputs to use. See :mod:`mopidy.outputs` for all available +#: backends +#: +#: Default:: +#: +#: OUTPUTS = ( +#: u'mopidy.outputs.LocalAudioOutput', +#: ) +OUTPUTS = ( + u'mopidy.outputs.LocalAudioOutput', +) + #: The log format used for informational logging. #: #: See http://docs.python.org/library/logging.html#formatter-objects for @@ -54,12 +66,12 @@ FRONTENDS = ( u'mopidy.frontends.lastfm.LastfmFrontend', ) -#: Which GStreamer audio sink to use in :mod:`mopidy.outputs.gstreamer`. +#: Which GStreamer bin description to use in :mod:`mopidy.outputs.CustomOutput`. #: #: Default:: #: -#: GSTREAMER_AUDIO_SINK = u'autoaudiosink' -GSTREAMER_AUDIO_SINK = u'autoaudiosink' +#: CUSTOM_OUTPUT = None +CUSTOM_OUTPUT= None #: Your `Last.fm `_ username. #: From ebe9bba9a92f5a7e95827a087b8f33b0079ba0e1 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 25 Apr 2011 00:59:53 +0200 Subject: [PATCH 017/218] Use modify_bin and set_property to construct shoutcast output --- mopidy/outputs.py | 15 +++++++-------- mopidy/settings.py | 4 ++-- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/mopidy/outputs.py b/mopidy/outputs.py index 60569a95..229f273e 100644 --- a/mopidy/outputs.py +++ b/mopidy/outputs.py @@ -18,11 +18,12 @@ class ShoutcastOutput(BaseOutput): if settings.SHOUTCAST_OVERRIDE: return settings.SHOUTCAST_OVERRIDE - if not settings.SHOUTCAST_SERVER: - return None + return 'audioconvert ! %s ! shout2send name=shoutcast' \ + % settings.SHOUTCAST_ENCODER - description = ['audioconvert ! %s ! shout2send' % settings.SHOUTCAST_ENCODER] - options = { + def modify_bin(self, output): + shoutcast = output.get_by_name('shoutcast') + properties = { u'ip': settings.SHOUTCAST_SERVER, u'mount': settings.SHOUTCAST_MOUNT, u'port': settings.SHOUTCAST_PORT, @@ -30,8 +31,6 @@ class ShoutcastOutput(BaseOutput): u'password': settings.SHOUTCAST_PASSWORD, } - for key, value in sorted(options.items()): + for key, value in properties.items(): if value: - description.append('%s="%s"' % (key, value)) - - return u' '.join(description) + shoutcast.set_property(key, value) diff --git a/mopidy/settings.py b/mopidy/settings.py index ab299eb6..9db30a11 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -183,8 +183,8 @@ MPD_SERVER_PORT = 6600 #: #: Default:: #: -#: SHOUTCAST_SERVER = None # Must be set to enable shoutcase -SHOUTCAST_SERVER = None +#: SHOUTCAST_SERVER = u'127.0.0.1' +SHOUTCAST_SERVER = u'127.0.0.1' #: User to authenticate as against Shoutcast server. #: From 1c233a3f8a971377de4ee594ba3f3e9817bb40db Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 25 Apr 2011 17:14:31 +0200 Subject: [PATCH 018/218] Replace CustomOutput with override for LocalOutput --- mopidy/outputs.py | 8 +++----- mopidy/settings.py | 4 ++-- mopidy/utils/settings.py | 2 +- 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/mopidy/outputs.py b/mopidy/outputs.py index 229f273e..6aff0a0f 100644 --- a/mopidy/outputs.py +++ b/mopidy/outputs.py @@ -1,14 +1,12 @@ from mopidy import settings from mopidy.gstreamer import BaseOutput -class LocalAudioOutput(BaseOutput): +class LocalOutput(BaseOutput): def describe_bin(self): + if settings.LOCALOUTPUT_OVERRIDE: + return settings.LOCALOUTPUT_OVERRIDE return 'autoaudiosink' -class CustomOutput(BaseOutput): - def describe_bin(self): - return settings.CUSTOM_OUTPUT - class NullOutput(BaseOutput): def describe_bin(self): return 'fakesink' diff --git a/mopidy/settings.py b/mopidy/settings.py index 9db30a11..915363b4 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -70,8 +70,8 @@ FRONTENDS = ( #: #: Default:: #: -#: CUSTOM_OUTPUT = None -CUSTOM_OUTPUT= None +#: LOCALOUTPUT_OVERRIDE = None +LOCALOUTPUT_OVERRIDE = None #: Your `Last.fm `_ username. #: diff --git a/mopidy/utils/settings.py b/mopidy/utils/settings.py index b907e85f..6c678e4b 100644 --- a/mopidy/utils/settings.py +++ b/mopidy/utils/settings.py @@ -97,7 +97,7 @@ def validate_settings(defaults, settings): 'DUMP_LOG_FILENAME': 'DEBUG_LOG_FILENAME', 'DUMP_LOG_FORMAT': 'DEBUG_LOG_FORMAT', 'FRONTEND': 'FRONTENDS', - 'GSTREAMER_AUDIO_SINK': 'CUSTOM_OUTPUT', + 'GSTREAMER_AUDIO_SINK': 'LOCALOUTPUT_OVERRIDE', 'LOCAL_MUSIC_FOLDER': 'LOCAL_MUSIC_PATH', 'LOCAL_PLAYLIST_FOLDER': 'LOCAL_PLAYLIST_PATH', 'LOCAL_TAG_CACHE': 'LOCAL_TAG_CACHE_FILE', From 5c19bf843463adee36745087b314b979e582d229 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 25 Apr 2011 17:15:43 +0200 Subject: [PATCH 019/218] Create set_properties helper for BaseOutput --- mopidy/gstreamer.py | 9 +++++++++ mopidy/outputs.py | 13 +++++-------- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/mopidy/gstreamer.py b/mopidy/gstreamer.py index ab70b0ba..5c242a66 100644 --- a/mopidy/gstreamer.py +++ b/mopidy/gstreamer.py @@ -45,6 +45,15 @@ class BaseOutput(object): """ raise NotImplementedError + def set_properties(self, element, properties): + """ + Set properties on element if they have a value. + """ + for key, value in properties.items(): + if value: + element.set_property(key, value) + + class GStreamer(ThreadingActor): """ Audio output through `GStreamer `_. diff --git a/mopidy/outputs.py b/mopidy/outputs.py index 6aff0a0f..1a767d58 100644 --- a/mopidy/outputs.py +++ b/mopidy/outputs.py @@ -15,20 +15,17 @@ class ShoutcastOutput(BaseOutput): def describe_bin(self): if settings.SHOUTCAST_OVERRIDE: return settings.SHOUTCAST_OVERRIDE - return 'audioconvert ! %s ! shout2send name=shoutcast' \ % settings.SHOUTCAST_ENCODER def modify_bin(self, output): - shoutcast = output.get_by_name('shoutcast') - properties = { + if settings.SHOUTCAST_OVERRIDE: + return + + self.set_properties(output.get_by_name('shoutcast'), { u'ip': settings.SHOUTCAST_SERVER, u'mount': settings.SHOUTCAST_MOUNT, u'port': settings.SHOUTCAST_PORT, u'username': settings.SHOUTCAST_USER, u'password': settings.SHOUTCAST_PASSWORD, - } - - for key, value in properties.items(): - if value: - shoutcast.set_property(key, value) + }) From eceba712737a77ed73640a82d7756150d9aef0c7 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 25 Apr 2011 17:30:55 +0200 Subject: [PATCH 020/218] Unify naming of output settings --- mopidy/outputs.py | 22 +++++++++++----------- mopidy/settings.py | 34 +++++++++++++++++----------------- mopidy/utils/settings.py | 2 +- 3 files changed, 29 insertions(+), 29 deletions(-) diff --git a/mopidy/outputs.py b/mopidy/outputs.py index 1a767d58..ee1d20b0 100644 --- a/mopidy/outputs.py +++ b/mopidy/outputs.py @@ -3,8 +3,8 @@ from mopidy.gstreamer import BaseOutput class LocalOutput(BaseOutput): def describe_bin(self): - if settings.LOCALOUTPUT_OVERRIDE: - return settings.LOCALOUTPUT_OVERRIDE + if settings.LOCAL_OUTPUT_OVERRIDE: + return settings.LOCAL_OUTPUT_OVERRIDE return 'autoaudiosink' class NullOutput(BaseOutput): @@ -13,19 +13,19 @@ class NullOutput(BaseOutput): class ShoutcastOutput(BaseOutput): def describe_bin(self): - if settings.SHOUTCAST_OVERRIDE: - return settings.SHOUTCAST_OVERRIDE + if settings.SHOUTCAST_OUTPUT_OVERRIDE: + return settings.SHOUTCAST_OUTPUT_OVERRIDE return 'audioconvert ! %s ! shout2send name=shoutcast' \ - % settings.SHOUTCAST_ENCODER + % settings.SHOUTCAST_OUTPUT_ENCODER def modify_bin(self, output): - if settings.SHOUTCAST_OVERRIDE: + if settings.SHOUTCAST_OUTPUT_OVERRIDE: return self.set_properties(output.get_by_name('shoutcast'), { - u'ip': settings.SHOUTCAST_SERVER, - u'mount': settings.SHOUTCAST_MOUNT, - u'port': settings.SHOUTCAST_PORT, - u'username': settings.SHOUTCAST_USER, - u'password': settings.SHOUTCAST_PASSWORD, + u'ip': settings.SHOUTCAST_OUTPUT_SERVER, + u'mount': settings.SHOUTCAST_OUTPUT_MOUNT, + u'port': settings.SHOUTCAST_OUTPUT_PORT, + u'username': settings.SHOUTCAST_OUTPUT_USER, + u'password': settings.SHOUTCAST_OUTPUT_PASSWORD, }) diff --git a/mopidy/settings.py b/mopidy/settings.py index 915363b4..483d89dd 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -70,8 +70,8 @@ FRONTENDS = ( #: #: Default:: #: -#: LOCALOUTPUT_OVERRIDE = None -LOCALOUTPUT_OVERRIDE = None +#: LOCAL_OUTPUT_OVERRIDE = None +LOCAL_OUTPUT_OVERRIDE = None #: Your `Last.fm `_ username. #: @@ -183,46 +183,46 @@ MPD_SERVER_PORT = 6600 #: #: Default:: #: -#: SHOUTCAST_SERVER = u'127.0.0.1' -SHOUTCAST_SERVER = u'127.0.0.1' +#: SHOUTCAST_OUTPUT_SERVER = u'127.0.0.1' +SHOUTCAST_OUTPUT_SERVER = u'127.0.0.1' #: User to authenticate as against Shoutcast server. #: #: Default:: #: -#: SHOUTCAST_USER = u'source' -SHOUTCAST_USER = u'source' +#: SHOUTCAST_OUTPUT_USER = u'source' +SHOUTCAST_OUTPUT_USER = u'source' #: Password to authenticate with against Shoutcast server. #: #: Default:: #: -#: SHOUTCAST_PASSWORD = u'hackme' -SHOUTCAST_PASSWORD = u'hackme' +#: SHOUTCAST_OUTPUT_PASSWORD = u'hackme' +SHOUTCAST_OUTPUT_PASSWORD = u'hackme' #: Port to use for streaming to Shoutcast server. #: #: Default:: #: -#: SHOUTCAST_PORT = 8000 -SHOUTCAST_PORT = 8000 +#: SHOUTCAST_OUTPUT_PORT = 8000 +SHOUTCAST_OUTPUT_PORT = 8000 #: Mountpoint to use for the stream on the Shoutcast server. #: #: Default:: #: -#: SHOUTCAST_MOUNT = u'/stream' -SHOUTCAST_MOUNT = u'/stream' +#: SHOUTCAST_OUTPUT_MOUNT = u'/stream' +SHOUTCAST_OUTPUT_MOUNT = u'/stream' #: Encoder to use to process audio data before streaming. #: #: Default:: #: -#: SHOUTCAST_ENCODER = u'lame mode=stereo bitrate=320' -SHOUTCAST_ENCODER = u'lame mode=stereo bitrate=320' +#: SHOUTCAST_OUTPUT_ENCODER = u'lame mode=stereo bitrate=320' +SHOUTCAST_OUTPUT_ENCODER = u'lame mode=stereo bitrate=320' #: Overrides to allow advanced setup of shoutcast. Using this settings implies -#: that all other SHOUTCAST_* settings will be ignored. +#: that all other SHOUTCAST_OUTPUT_* settings will be ignored. #: #: Examples: #: @@ -235,8 +235,8 @@ SHOUTCAST_ENCODER = u'lame mode=stereo bitrate=320' #: #: Default:: #: -#: SHOUTCAST_OVERRIDE = None -SHOUTCAST_OVERRIDE = None +#: SHOUTCAST_OUTPUT_OVERRIDE = None +SHOUTCAST_OUTPUT_OVERRIDE = None #: Path to the Spotify cache. #: diff --git a/mopidy/utils/settings.py b/mopidy/utils/settings.py index 6c678e4b..0dc6b4cb 100644 --- a/mopidy/utils/settings.py +++ b/mopidy/utils/settings.py @@ -97,7 +97,7 @@ def validate_settings(defaults, settings): 'DUMP_LOG_FILENAME': 'DEBUG_LOG_FILENAME', 'DUMP_LOG_FORMAT': 'DEBUG_LOG_FORMAT', 'FRONTEND': 'FRONTENDS', - 'GSTREAMER_AUDIO_SINK': 'LOCALOUTPUT_OVERRIDE', + 'GSTREAMER_AUDIO_SINK': 'LOCAL_OUTPUT_OVERRIDE', 'LOCAL_MUSIC_FOLDER': 'LOCAL_MUSIC_PATH', 'LOCAL_PLAYLIST_FOLDER': 'LOCAL_PLAYLIST_PATH', 'LOCAL_TAG_CACHE': 'LOCAL_TAG_CACHE_FILE', From 169fbae695616074d95985b44e412ff79a3f11eb Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 25 Apr 2011 17:41:33 +0200 Subject: [PATCH 021/218] Fix outputs setting --- mopidy/settings.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/mopidy/settings.py b/mopidy/settings.py index 483d89dd..f803c932 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -20,18 +20,6 @@ BACKENDS = ( u'mopidy.backends.spotify.SpotifyBackend', ) -#: List of outputs to use. See :mod:`mopidy.outputs` for all available -#: backends -#: -#: Default:: -#: -#: OUTPUTS = ( -#: u'mopidy.outputs.LocalAudioOutput', -#: ) -OUTPUTS = ( - u'mopidy.outputs.LocalAudioOutput', -) - #: The log format used for informational logging. #: #: See http://docs.python.org/library/logging.html#formatter-objects for @@ -179,6 +167,18 @@ MPD_SERVER_PASSWORD = None #: Default: 6600 MPD_SERVER_PORT = 6600 +#: List of outputs to use. See :mod:`mopidy.outputs` for all available +#: backends +#: +#: Default:: +#: +#: OUTPUTS = ( +#: u'mopidy.outputs.LocalAudioOutput', +#: ) +OUTPUTS = ( + u'mopidy.outputs.LocalOutput', +) + #: Servar that runs Shoutcast server to send stream to. #: #: Default:: From a4c526774bfe29f11f02485c21881c5a3585bfe9 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 25 Apr 2011 17:44:02 +0200 Subject: [PATCH 022/218] Kill off stale shoutcast tests --- tests/gstreamer_test.py | 56 ++--------------------------------------- 1 file changed, 2 insertions(+), 54 deletions(-) diff --git a/tests/gstreamer_test.py b/tests/gstreamer_test.py index 5601160e..9087e0db 100644 --- a/tests/gstreamer_test.py +++ b/tests/gstreamer_test.py @@ -14,6 +14,8 @@ from mopidy.utils.path import path_to_uri from tests import path_to_data_dir +# TODO BaseOutputTest? + class GStreamerTest(unittest.TestCase): def setUp(self): settings.BACKENDS = ('mopidy.backends.local.LocalBackend',) @@ -60,57 +62,3 @@ class GStreamerTest(unittest.TestCase): @SkipTest def test_set_position(self): pass # TODO - - def test_build_shoutcast_description_without_server(self): - self.assertEqual(None, self.gstreamer._build_shoutcast_description()) - - def test_build_shoutcast_description_with_server(self): - settings.SHOUTCAST_SERVER = '127.0.0.1' - - expected = u'audioconvert ! %s ! ' % settings.SHOUTCAST_ENCODER + \ - u'shout2send ip="127.0.0.1" mount="/stream" ' \ - u'password="hackme" port="8000" username="source"' - result = self.gstreamer._build_shoutcast_description() - self.assertEqual(expected, result) - - def test_build_shoutcast_description_with_mount(self): - settings.SHOUTCAST_SERVER = '127.0.0.1' - settings.SHOUTCAST_MOUNT = '/stream.mp3' - - self.check_shoutcast_options(u'ip="127.0.0.1" mount="/stream.mp3" ' - u'password="hackme" port="8000" username="source"') - - def test_build_shoutcast_description_with_user_and_passwod(self): - settings.SHOUTCAST_SERVER = '127.0.0.1' - settings.SHOUTCAST_USER = 'john' - settings.SHOUTCAST_PASSWORD = 'doe' - - self.check_shoutcast_options('ip="127.0.0.1" mount="/stream" ' - u'password="doe" port="8000" username="john"') - - def test_build_shoutcast_description_unset_user_and_pass(self): - settings.SHOUTCAST_SERVER = '127.0.0.1' - settings.SHOUTCAST_USER = None - settings.SHOUTCAST_PASSWORD = None - - self.check_shoutcast_options(u'ip="127.0.0.1" mount="/stream" port="8000"') - - def test_build_shoutcast_description_with_override(self): - settings.SHOUTCAST_OVERRIDE = 'foobar' - - result = self.gstreamer._build_shoutcast_description() - self.assertEqual('foobar', result) - - def test_build_shoutcast_description_with_override_and_server(self): - settings.SHOUTCAST_OVERRIDE = 'foobar' - settings.SHOUTCAST_SERVER = '127.0.0.1' - - result = self.gstreamer._build_shoutcast_description() - self.assertEqual('foobar', result) - - def check_shoutcast_options(self, options): - expected = u'audioconvert ! %s ! shout2send ' % settings.SHOUTCAST_ENCODER - expected += options - - result = self.gstreamer._build_shoutcast_description() - self.assertEqual(expected, result) From 472e4d2790f9abd4be48ab30498673576fb1971b Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 25 Apr 2011 21:30:01 +0200 Subject: [PATCH 023/218] Rename output to gstreamer in backends --- mopidy/backends/local/__init__.py | 20 ++++++++++---------- mopidy/backends/spotify/__init__.py | 8 ++++---- mopidy/backends/spotify/playback.py | 14 +++++++------- mopidy/backends/spotify/session_manager.py | 12 ++++++------ tests/backends/base/current_playlist.py | 2 +- tests/backends/base/playback.py | 12 ++++++------ 6 files changed, 34 insertions(+), 34 deletions(-) diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index cc09271a..126034f1 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -50,12 +50,12 @@ class LocalBackend(ThreadingActor, Backend): self.uri_handlers = [u'file://'] - self.output = None + self.gstreamer = None def on_start(self): - output_refs = ActorRegistry.get_by_class(GStreamer) - assert len(output_refs) == 1, 'Expected exactly one running output.' - self.output = output_refs[0].proxy() + gstreamer_refs = ActorRegistry.get_by_class(GStreamer) + assert len(gstreamer_refs) == 1, 'Expected exactly one running gstreamer.' + self.gstreamer = gstreamer_refs[0].proxy() class LocalPlaybackController(PlaybackController): @@ -67,24 +67,24 @@ class LocalPlaybackController(PlaybackController): @property def time_position(self): - return self.backend.output.get_position().get() + return self.backend.gstreamer.get_position().get() class LocalPlaybackProvider(BasePlaybackProvider): def pause(self): - return self.backend.output.set_state('PAUSED').get() + return self.backend.gstreamer.set_state('PAUSED').get() def play(self, track): - return self.backend.output.play_uri(track.uri).get() + return self.backend.gstreamer.play_uri(track.uri).get() def resume(self): - return self.backend.output.set_state('PLAYING').get() + return self.backend.gstreamer.set_state('PLAYING').get() def seek(self, time_position): - return self.backend.output.set_position(time_position).get() + return self.backend.gstreamer.set_position(time_position).get() def stop(self): - return self.backend.output.set_state('READY').get() + return self.backend.gstreamer.set_state('READY').get() class LocalStoredPlaylistsProvider(BaseStoredPlaylistsProvider): diff --git a/mopidy/backends/spotify/__init__.py b/mopidy/backends/spotify/__init__.py index 641f5377..9dababc0 100644 --- a/mopidy/backends/spotify/__init__.py +++ b/mopidy/backends/spotify/__init__.py @@ -63,13 +63,13 @@ class SpotifyBackend(ThreadingActor, Backend): self.uri_handlers = [u'spotify:', u'http://open.spotify.com/'] - self.output = None + self.gstreamer = None self.spotify = None def on_start(self): - output_refs = ActorRegistry.get_by_class(GStreamer) - assert len(output_refs) == 1, 'Expected exactly one running output.' - self.output = output_refs[0].proxy() + gstreamer_refs = ActorRegistry.get_by_class(GStreamer) + assert len(gstreamer_refs) == 1, 'Expected exactly one running gstreamer.' + self.gstreamer = gstreamer_refs[0].proxy() self.spotify = self._connect() diff --git a/mopidy/backends/spotify/playback.py b/mopidy/backends/spotify/playback.py index f4db490c..496bd83c 100644 --- a/mopidy/backends/spotify/playback.py +++ b/mopidy/backends/spotify/playback.py @@ -8,10 +8,10 @@ logger = logging.getLogger('mopidy.backends.spotify.playback') class SpotifyPlaybackProvider(BasePlaybackProvider): def pause(self): - return self.backend.output.set_state('PAUSED') + return self.backend.gstreamer.set_state('PAUSED') def play(self, track): - self.backend.output.set_state('READY') + self.backend.gstreamer.set_state('READY') if self.backend.playback.state == self.backend.playback.PLAYING: self.backend.spotify.session.play(0) if track.uri is None: @@ -20,8 +20,8 @@ class SpotifyPlaybackProvider(BasePlaybackProvider): self.backend.spotify.session.load( Link.from_string(track.uri).as_track()) self.backend.spotify.session.play(1) - self.backend.output.play_uri('appsrc://') - self.backend.output.set_metadata(track) + self.backend.gstreamer.play_uri('appsrc://') + self.backend.gstreamer.set_metadata(track) return True except SpotifyError as e: logger.warning('Play %s failed: %s', track.uri, e) @@ -31,12 +31,12 @@ class SpotifyPlaybackProvider(BasePlaybackProvider): return self.seek(self.backend.playback.time_position) def seek(self, time_position): - self.backend.output.set_state('READY') + self.backend.gstreamer.set_state('READY') self.backend.spotify.session.seek(time_position) - self.backend.output.set_state('PLAYING') + self.backend.gstreamer.set_state('PLAYING') return True def stop(self): - result = self.backend.output.set_state('READY') + result = self.backend.gstreamer.set_state('READY') self.backend.spotify.session.play(0) return result diff --git a/mopidy/backends/spotify/session_manager.py b/mopidy/backends/spotify/session_manager.py index 2b768b20..1d997156 100644 --- a/mopidy/backends/spotify/session_manager.py +++ b/mopidy/backends/spotify/session_manager.py @@ -29,7 +29,7 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager): BaseThread.__init__(self) self.name = 'SpotifySMThread' - self.output = None + self.gstreamer = None self.backend = None self.connected = threading.Event() @@ -40,9 +40,9 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager): self.connect() def setup(self): - output_refs = ActorRegistry.get_by_class(GStreamer) - assert len(output_refs) == 1, 'Expected exactly one running output.' - self.output = output_refs[0].proxy() + gstreamer_refs = ActorRegistry.get_by_class(GStreamer) + assert len(gstreamer_refs) == 1, 'Expected exactly one running gstreamer.' + self.gstreamer = gstreamer_refs[0].proxy() backend_refs = ActorRegistry.get_by_class(Backend) assert len(backend_refs) == 1, 'Expected exactly one running backend.' @@ -102,7 +102,7 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager): 'sample_rate': sample_rate, 'channels': channels, } - self.output.deliver_data(capabilites, bytes(frames)) + self.gstreamer.deliver_data(capabilites, bytes(frames)) def play_token_lost(self, session): """Callback used by pyspotify""" @@ -116,7 +116,7 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager): def end_of_track(self, session): """Callback used by pyspotify""" logger.debug(u'End of data stream reached') - self.output.end_of_data_stream() + self.gstreamer.end_of_data_stream() def refresh_stored_playlists(self): """Refresh the stored playlists in the backend with fresh meta data diff --git a/tests/backends/base/current_playlist.py b/tests/backends/base/current_playlist.py index a298817a..427ce76d 100644 --- a/tests/backends/base/current_playlist.py +++ b/tests/backends/base/current_playlist.py @@ -12,7 +12,7 @@ class CurrentPlaylistControllerTest(object): def setUp(self): self.backend = self.backend_class() - self.backend.output = mock.Mock(spec=GStreamer) + self.backend.gstreamer = mock.Mock(spec=GStreamer) self.controller = self.backend.current_playlist self.playback = self.backend.playback diff --git a/tests/backends/base/playback.py b/tests/backends/base/playback.py index 972e5b5e..2d455225 100644 --- a/tests/backends/base/playback.py +++ b/tests/backends/base/playback.py @@ -16,7 +16,7 @@ class PlaybackControllerTest(object): def setUp(self): self.backend = self.backend_class() - self.backend.output = mock.Mock(spec=GStreamer) + self.backend.gstreamer = mock.Mock(spec=GStreamer) self.playback = self.backend.playback self.current_playlist = self.backend.current_playlist @@ -520,7 +520,7 @@ class PlaybackControllerTest(object): self.assert_(wrapper.called) - @SkipTest # Blocks for 10ms and does not work with DummyOutput + @SkipTest # Blocks for 10ms @populate_playlist def test_end_of_track_callback_gets_called(self): self.playback.play() @@ -599,7 +599,7 @@ class PlaybackControllerTest(object): self.playback.pause() self.assertEqual(self.playback.resume(), None) - @SkipTest # Uses sleep and does not work with DummyOutput+LocalBackend + @SkipTest # Uses sleep and might not work with LocalBackend @populate_playlist def test_resume_continues_from_right_position(self): self.playback.play() @@ -729,7 +729,7 @@ class PlaybackControllerTest(object): def test_time_position_when_stopped(self): future = mock.Mock() future.get = mock.Mock(return_value=0) - self.backend.output.get_position = mock.Mock(return_value=future) + self.backend.gstreamer.get_position = mock.Mock(return_value=future) self.assertEqual(self.playback.time_position, 0) @@ -737,11 +737,11 @@ class PlaybackControllerTest(object): def test_time_position_when_stopped_with_playlist(self): future = mock.Mock() future.get = mock.Mock(return_value=0) - self.backend.output.get_position = mock.Mock(return_value=future) + self.backend.gstreamer.get_position = mock.Mock(return_value=future) self.assertEqual(self.playback.time_position, 0) - @SkipTest # Uses sleep and does not work with LocalBackend+DummyOutput + @SkipTest # Uses sleep and does might not work with LocalBackend @populate_playlist def test_time_position_when_playing(self): self.playback.play() From bb8296ceb70c9b942593aee320264da468d0a3c5 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 25 Apr 2011 21:30:15 +0200 Subject: [PATCH 024/218] Fix stale documentation in settings.py --- mopidy/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/settings.py b/mopidy/settings.py index f803c932..b6176f9b 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -173,7 +173,7 @@ MPD_SERVER_PORT = 6600 #: Default:: #: #: OUTPUTS = ( -#: u'mopidy.outputs.LocalAudioOutput', +#: u'mopidy.outputs.LocalOutput', #: ) OUTPUTS = ( u'mopidy.outputs.LocalOutput', From 9b9419fcb3a41accd1db0164da34342528c6eb72 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 25 Apr 2011 21:32:34 +0200 Subject: [PATCH 025/218] s/USER/USERNAME/ --- mopidy/outputs.py | 2 +- mopidy/settings.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/mopidy/outputs.py b/mopidy/outputs.py index ee1d20b0..5a57f446 100644 --- a/mopidy/outputs.py +++ b/mopidy/outputs.py @@ -26,6 +26,6 @@ class ShoutcastOutput(BaseOutput): u'ip': settings.SHOUTCAST_OUTPUT_SERVER, u'mount': settings.SHOUTCAST_OUTPUT_MOUNT, u'port': settings.SHOUTCAST_OUTPUT_PORT, - u'username': settings.SHOUTCAST_OUTPUT_USER, + u'username': settings.SHOUTCAST_OUTPUT_USERNAME, u'password': settings.SHOUTCAST_OUTPUT_PASSWORD, }) diff --git a/mopidy/settings.py b/mopidy/settings.py index b6176f9b..c0ee3569 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -190,8 +190,8 @@ SHOUTCAST_OUTPUT_SERVER = u'127.0.0.1' #: #: Default:: #: -#: SHOUTCAST_OUTPUT_USER = u'source' -SHOUTCAST_OUTPUT_USER = u'source' +#: SHOUTCAST_OUTPUT_USERNAME = u'source' +SHOUTCAST_OUTPUT_USERNAME = u'source' #: Password to authenticate with against Shoutcast server. #: From 50aef50989ec5618e372fdb8a36f9cf82f06a88a Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 27 Apr 2011 00:20:08 +0200 Subject: [PATCH 026/218] Ready for 0.5.0 development --- mopidy/__init__.py | 2 +- tests/version_test.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/mopidy/__init__.py b/mopidy/__init__.py index 1fbf99c8..79a0aa29 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -5,7 +5,7 @@ if not (2, 6) <= sys.version_info < (3,): from subprocess import PIPE, Popen -VERSION = (0, 4, 0) +VERSION = (0, 5, 0) def get_version(): try: diff --git a/tests/version_test.py b/tests/version_test.py index 7f204283..b060a9c6 100644 --- a/tests/version_test.py +++ b/tests/version_test.py @@ -17,8 +17,9 @@ class VersionTest(unittest.TestCase): self.assert_(SV('0.1.0') < SV('1.0.0')) self.assert_(SV('0.2.0') < SV('0.3.0')) self.assert_(SV('0.3.0') < SV('0.3.1')) - self.assert_(SV('0.3.1') < SV(get_plain_version())) - self.assert_(SV(get_plain_version()) < SV('0.4.1')) + self.assert_(SV('0.3.1') < SV('0.4.0')) + self.assert_(SV('0.4.0') < SV(get_plain_version())) + self.assert_(SV(get_plain_version()) < SV('0.5.1')) def test_get_platform_contains_platform(self): self.assert_(platform.platform() in get_platform()) From 99ef5a3c7f79f5ff7dd7cf5688589cae4f21efba Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 27 Apr 2011 00:20:31 +0200 Subject: [PATCH 027/218] Include tox.ini in source distribution --- MANIFEST.in | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MANIFEST.in b/MANIFEST.in index 1c126f85..033c51f2 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,4 +1,4 @@ -include LICENSE pylintrc *.rst data/mopidy.desktop +include LICENSE pylintrc *.rst *.ini data/mopidy.desktop include mopidy/backends/spotify/spotify_appkey.key recursive-include docs * prune docs/_build From 1a3fa4b80d09bc63036f1b1f32aa9441f3a98367 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 27 Apr 2011 00:21:39 +0200 Subject: [PATCH 028/218] Prepare changelog for 0.5.0 development --- docs/changes.rst | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/changes.rst b/docs/changes.rst index 5a7db6ad..12da4e6d 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -5,6 +5,16 @@ Changes This change log is used to track all major changes to Mopidy. +0.5.0 (in development) +====================== + +No description yet. + +**Changes** + +No changes yet. + + 0.4.0 (2011-04-27) ================== From 5a16b2ec55a6021c1fe5021afc8d15747cd96305 Mon Sep 17 00:00:00 2001 From: Antoine Pierlot-Garcin Date: Fri, 29 Apr 2011 20:46:34 -0400 Subject: [PATCH 029/218] Fix UnicodeDecodeError in MPD frontend on non-english locale --- 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 8507e266..1be46ef4 100644 --- a/mopidy/frontends/mpd/server.py +++ b/mopidy/frontends/mpd/server.py @@ -52,7 +52,7 @@ class MpdServer(asyncore.dispatcher): self._format_hostname(settings.MPD_SERVER_HOSTNAME), settings.MPD_SERVER_PORT) except IOError, e: - logger.error('MPD server startup failed: %s' % e) + logger.error(u'MPD server startup failed: %s' % str(e).decode('utf-8')) sys.exit(1) def handle_accept(self): From f39d98f5050853aee78e234ff2ab5823048fac5a Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 2 May 2011 21:55:11 +0200 Subject: [PATCH 030/218] Fix merge error in gstreamer.py --- mopidy/gstreamer.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/mopidy/gstreamer.py b/mopidy/gstreamer.py index 6a00ca37..7b444df2 100644 --- a/mopidy/gstreamer.py +++ b/mopidy/gstreamer.py @@ -101,11 +101,11 @@ class GStreamer(ThreadingActor): self.gst_volume = self.gst_pipeline.get_by_name('volume') self.gst_taginject = self.gst_pipeline.get_by_name('tag') - uridecodebin = gst.element_factory_make('uridecodebin', 'uri') - uridecodebin.connect('notify::source', self._process_new_source) - uridecodebin.connect('pad-added', self._process_new_pad, + self.gst_uridecodebin = gst.element_factory_make('uridecodebin', 'uri') + self.gst_uridecodebin.connect('notify::source', self._process_new_source) + self.gst_uridecodebin.connect('pad-added', self._process_new_pad, self.gst_convert.get_pad('sink')) - self.gst_pipeline.add(uridecodebin) + self.gst_pipeline.add(self.gst_uridecodebin) for output in settings.OUTPUTS: output_cls = get_class(output)() From e57f3f39c43a8f9aaf61558dc36565f8fa6048ce Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 2 May 2011 23:49:39 +0200 Subject: [PATCH 031/218] Short circuit retrival of position when we are in stopped state, should fix #87 --- mopidy/gstreamer.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/mopidy/gstreamer.py b/mopidy/gstreamer.py index 7b444df2..ddcc6a56 100644 --- a/mopidy/gstreamer.py +++ b/mopidy/gstreamer.py @@ -169,6 +169,8 @@ class GStreamer(ThreadingActor): self.gst_pipeline.get_by_name('source').emit('end-of-stream') def get_position(self): + if self.gst_pipeline.get_state()[1] == gst.STATE_NULL: + return 0 try: position = self.gst_pipeline.query_position(gst.FORMAT_TIME)[0] return position // gst.MSECOND From dc018024f93121a8f7e16b6eff2a034a66de9f8a Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 3 May 2011 10:04:20 +0200 Subject: [PATCH 032/218] Move BaseOutput from mopidy.gstreamer to mopidy.outputs --- mopidy/gstreamer.py | 40 ----------------------------- mopidy/outputs.py | 61 ++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 60 insertions(+), 41 deletions(-) diff --git a/mopidy/gstreamer.py b/mopidy/gstreamer.py index ddcc6a56..0c356c45 100644 --- a/mopidy/gstreamer.py +++ b/mopidy/gstreamer.py @@ -22,46 +22,6 @@ default_caps = gst.Caps(""" signed=(boolean)true, rate=(int)44100""") -class BaseOutput(object): - def connect_bin(self, pipeline, element_to_link_to): - """ - Connect output bin to pipeline and given element. - """ - description = 'queue ! %s' % self.describe_bin() - logger.debug('Adding new output to tee: %s', description) - - output = self.parse_bin(description) - self.modify_bin(output) - - pipeline.add(output) - output.sync_state_with_parent() - gst.element_link_many(element_to_link_to, output) - - def parse_bin(self, description): - return gst.parse_bin_from_description(description, True) - - def modify_bin(self, output): - """ - Modifies bin before it is installed if needed - """ - pass - - def describe_bin(self): - """ - Describe bin to be parsed. - - Must be implemented by subclasses. - """ - raise NotImplementedError - - def set_properties(self, element, properties): - """ - Set properties on element if they have a value. - """ - for key, value in properties.items(): - if value: - element.set_property(key, value) - class GStreamer(ThreadingActor): """ diff --git a/mopidy/outputs.py b/mopidy/outputs.py index 5a57f446..3aac4105 100644 --- a/mopidy/outputs.py +++ b/mopidy/outputs.py @@ -1,17 +1,76 @@ +import logging + +import pygst +pygst.require('0.10') +import gst + from mopidy import settings -from mopidy.gstreamer import BaseOutput + +logger = logging.getLogger('mopidy.outputs') + + +class BaseOutput(object): + """TODO adamcik""" + + def connect_bin(self, pipeline, element_to_link_to): + """ + Connect output bin to pipeline and given element. + """ + description = 'queue ! %s' % self.describe_bin() + logger.debug('Adding new output to tee: %s', description) + + output = self.parse_bin(description) + self.modify_bin(output) + + pipeline.add(output) + output.sync_state_with_parent() + gst.element_link_many(element_to_link_to, output) + + def parse_bin(self, description): + return gst.parse_bin_from_description(description, True) + + def modify_bin(self, output): + """ + Modifies bin before it is installed if needed + """ + pass + + def describe_bin(self): + """ + Describe bin to be parsed. + + Must be implemented by subclasses. + """ + raise NotImplementedError + + def set_properties(self, element, properties): + """ + Set properties on element if they have a value. + """ + for key, value in properties.items(): + if value: + element.set_property(key, value) + class LocalOutput(BaseOutput): + """TODO adamcik""" + def describe_bin(self): if settings.LOCAL_OUTPUT_OVERRIDE: return settings.LOCAL_OUTPUT_OVERRIDE return 'autoaudiosink' + class NullOutput(BaseOutput): + """TODO adamcik""" + def describe_bin(self): return 'fakesink' + class ShoutcastOutput(BaseOutput): + """TODO adamcik""" + def describe_bin(self): if settings.SHOUTCAST_OUTPUT_OVERRIDE: return settings.SHOUTCAST_OUTPUT_OVERRIDE From ed5cd8f52b2b73d4aaacff1bf6b11ec8a0a2a61f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 3 May 2011 10:08:21 +0200 Subject: [PATCH 033/218] docs: Refresh docs wrt. mopidy.{gstreamer,outputs} --- docs/api/outputs.rst | 14 +++++--------- docs/modules/gstreamer.rst | 9 +++++++++ docs/modules/outputs.rst | 9 +++++++++ docs/modules/outputs/gstreamer.rst | 9 --------- 4 files changed, 23 insertions(+), 18 deletions(-) create mode 100644 docs/modules/gstreamer.rst create mode 100644 docs/modules/outputs.rst delete mode 100644 docs/modules/outputs/gstreamer.rst diff --git a/docs/api/outputs.rst b/docs/api/outputs.rst index 5ef1606d..b96c909e 100644 --- a/docs/api/outputs.rst +++ b/docs/api/outputs.rst @@ -2,19 +2,15 @@ Output API ********** -Outputs are responsible for playing audio. +Outputs are used by :mod:`mopidy.gstreamer` to output audio in some way. -.. warning:: - - A stable output API is not available yet, as we've only implemented a - single output module. - -.. automodule:: mopidy.outputs.base - :synopsis: Base class for outputs +.. autoclass:: mopidy.outputs.BaseOutput :members: Output implementations ====================== -* :mod:`mopidy.outputs.gstreamer` +* :class:`mopidy.outputs.LocalOutput` +* :class:`mopidy.outputs.NullOutput` +* :class:`mopidy.outputs.ShoutcastOutput` diff --git a/docs/modules/gstreamer.rst b/docs/modules/gstreamer.rst new file mode 100644 index 00000000..adbf5fda --- /dev/null +++ b/docs/modules/gstreamer.rst @@ -0,0 +1,9 @@ +******************************************** +:mod:`mopidy.gstreamer` -- GStreamer adapter +******************************************** + +.. inheritance-diagram:: mopidy.gstreamer + +.. automodule:: mopidy.gstreamer + :synopsis: GStreamer adapter + :members: diff --git a/docs/modules/outputs.rst b/docs/modules/outputs.rst new file mode 100644 index 00000000..0a6986dd --- /dev/null +++ b/docs/modules/outputs.rst @@ -0,0 +1,9 @@ +************************************************ +:mod:`mopidy.outputs` -- GStreamer audio outputs +************************************************ + +.. inheritance-diagram:: mopidy.outputs + +.. automodule:: mopidy.outputs + :synopsis: GStreamer audio outputs + :members: diff --git a/docs/modules/outputs/gstreamer.rst b/docs/modules/outputs/gstreamer.rst deleted file mode 100644 index 69c77dad..00000000 --- a/docs/modules/outputs/gstreamer.rst +++ /dev/null @@ -1,9 +0,0 @@ -********************************************************************* -:mod:`mopidy.outputs.gstreamer` -- GStreamer output for all platforms -********************************************************************* - -.. inheritance-diagram:: mopidy.outputs.gstreamer - -.. automodule:: mopidy.outputs.gstreamer - :synopsis: GStreamer output for all platforms - :members: From 882ba520d89eba517be33cd4a0cb1a235685a99c Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 3 May 2011 23:04:15 +0200 Subject: [PATCH 034/218] Replace set_state with _playback helpers --- mopidy/backends/local/__init__.py | 10 ++++++---- mopidy/backends/spotify/playback.py | 13 +++++++------ mopidy/gstreamer.py | 29 ++++++++++++++++++++--------- 3 files changed, 33 insertions(+), 19 deletions(-) diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index 126034f1..5fb7f1cb 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -72,19 +72,21 @@ class LocalPlaybackController(PlaybackController): class LocalPlaybackProvider(BasePlaybackProvider): def pause(self): - return self.backend.gstreamer.set_state('PAUSED').get() + return self.backend.gstreamer.pause_playback().get() def play(self, track): - return self.backend.gstreamer.play_uri(track.uri).get() + self.backend.gstreamer.prepare_playback() + self.backend.gstreamer.set_uri(track.uri).get() + return self.backend.gstreamer.start_playback().get() def resume(self): - return self.backend.gstreamer.set_state('PLAYING').get() + return self.backend.gstreamer.start_playback().get() def seek(self, time_position): return self.backend.gstreamer.set_position(time_position).get() def stop(self): - return self.backend.gstreamer.set_state('READY').get() + return self.backend.gstreamer.stop_playback().get() class LocalStoredPlaylistsProvider(BaseStoredPlaylistsProvider): diff --git a/mopidy/backends/spotify/playback.py b/mopidy/backends/spotify/playback.py index 3f2a157b..7c67a7c1 100644 --- a/mopidy/backends/spotify/playback.py +++ b/mopidy/backends/spotify/playback.py @@ -8,10 +8,9 @@ logger = logging.getLogger('mopidy.backends.spotify.playback') class SpotifyPlaybackProvider(BasePlaybackProvider): def pause(self): - return self.backend.gstreamer.set_state('PAUSED') + return self.backend.gstreamer.pause_playback() def play(self, track): - self.backend.gstreamer.set_state('READY') if self.backend.playback.state == self.backend.playback.PLAYING: self.backend.spotify.session.play(0) if track.uri is None: @@ -20,7 +19,9 @@ class SpotifyPlaybackProvider(BasePlaybackProvider): self.backend.spotify.session.load( Link.from_string(track.uri).as_track()) self.backend.spotify.session.play(1) - self.backend.gstreamer.play_uri('appsrc://') + self.backend.gstreamer.prepare_playback() + self.backend.gstreamer.set_uri('appsrc://') + self.backend.gstreamer.start_playback() self.backend.gstreamer.set_metadata(track) return True except SpotifyError as e: @@ -31,12 +32,12 @@ class SpotifyPlaybackProvider(BasePlaybackProvider): return self.seek(self.backend.playback.time_position) def seek(self, time_position): - self.backend.gstreamer.set_state('READY') + self.backend.gstreamer.prepare_playback() self.backend.spotify.session.seek(time_position) - self.backend.gstreamer.set_state('PLAYING') + self.backend.gstreamer.start_playback() return True def stop(self): - result = self.backend.gstreamer.set_state('READY') + result = self.backend.gstreamer.stop_playback() self.backend.spotify.session.play(0) return result diff --git a/mopidy/gstreamer.py b/mopidy/gstreamer.py index ddcc6a56..052c0827 100644 --- a/mopidy/gstreamer.py +++ b/mopidy/gstreamer.py @@ -133,7 +133,7 @@ class GStreamer(ThreadingActor): 'Telling backend ...') self._get_backend().playback.on_end_of_track() elif message.type == gst.MESSAGE_ERROR: - self.set_state('NULL') + self.stop_playback() error, debug = message.parse_error() logger.error(u'%s %s', error, debug) # FIXME Should we send 'stop_playback' to the backend here? Can we @@ -144,11 +144,9 @@ class GStreamer(ThreadingActor): assert len(backend_refs) == 1, 'Expected exactly one running backend.' return backend_refs[0].proxy() - def play_uri(self, uri): + def set_uri(self, uri): """Play audio at URI""" - self.set_state('READY') self.gst_uridecodebin.set_property('uri', uri) - return self.set_state('PLAYING') def deliver_data(self, caps_string, data): """Deliver audio data to be played""" @@ -185,7 +183,19 @@ class GStreamer(ThreadingActor): self.gst_pipeline.get_state() # block until seek is done return handeled - def set_state(self, state_name): + def start_playback(self): + return self.set_state(gst.STATE_PLAYING) + + def pause_playback(self): + return self.set_state(gst.STATE_PAUSE) + + def prepare_playback(self): + return self.set_state(gst.STATE_READY) + + def stop_playback(self): + return self.set_state(gst.STATE_NULL) + + def set_state(self, state): """ Set the GStreamer state. Returns :class:`True` if successful. @@ -202,13 +212,14 @@ class GStreamer(ThreadingActor): :type state_name: string :rtype: :class:`True` or :class:`False` """ - result = self.gst_pipeline.set_state( - getattr(gst, 'STATE_' + state_name)) + result = self.gst_pipeline.set_state(state) if result == gst.STATE_CHANGE_FAILURE: - logger.warning('Setting GStreamer state to %s: failed', state_name) + logger.warning('Setting GStreamer state to %s: failed', + state.value_name) return False else: - logger.debug('Setting GStreamer state to %s: OK', state_name) + logger.debug('Setting GStreamer state to %s: OK', + state.value_name) return True def get_volume(self): From 42547563eaae2c5c1dd9efa923482b9c32935040 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 3 May 2011 23:08:31 +0200 Subject: [PATCH 035/218] Convert set_state to internal method --- mopidy/gstreamer.py | 10 +++++----- tests/gstreamer_test.py | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/mopidy/gstreamer.py b/mopidy/gstreamer.py index 052c0827..901597a7 100644 --- a/mopidy/gstreamer.py +++ b/mopidy/gstreamer.py @@ -184,18 +184,18 @@ class GStreamer(ThreadingActor): return handeled def start_playback(self): - return self.set_state(gst.STATE_PLAYING) + return self._set_state(gst.STATE_PLAYING) def pause_playback(self): - return self.set_state(gst.STATE_PAUSE) + return self._set_state(gst.STATE_PAUSE) def prepare_playback(self): - return self.set_state(gst.STATE_READY) + return self._set_state(gst.STATE_READY) def stop_playback(self): - return self.set_state(gst.STATE_NULL) + return self._set_state(gst.STATE_NULL) - def set_state(self, state): + def _set_state(self, state): """ Set the GStreamer state. Returns :class:`True` if successful. diff --git a/tests/gstreamer_test.py b/tests/gstreamer_test.py index 9087e0db..d05b3bda 100644 --- a/tests/gstreamer_test.py +++ b/tests/gstreamer_test.py @@ -56,7 +56,7 @@ class GStreamerTest(unittest.TestCase): self.assertEqual(100, self.gstreamer.get_volume()) @SkipTest - def test_set_state(self): + def test_set_state_encapsulation(self): pass # TODO @SkipTest From 32cf5588eb5a7e01dcbaa10857c44502a558190a Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 3 May 2011 23:25:33 +0200 Subject: [PATCH 036/218] Recovered old BastOutput docstrings and added them to GStreamer class where apropriate --- mopidy/gstreamer.py | 58 +++++++++++++++++++++++++++++++++++++++------ 1 file changed, 51 insertions(+), 7 deletions(-) diff --git a/mopidy/gstreamer.py b/mopidy/gstreamer.py index 0c356c45..bd1467b2 100644 --- a/mopidy/gstreamer.py +++ b/mopidy/gstreamer.py @@ -29,7 +29,7 @@ class GStreamer(ThreadingActor): **Settings:** - - :attr:`mopidy.settings.GSTREAMER_AUDIO_SINK` + - :attr:`mopidy.settings.OUTPUTS` """ @@ -105,15 +105,27 @@ class GStreamer(ThreadingActor): return backend_refs[0].proxy() def play_uri(self, uri): - """Play audio at URI""" + """ + Play audio at URI + + :param uri: the URI to play + :type uri: string + :rtype: :class:`True` if successful, else :class:`False` + """ self.set_state('READY') self.gst_uridecodebin.set_property('uri', uri) return self.set_state('PLAYING') - def deliver_data(self, caps_string, data): - """Deliver audio data to be played""" + def deliver_data(self, capabilities, data): + """ + Deliver audio data to be played + + :param capabilities: a GStreamer capabilities string + :type capabilities: string + :param data: raw audio data to be played + """ source = self.gst_pipeline.get_by_name('source') - caps = gst.caps_from_string(caps_string) + caps = gst.caps_from_string(capabilities) buffer_ = gst.Buffer(buffer(data)) buffer_.set_caps(caps) source.set_property('caps', caps) @@ -129,6 +141,11 @@ class GStreamer(ThreadingActor): self.gst_pipeline.get_by_name('source').emit('end-of-stream') def get_position(self): + """ + Get position in milliseconds. + + :rtype: int + """ if self.gst_pipeline.get_state()[1] == gst.STATE_NULL: return 0 try: @@ -139,6 +156,13 @@ class GStreamer(ThreadingActor): return 0 def set_position(self, position): + """ + Set position in milliseconds. + + :param position: the position in milliseconds + :type volume: int + :rtype: :class:`True` if successful, else :class:`False` + """ self.gst_pipeline.get_state() # block until state changes are done handeled = self.gst_pipeline.seek_simple(gst.Format(gst.FORMAT_TIME), gst.SEEK_FLAG_FLUSH, position * gst.MSECOND) @@ -172,15 +196,35 @@ class GStreamer(ThreadingActor): return True def get_volume(self): - """Get volume in range [0..100]""" + """ + Get volume level for software mixer. + + :rtype: int in range [0..100] + """ return int(self.gst_volume.get_property('volume') * 100) def set_volume(self, volume): - """Set volume in range [0..100]""" + """ + Set volume level for software mixer. + + :param volume: the volume in the range [0..100] + :type volume: int + :rtype: :class:`True` if successful, else :class:`False` + """ self.gst_volume.set_property('volume', volume / 100.0) return True def set_metadata(self, track): + """ + Set track metadata for currently playing song. + + Only needs to be called by sources such as appsrc which don't already + inject tags in pipeline. + + :param track: Track containing metadata for current song. + :type track: :class:`mopidy.modes.Track` + """ + # FIXME what if we want to unset taginject tags? tags = u'artist="%(artist)s",title="%(title)s"' % { 'artist': u', '.join([a.name for a in track.artists]), 'title': track.name, From 9b47870404389bbbf4d07211a63cbaa3b5ee25d5 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 3 May 2011 23:27:23 +0200 Subject: [PATCH 037/218] Make mopidy.ouputs a module again --- mopidy/{outputs.py => outputs/__init__.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename mopidy/{outputs.py => outputs/__init__.py} (100%) diff --git a/mopidy/outputs.py b/mopidy/outputs/__init__.py similarity index 100% rename from mopidy/outputs.py rename to mopidy/outputs/__init__.py From 8e8a7cd037fbc2c34173960be70d1629339d0a4d Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 3 May 2011 23:41:01 +0200 Subject: [PATCH 038/218] Add docstring for BaseOutput --- mopidy/outputs/__init__.py | 49 ++++++++++++++++++++++++++++---------- 1 file changed, 36 insertions(+), 13 deletions(-) diff --git a/mopidy/outputs/__init__.py b/mopidy/outputs/__init__.py index 3aac4105..8ff43a5f 100644 --- a/mopidy/outputs/__init__.py +++ b/mopidy/outputs/__init__.py @@ -10,45 +10,68 @@ logger = logging.getLogger('mopidy.outputs') class BaseOutput(object): - """TODO adamcik""" + """Base class for providing support for multiple pluggable outputs.""" - def connect_bin(self, pipeline, element_to_link_to): + def connect_bin(self, pipeline, element): """ Connect output bin to pipeline and given element. + + In normal cases the element will probably be a `tee`, + thus allowing us to connect any number of outputs. This + however is why each bin is forced to have its own `queue` + after the `tee`. + + :param pipeline: gst.Pipeline to add output to. + :type pipeline: :class:`gst.Pipeline` + :param element: gst.Element in pipeline to connect output to. + :type element: :class:`gst.Element` """ description = 'queue ! %s' % self.describe_bin() logger.debug('Adding new output to tee: %s', description) - output = self.parse_bin(description) + output = gst.parse_bin_from_description(description, True) self.modify_bin(output) pipeline.add(output) - output.sync_state_with_parent() - gst.element_link_many(element_to_link_to, output) - - def parse_bin(self, description): - return gst.parse_bin_from_description(description, True) + output.sync_state_with_parent() # Required to add to running pipe + gst.element_link_many(element, output) def modify_bin(self, output): """ - Modifies bin before it is installed if needed + Modifies bin before it is installed if needed. + + Overriding this method allows for outputs to modify the constructed bin + before it is installed. This can for instance be a good place to call + `set_properties` on elements that need to be configured. """ pass def describe_bin(self): """ - Describe bin to be parsed. + Return text string describing bin in gst-launch format. - Must be implemented by subclasses. + For simple cases this can just be a plain sink such as `autoaudiosink` + or it can be a chain `element1 ! element2 ! sink`. See `man + gst-launch0.10` for details on format. + + *MUST be implemented by subclass.* """ raise NotImplementedError def set_properties(self, element, properties): """ - Set properties on element if they have a value. + Helper to allow for simple setting of properties on elements. + + Will call `set_property` on the element for each key that has a value + that is not None. + + :param element: gst.Element to set properties on. + :type element: :class:`gst.Element` + :param properties: Dictionary of properties to set on element. + :type element: dict """ for key, value in properties.items(): - if value: + if value is not None: element.set_property(key, value) From f69e01c501b14921b74c57e3536ba703af33a92a Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 3 May 2011 23:56:24 +0200 Subject: [PATCH 039/218] docstrings for outputs --- mopidy/outputs/__init__.py | 41 +++++++++++++++++++++++++++++++++++--- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/mopidy/outputs/__init__.py b/mopidy/outputs/__init__.py index 8ff43a5f..9623681f 100644 --- a/mopidy/outputs/__init__.py +++ b/mopidy/outputs/__init__.py @@ -76,7 +76,21 @@ class BaseOutput(object): class LocalOutput(BaseOutput): - """TODO adamcik""" + """ + Basic output to local audio sink. + + 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. + + Advanced: + + However, there are chases when you want to explicitly set what GStreamer + should use. This can be achieved by setting `settings.LOCAL_OUTPUT_OVERRIDE` + to the sink you want to use. Some of the possible values are: alsasink, + esdsink, jackaudiosink, oss4sink and osssink. Exact values that will work + on your system will depend on your sound setup and installed GStreamer + plugins. Run `gst-inspect0.10` for list of all available plugins. + """ def describe_bin(self): if settings.LOCAL_OUTPUT_OVERRIDE: @@ -85,14 +99,35 @@ class LocalOutput(BaseOutput): class NullOutput(BaseOutput): - """TODO adamcik""" + """ + Fall-back null output. + + This output will not output anything. It is intended as a fall-back for + when setup of all other outputs have failed and should not be used by end + users. Inserting this output in such a case ensures that the pipeline does + not crash. + """ def describe_bin(self): return 'fakesink' class ShoutcastOutput(BaseOutput): - """TODO adamcik""" + """ + Shoutcast streaming output. + + This output allows for streaming to an icecast server or anything else that + 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. + + Advanced: + + If you need to do something special that this output has not taken into + account the setting `settings.SHOUTCAST_OUTPUT_OVERRIDE` has been provided + to allow for manual setup of the bin using a gst-launch string. If this + setting is set all other shoutcast settings will be ignored. + """ def describe_bin(self): if settings.SHOUTCAST_OUTPUT_OVERRIDE: From 0a2a582b25a52517bdebcaf864db4e817fc028e6 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 3 May 2011 23:57:38 +0200 Subject: [PATCH 040/218] Remove last refrenceto GStreamerOutput --- mopidy/gstreamer.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/gstreamer.py b/mopidy/gstreamer.py index bd1467b2..4c69da91 100644 --- a/mopidy/gstreamer.py +++ b/mopidy/gstreamer.py @@ -41,9 +41,9 @@ class GStreamer(ThreadingActor): def _setup_gstreamer(self): """ - **Warning:** :class:`GStreamerOutput` requires + **Warning:** :class:`GStreamer` requires :class:`mopidy.utils.process.GObjectEventThread` to be running. This is - not enforced by :class:`GStreamerOutput` itself. + not enforced by :class:`GStreamer` itself. """ base_pipeline = ' ! '.join([ 'audioconvert name=convert', From 4df54ffdef57524683e0a9962804e61680dd4991 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 4 May 2011 00:00:38 +0200 Subject: [PATCH 041/218] Fix refrence to mopidy.outputs.CustomOutput --- mopidy/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/settings.py b/mopidy/settings.py index c0ee3569..78abb6b7 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -54,7 +54,7 @@ FRONTENDS = ( u'mopidy.frontends.lastfm.LastfmFrontend', ) -#: Which GStreamer bin description to use in :mod:`mopidy.outputs.CustomOutput`. +#: Which GStreamer bin description to use in :class:`mopidy.outputs.LocalOutput`. #: #: Default:: #: From fad015ba7e424a4ca9e9e0872a77821874ad4e0f Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 4 May 2011 00:15:21 +0200 Subject: [PATCH 042/218] Update changelog with modularised output and time error bug --- docs/changes.rst | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/changes.rst b/docs/changes.rst index 12da4e6d..31d0a015 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -10,9 +10,16 @@ This change log is used to track all major changes to Mopidy. No description yet. +**Important changes** + +- 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. + **Changes** -No changes yet. +- Fix local backend time query errors that where coming from stopped pipeline. + (Fixes: :issue:`87`) 0.4.0 (2011-04-27) From 173b5cfb827adc2e6f15d382640965c3a977276c Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 4 May 2011 00:23:21 +0200 Subject: [PATCH 043/218] Minor doc fixes for outputs --- mopidy/outputs/__init__.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/mopidy/outputs/__init__.py b/mopidy/outputs/__init__.py index 9623681f..f7a796a8 100644 --- a/mopidy/outputs/__init__.py +++ b/mopidy/outputs/__init__.py @@ -43,6 +43,9 @@ class BaseOutput(object): Overriding this method allows for outputs to modify the constructed bin before it is installed. This can for instance be a good place to call `set_properties` on elements that need to be configured. + + :param output: gst.Bin to modify in some way. + :type output: :class:`gst.Bin` """ pass @@ -68,7 +71,7 @@ class BaseOutput(object): :param element: gst.Element to set properties on. :type element: :class:`gst.Element` :param properties: Dictionary of properties to set on element. - :type element: dict + :type properties: dict """ for key, value in properties.items(): if value is not None: From 6feb4f4c419c8c6442a6814d4d5b93ec90823e2c Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 4 May 2011 11:18:17 +0200 Subject: [PATCH 044/218] Add pulsesink to docstring and mention gst-launch --- mopidy/outputs/__init__.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/mopidy/outputs/__init__.py b/mopidy/outputs/__init__.py index f7a796a8..e3747463 100644 --- a/mopidy/outputs/__init__.py +++ b/mopidy/outputs/__init__.py @@ -90,9 +90,10 @@ class LocalOutput(BaseOutput): However, there are chases when you want to explicitly set what GStreamer should use. This can be achieved by setting `settings.LOCAL_OUTPUT_OVERRIDE` to the sink you want to use. Some of the possible values are: alsasink, - esdsink, jackaudiosink, oss4sink and osssink. Exact values that will work - on your system will depend on your sound setup and installed GStreamer - plugins. Run `gst-inspect0.10` for list of all available plugins. + esdsink, jackaudiosink, oss4sink, osssink and pulsesink. Exact values that + will work on your system will depend on your sound setup and installed + GStreamer plugins. Run `gst-inspect0.10` for list of all available plugins. + Also note that this accepts properties and bins in `gst-launch` format. """ def describe_bin(self): From d0e0594a780706dfc3e0ca084ad75bf379415819 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 4 May 2011 23:35:32 +0200 Subject: [PATCH 045/218] s/PAUSE/PAUSED/ in pause_playback --- mopidy/gstreamer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/gstreamer.py b/mopidy/gstreamer.py index 901597a7..66735beb 100644 --- a/mopidy/gstreamer.py +++ b/mopidy/gstreamer.py @@ -187,7 +187,7 @@ class GStreamer(ThreadingActor): return self._set_state(gst.STATE_PLAYING) def pause_playback(self): - return self._set_state(gst.STATE_PAUSE) + return self._set_state(gst.STATE_PAUSED) def prepare_playback(self): return self._set_state(gst.STATE_READY) From 9998a0e80f2c677895fee1955586b5f2acac407c Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 4 May 2011 23:35:50 +0200 Subject: [PATCH 046/218] Update gstreamer test --- tests/gstreamer_test.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/gstreamer_test.py b/tests/gstreamer_test.py index d05b3bda..81231d37 100644 --- a/tests/gstreamer_test.py +++ b/tests/gstreamer_test.py @@ -26,11 +26,15 @@ class GStreamerTest(unittest.TestCase): def tearDown(self): settings.runtime.clear() - def test_play_uri_existing_file(self): - self.assertTrue(self.gstreamer.play_uri(self.song_uri)) + def test_set_uri_existing_file(self): + self.gstreamer.prepare_playback() + self.gstreamer.set_uri(self.song_uri) + self.assertTrue(self.gstreamer.start_playback()) - def test_play_uri_non_existing_file(self): - self.assertFalse(self.gstreamer.play_uri(self.song_uri + 'bogus')) + def test_set_uri_non_existing_file(self): + self.gstreamer.prepare_playback() + self.gstreamer.set_uri(self.song_uri + 'bogus') + self.assertFalse(self.gstreamer.start_playback()) @SkipTest def test_deliver_data(self): From 76a33f37dbc8b2dd7c33648cc75d7ddd647d44ff Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 4 May 2011 23:42:14 +0200 Subject: [PATCH 047/218] Add some more tests --- tests/gstreamer_test.py | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/tests/gstreamer_test.py b/tests/gstreamer_test.py index 81231d37..d01e694c 100644 --- a/tests/gstreamer_test.py +++ b/tests/gstreamer_test.py @@ -23,19 +23,31 @@ class GStreamerTest(unittest.TestCase): self.gstreamer = GStreamer() self.gstreamer.on_start() + def prepare_uri(self, uri): + self.gstreamer.prepare_playback() + self.gstreamer.set_uri(uri) + def tearDown(self): settings.runtime.clear() - def test_set_uri_existing_file(self): - self.gstreamer.prepare_playback() - self.gstreamer.set_uri(self.song_uri) + def test_start_playback_existing_file(self): + self.prepare_uri(self.song_uri) self.assertTrue(self.gstreamer.start_playback()) - def test_set_uri_non_existing_file(self): - self.gstreamer.prepare_playback() - self.gstreamer.set_uri(self.song_uri + 'bogus') + def test_start_playback_non_existing_file(self): + self.prepare_uri(self.song_uri + 'bogus') self.assertFalse(self.gstreamer.start_playback()) + def test_pause_playback_while_playing(self): + self.prepare_uri(self.song_uri) + self.gstreamer.start_playback() + self.assertTrue(self.gstreamer.pause_playback()) + + def test_stop_playback_while_playing(self): + self.prepare_uri(self.song_uri) + self.gstreamer.start_playback() + self.assertTrue(self.gstreamer.stop_playback()) + @SkipTest def test_deliver_data(self): pass # TODO From e1cc1dfe483de0658caeb6d1b408148db05982c4 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 5 May 2011 20:17:28 +0200 Subject: [PATCH 048/218] Rename prepare_playback to prepare_change --- mopidy/backends/local/__init__.py | 2 +- mopidy/backends/spotify/playback.py | 4 ++-- mopidy/gstreamer.py | 2 +- tests/gstreamer_test.py | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index 5fb7f1cb..cc039ce0 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -75,7 +75,7 @@ class LocalPlaybackProvider(BasePlaybackProvider): return self.backend.gstreamer.pause_playback().get() def play(self, track): - self.backend.gstreamer.prepare_playback() + self.backend.gstreamer.prepare_change() self.backend.gstreamer.set_uri(track.uri).get() return self.backend.gstreamer.start_playback().get() diff --git a/mopidy/backends/spotify/playback.py b/mopidy/backends/spotify/playback.py index 7c67a7c1..dc328fc9 100644 --- a/mopidy/backends/spotify/playback.py +++ b/mopidy/backends/spotify/playback.py @@ -19,7 +19,7 @@ class SpotifyPlaybackProvider(BasePlaybackProvider): self.backend.spotify.session.load( Link.from_string(track.uri).as_track()) self.backend.spotify.session.play(1) - self.backend.gstreamer.prepare_playback() + self.backend.gstreamer.prepare_change() self.backend.gstreamer.set_uri('appsrc://') self.backend.gstreamer.start_playback() self.backend.gstreamer.set_metadata(track) @@ -32,7 +32,7 @@ class SpotifyPlaybackProvider(BasePlaybackProvider): return self.seek(self.backend.playback.time_position) def seek(self, time_position): - self.backend.gstreamer.prepare_playback() + self.backend.gstreamer.prepare_change() self.backend.spotify.session.seek(time_position) self.backend.gstreamer.start_playback() return True diff --git a/mopidy/gstreamer.py b/mopidy/gstreamer.py index 66735beb..95953d4a 100644 --- a/mopidy/gstreamer.py +++ b/mopidy/gstreamer.py @@ -189,7 +189,7 @@ class GStreamer(ThreadingActor): def pause_playback(self): return self._set_state(gst.STATE_PAUSED) - def prepare_playback(self): + def prepare_change(self): return self._set_state(gst.STATE_READY) def stop_playback(self): diff --git a/tests/gstreamer_test.py b/tests/gstreamer_test.py index d01e694c..0b9a559e 100644 --- a/tests/gstreamer_test.py +++ b/tests/gstreamer_test.py @@ -24,7 +24,7 @@ class GStreamerTest(unittest.TestCase): self.gstreamer.on_start() def prepare_uri(self, uri): - self.gstreamer.prepare_playback() + self.gstreamer.prepare_change() self.gstreamer.set_uri(uri) def tearDown(self): From b966c0fd1b68883cd9e80adaa784dd2ebe0da55e Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 5 May 2011 20:21:17 +0200 Subject: [PATCH 049/218] Basic docstrings for new helpers --- mopidy/gstreamer.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/mopidy/gstreamer.py b/mopidy/gstreamer.py index 95953d4a..ff25ac58 100644 --- a/mopidy/gstreamer.py +++ b/mopidy/gstreamer.py @@ -184,15 +184,24 @@ class GStreamer(ThreadingActor): return handeled def start_playback(self): + """Notify GStreamer that it should start playback""" return self._set_state(gst.STATE_PLAYING) def pause_playback(self): + """Notify GStreamer that it should pause playback""" return self._set_state(gst.STATE_PAUSED) def prepare_change(self): + """ + Notify GStreamer that we are about to change state of playback. + + This function always needs to be called before changing URIS or doing + changes like updating data that is being pushed. + """ return self._set_state(gst.STATE_READY) def stop_playback(self): + """Notify GStreamer that is should stop playback""" return self._set_state(gst.STATE_NULL) def _set_state(self, state): From 6f9be1159425867a3d260cfd3379f61c97b461b2 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 4 May 2011 23:21:26 +0200 Subject: [PATCH 050/218] Cleanup gstreamer. Simplified code and reduced get_by_name usage. --- mopidy/gstreamer.py | 85 ++++++++++++++++++++++++--------------------- 1 file changed, 46 insertions(+), 39 deletions(-) diff --git a/mopidy/gstreamer.py b/mopidy/gstreamer.py index 7f0fc7b7..5b715175 100644 --- a/mopidy/gstreamer.py +++ b/mopidy/gstreamer.py @@ -34,7 +34,12 @@ class GStreamer(ThreadingActor): """ def __init__(self): - self.gst_pipeline = None + self._pipeline = None + self._source = None + self._taginject = None + self._tee = None + self._uridecodebin = None + self._volume = None def on_start(self): self._setup_gstreamer() @@ -45,46 +50,44 @@ class GStreamer(ThreadingActor): :class:`mopidy.utils.process.GObjectEventThread` to be running. This is not enforced by :class:`GStreamer` itself. """ - base_pipeline = ' ! '.join([ + description = ' ! '.join([ + 'uridecodebin name=uri', 'audioconvert name=convert', 'volume name=volume', - 'taginject name=tag', - 'tee name=tee', - ]) + 'taginject name=inject', + 'tee name=tee']) - logger.debug(u'Setting up base GStreamer pipeline: %s', base_pipeline) + logger.debug(u'Setting up base GStreamer pipeline: %s', description) - self.gst_pipeline = gst.parse_launch(base_pipeline) + self._pipeline = gst.parse_launch(description) + self._taginject = self._pipeline.get_by_name('inject') + self._tee = self._pipeline.get_by_name('tee') + self._volume = self._pipeline.get_by_name('volume') + self._uridecodebin = self._pipeline.get_by_name('uri') - self.gst_tee = self.gst_pipeline.get_by_name('tee') - self.gst_convert = self.gst_pipeline.get_by_name('convert') - self.gst_volume = self.gst_pipeline.get_by_name('volume') - self.gst_taginject = self.gst_pipeline.get_by_name('tag') - - self.gst_uridecodebin = gst.element_factory_make('uridecodebin', 'uri') - self.gst_uridecodebin.connect('notify::source', self._process_new_source) - self.gst_uridecodebin.connect('pad-added', self._process_new_pad, - self.gst_convert.get_pad('sink')) - self.gst_pipeline.add(self.gst_uridecodebin) + self._uridecodebin.connect('notify::source', self._process_new_source) + self._uridecodebin.connect('pad-added', self._process_new_pad, + self._pipeline.get_by_name('convert').get_pad('sink')) for output in settings.OUTPUTS: output_cls = get_class(output)() - output_cls.connect_bin(self.gst_pipeline, self.gst_tee) + output_cls.connect_bin(self._pipeline, self._tee) # Setup bus and message processor - gst_bus = self.gst_pipeline.get_bus() - gst_bus.add_signal_watch() - gst_bus.connect('message', self._process_gstreamer_message) + bus = self._pipeline.get_bus() + bus.add_signal_watch() + bus.connect('message', self._process_gstreamer_message) def _process_new_source(self, element, pad): - source = element.get_by_name('source') + self._source = element.get_by_name('source') try: - source.set_property('caps', default_caps) + self._source.set_property('caps', default_caps) except TypeError: pass def _process_new_pad(self, source, pad, target_pad): - pad.link(target_pad) + if not pad.is_linked(): + pad.link(target_pad) def _process_gstreamer_message(self, bus, message): """Process messages from GStreamer.""" @@ -105,8 +108,13 @@ class GStreamer(ThreadingActor): return backend_refs[0].proxy() def set_uri(self, uri): - """Change internal uridecodebin's URI""" - self.gst_uridecodebin.set_property('uri', uri) + """ + Change internal uridecodebin's URI + + :param uri: the URI to play + :type uri: string + """ + self._uridecodebin.set_property('uri', uri) def deliver_data(self, capabilities, data): """ @@ -116,12 +124,11 @@ class GStreamer(ThreadingActor): :type capabilities: string :param data: raw audio data to be played """ - source = self.gst_pipeline.get_by_name('source') caps = gst.caps_from_string(capabilities) buffer_ = gst.Buffer(buffer(data)) buffer_.set_caps(caps) - source.set_property('caps', caps) - source.emit('push-buffer', buffer_) + self._source.set_property('caps', caps) + self._source.emit('push-buffer', buffer_) def end_of_data_stream(self): """ @@ -130,7 +137,7 @@ class GStreamer(ThreadingActor): We will get a GStreamer message when the stream playback reaches the token, and can then do any end-of-stream related tasks. """ - self.gst_pipeline.get_by_name('source').emit('end-of-stream') + self._source.emit('end-of-stream') def get_position(self): """ @@ -138,10 +145,10 @@ class GStreamer(ThreadingActor): :rtype: int """ - if self.gst_pipeline.get_state()[1] == gst.STATE_NULL: + if self._pipeline.get_state()[1] == gst.STATE_NULL: return 0 try: - position = self.gst_pipeline.query_position(gst.FORMAT_TIME)[0] + position = self._pipeline.query_position(gst.FORMAT_TIME)[0] return position // gst.MSECOND except gst.QueryError, e: logger.error('time_position failed: %s', e) @@ -155,10 +162,10 @@ class GStreamer(ThreadingActor): :type volume: int :rtype: :class:`True` if successful, else :class:`False` """ - self.gst_pipeline.get_state() # block until state changes are done - handeled = self.gst_pipeline.seek_simple(gst.Format(gst.FORMAT_TIME), + self._pipeline.get_state() # block until state changes are done + handeled = self._pipeline.seek_simple(gst.Format(gst.FORMAT_TIME), gst.SEEK_FLAG_FLUSH, position * gst.MSECOND) - self.gst_pipeline.get_state() # block until seek is done + self._pipeline.get_state() # block until seek is done return handeled def start_playback(self): @@ -199,7 +206,7 @@ class GStreamer(ThreadingActor): :type state_name: string :rtype: :class:`True` or :class:`False` """ - result = self.gst_pipeline.set_state(state) + result = self._pipeline.set_state(state) if result == gst.STATE_CHANGE_FAILURE: logger.warning('Setting GStreamer state to %s: failed', state.value_name) @@ -215,7 +222,7 @@ class GStreamer(ThreadingActor): :rtype: int in range [0..100] """ - return int(self.gst_volume.get_property('volume') * 100) + return int(self._volume.get_property('volume') * 100) def set_volume(self, volume): """ @@ -225,7 +232,7 @@ class GStreamer(ThreadingActor): :type volume: int :rtype: :class:`True` if successful, else :class:`False` """ - self.gst_volume.set_property('volume', volume / 100.0) + self._volume.set_property('volume', volume / 100.0) return True def set_metadata(self, track): @@ -244,4 +251,4 @@ class GStreamer(ThreadingActor): 'title': track.name, } logger.debug('Setting tags to: %s', tags) - self.gst_taginject.set_property('tags', tags) + self._taginject.set_property('tags', tags) From 8922ebe0aa155852559171aa09f649bd17326bec Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 5 May 2011 21:45:42 +0200 Subject: [PATCH 051/218] Log gst warnings as well --- mopidy/gstreamer.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/mopidy/gstreamer.py b/mopidy/gstreamer.py index 5b715175..5b9077a5 100644 --- a/mopidy/gstreamer.py +++ b/mopidy/gstreamer.py @@ -101,6 +101,9 @@ class GStreamer(ThreadingActor): logger.error(u'%s %s', error, debug) # FIXME Should we send 'stop_playback' to the backend here? Can we # differentiate on how serious the error is? + elif message.type == gst.MESSAGE_WARNING: + error, debug = message.parse_warning() + logger.warning(u'%s %s', error, debug) def _get_backend(self): backend_refs = ActorRegistry.get_by_class(Backend) From f34ab8761ad57e10b52a7bf59ae0ee06ca9bf43c Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 5 May 2011 21:50:40 +0200 Subject: [PATCH 052/218] Log async state changes --- mopidy/gstreamer.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/mopidy/gstreamer.py b/mopidy/gstreamer.py index 5b9077a5..79e87de4 100644 --- a/mopidy/gstreamer.py +++ b/mopidy/gstreamer.py @@ -214,6 +214,10 @@ class GStreamer(ThreadingActor): logger.warning('Setting GStreamer state to %s: failed', state.value_name) return False + elif result == gst.STATE_CHANGE_ASYNC: + logger.debug('Setting GStreamer state to %s: async', + state.value_name) + return True else: logger.debug('Setting GStreamer state to %s: OK', state.value_name) From 49db06d249ba1ac8bbca52b4960023cd0299cb77 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 5 May 2011 22:28:27 +0200 Subject: [PATCH 053/218] No need for an instance of GStreamer before the actor is started --- mopidy/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/core.py b/mopidy/core.py index 4ca6ec29..0c341a1a 100644 --- a/mopidy/core.py +++ b/mopidy/core.py @@ -58,7 +58,7 @@ def setup_gobject_loop(): return gobject_loop def setup_gstreamer(): - return GStreamer().start().proxy() + return GStreamer.start().proxy() def setup_mixer(): return get_class(settings.MIXER).start().proxy() From d536990ad4907603d10ece1c77f52045275296f7 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 5 May 2011 22:37:55 +0200 Subject: [PATCH 054/218] Update _set_state docstring --- mopidy/gstreamer.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/mopidy/gstreamer.py b/mopidy/gstreamer.py index 79e87de4..7125efe9 100644 --- a/mopidy/gstreamer.py +++ b/mopidy/gstreamer.py @@ -205,8 +205,9 @@ class GStreamer(ThreadingActor): "READY" -> "NULL" "READY" -> "PAUSED" - :param state_name: NULL, READY, PAUSED, or PLAYING - :type state_name: string + :param state: State to set pipeline to. One of: `gst.STATE_NULL`, + `gst.STATE_READY`, `gst.STATE_PAUSED` and `gst.STATE_PLAYING`. + :type state: :class:`gst.State` :rtype: :class:`True` or :class:`False` """ result = self._pipeline.set_state(state) From 65db8c4a7bb5ef7975208e75dcac040f1d009906 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Fri, 6 May 2011 22:01:39 +0200 Subject: [PATCH 055/218] Kill off *_OUTPUT_OVERRIDEs in favour of just having CustomOutput to handle corner cases --- mopidy/outputs/__init__.py | 49 +++++++++++++++++++------------------- mopidy/settings.py | 31 ++++++------------------ mopidy/utils/settings.py | 3 ++- 3 files changed, 34 insertions(+), 49 deletions(-) diff --git a/mopidy/outputs/__init__.py b/mopidy/outputs/__init__.py index e3747463..5cdb2baa 100644 --- a/mopidy/outputs/__init__.py +++ b/mopidy/outputs/__init__.py @@ -78,27 +78,40 @@ class BaseOutput(object): element.set_property(key, value) +class CustomOutput(BaseOutput): + """ + Custom output for using alternate setups. + + This output is intended to handle to main cases: + + 1. Simple things like switching which sink to use. Say :class:`LocalOutput` + doesn't work for you and you want to switch to ALSA, simple. Set + `CUSTOM_OUTPUT` to `alsasink` and you are good to go. Some possible + sinks include: + + - alsasink + - osssink + - pulsesink + - ...and many more + + 2. Advanced setups that require complete control of the output bin. For + these cases setup `CUSTOM_OUTPUT` with a `gst-launch` compatible string + describing the target setup. + + """ + def describe_bin(self): + return settings.CUSTOM_OUTPUT + + class LocalOutput(BaseOutput): """ Basic output to local audio sink. 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. - - Advanced: - - However, there are chases when you want to explicitly set what GStreamer - should use. This can be achieved by setting `settings.LOCAL_OUTPUT_OVERRIDE` - to the sink you want to use. Some of the possible values are: alsasink, - esdsink, jackaudiosink, oss4sink, osssink and pulsesink. Exact values that - will work on your system will depend on your sound setup and installed - GStreamer plugins. Run `gst-inspect0.10` for list of all available plugins. - Also note that this accepts properties and bins in `gst-launch` format. """ def describe_bin(self): - if settings.LOCAL_OUTPUT_OVERRIDE: - return settings.LOCAL_OUTPUT_OVERRIDE return 'autoaudiosink' @@ -124,25 +137,13 @@ 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. - - Advanced: - - If you need to do something special that this output has not taken into - account the setting `settings.SHOUTCAST_OUTPUT_OVERRIDE` has been provided - to allow for manual setup of the bin using a gst-launch string. If this - setting is set all other shoutcast settings will be ignored. """ def describe_bin(self): - if settings.SHOUTCAST_OUTPUT_OVERRIDE: - return settings.SHOUTCAST_OUTPUT_OVERRIDE return 'audioconvert ! %s ! shout2send name=shoutcast' \ % settings.SHOUTCAST_OUTPUT_ENCODER def modify_bin(self, output): - if settings.SHOUTCAST_OUTPUT_OVERRIDE: - return - self.set_properties(output.get_by_name('shoutcast'), { u'ip': settings.SHOUTCAST_OUTPUT_SERVER, u'mount': settings.SHOUTCAST_OUTPUT_MOUNT, diff --git a/mopidy/settings.py b/mopidy/settings.py index 78abb6b7..1aa4a630 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -26,6 +26,13 @@ BACKENDS = ( #: details on the format. CONSOLE_LOG_FORMAT = u'%(levelname)-8s %(message)s' +#: Which GStreamer bin description to use in :class:`mopidy.outputs.CustomOutput`. +#: +#: Default:: +#: +#: CUSTOM_OUTPUT = u'fakesink' +CUSTOM_OUTPUT = u'fakesink' + #: The log format used for debug logging. #: #: See http://docs.python.org/library/logging.html#formatter-objects for @@ -54,13 +61,6 @@ FRONTENDS = ( u'mopidy.frontends.lastfm.LastfmFrontend', ) -#: Which GStreamer bin description to use in :class:`mopidy.outputs.LocalOutput`. -#: -#: Default:: -#: -#: LOCAL_OUTPUT_OVERRIDE = None -LOCAL_OUTPUT_OVERRIDE = None - #: Your `Last.fm `_ username. #: #: Used by :mod:`mopidy.frontends.lastfm`. @@ -221,23 +221,6 @@ SHOUTCAST_OUTPUT_MOUNT = u'/stream' #: SHOUTCAST_OUTPUT_ENCODER = u'lame mode=stereo bitrate=320' SHOUTCAST_OUTPUT_ENCODER = u'lame mode=stereo bitrate=320' -#: Overrides to allow advanced setup of shoutcast. Using this settings implies -#: that all other SHOUTCAST_OUTPUT_* settings will be ignored. -#: -#: Examples: -#: -#: ``vorbisenc ! oggmux ! shout2send mount=/stream port=8000`` -#: Encode with vorbis and use ogg mux. -#: ``lame bitrate=320 ! shout2send mount=/stream port=8000`` -#: Encode with lame to bitrate=320. -#: -#: For all options see gst-inspect-0.10 lame, vorbisenc and shout2send. -#: -#: Default:: -#: -#: SHOUTCAST_OUTPUT_OVERRIDE = None -SHOUTCAST_OUTPUT_OVERRIDE = None - #: Path to the Spotify cache. #: #: Used by :mod:`mopidy.backends.spotify`. diff --git a/mopidy/utils/settings.py b/mopidy/utils/settings.py index 0dc6b4cb..7f541c21 100644 --- a/mopidy/utils/settings.py +++ b/mopidy/utils/settings.py @@ -97,8 +97,9 @@ def validate_settings(defaults, settings): 'DUMP_LOG_FILENAME': 'DEBUG_LOG_FILENAME', 'DUMP_LOG_FORMAT': 'DEBUG_LOG_FORMAT', 'FRONTEND': 'FRONTENDS', - 'GSTREAMER_AUDIO_SINK': 'LOCAL_OUTPUT_OVERRIDE', + 'GSTREAMER_AUDIO_SINK': 'CUSTOM_OUTPUT', 'LOCAL_MUSIC_FOLDER': 'LOCAL_MUSIC_PATH', + 'LOCAL_OUTPUT_OVERRIDE': 'CUSTOM_OUTPUT', 'LOCAL_PLAYLIST_FOLDER': 'LOCAL_PLAYLIST_PATH', 'LOCAL_TAG_CACHE': 'LOCAL_TAG_CACHE_FILE', 'OUTPUT': None, From ebee9620201ad9609e8b6d6a5322672d4bd52479 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 8 May 2011 01:38:04 +0200 Subject: [PATCH 056/218] Test that --help returns the options we expect it to --- tests/help_test.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 tests/help_test.py diff --git a/tests/help_test.py b/tests/help_test.py new file mode 100644 index 00000000..a64877c6 --- /dev/null +++ b/tests/help_test.py @@ -0,0 +1,19 @@ +import os +import subprocess +import sys +import unittest + +import mopidy + +class HelpTest(unittest.TestCase): + def test_help_has_mopidy_options(self): + mopidy_dir = os.path.dirname(mopidy.__file__) + args = [sys.executable, mopidy_dir, '--help'] + process = subprocess.Popen(args, stdout=subprocess.PIPE) + output = process.communicate()[0] + self.assert_('--version' in output) + self.assert_('--help' in output) + self.assert_('--quiet' in output) + self.assert_('--verbose' in output) + self.assert_('--save-debug-log' in output) + self.assert_('--list-settings' in output) From 644c87128b1fef5f5b6568b54d176a192d72eca0 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 8 May 2011 01:41:30 +0200 Subject: [PATCH 057/218] Naive workaround for #95 --- mopidy/core.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/mopidy/core.py b/mopidy/core.py index d0f2a668..cd49dfa1 100644 --- a/mopidy/core.py +++ b/mopidy/core.py @@ -1,7 +1,17 @@ import logging import optparse +import sys import time +# Extract any non-GStreamer arguments, and leave the GStreamer arguments for +# processing by GStreamer. This needs to be done before GStreamer is imported, +# so that GStreamer doesn't hijack e.g. ``--help``. +# NOTE This naive fix does not support values like ``bar`` in +# ``--gst-foo bar``. Use equals to pass values, like ``--gst-foo=bar``. +gstreamer_args = [arg for arg in sys.argv[1:] if arg.startswith('--gst')] +mopidy_args = [arg for arg in sys.argv[1:] if not arg.startswith('--gst')] +sys.argv[1:] = gstreamer_args + from pykka.registry import ActorRegistry from mopidy import get_version, settings, OptionalDependencyError @@ -45,7 +55,7 @@ def parse_options(): parser.add_option('--list-settings', action='callback', callback=list_settings_optparse_callback, help='list current settings') - return parser.parse_args()[0] + return parser.parse_args(args=mopidy_args)[0] def setup_settings(): get_or_create_folder('~/.mopidy/') From 3d705b3e022171d5c1148d9d3826f73b14ff1edc Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 8 May 2011 17:01:05 +0200 Subject: [PATCH 058/218] restify docstrings of CustomOutput --- mopidy/outputs/__init__.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/mopidy/outputs/__init__.py b/mopidy/outputs/__init__.py index 5cdb2baa..d7aacaab 100644 --- a/mopidy/outputs/__init__.py +++ b/mopidy/outputs/__init__.py @@ -82,12 +82,12 @@ class CustomOutput(BaseOutput): """ Custom output for using alternate setups. - This output is intended to handle to main cases: + This output is intended to handle two main cases: 1. Simple things like switching which sink to use. Say :class:`LocalOutput` doesn't work for you and you want to switch to ALSA, simple. Set - `CUSTOM_OUTPUT` to `alsasink` and you are good to go. Some possible - sinks include: + :attr:`mopidy.settings.CUSTOM_OUTPUT` to ``alsasink`` and you are good + to go. Some possible sinks include: - alsasink - osssink @@ -95,8 +95,8 @@ class CustomOutput(BaseOutput): - ...and many more 2. Advanced setups that require complete control of the output bin. For - these cases setup `CUSTOM_OUTPUT` with a `gst-launch` compatible string - describing the target setup. + these cases setup :attr:`mopidy.settings.CUSTOM_OUTPUT` with a + :cmd:`gst-launch` compatible string describing the target setup. """ def describe_bin(self): From 10e1f2abab7b47047f88aeb67e8ebda20f1b637d Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 8 May 2011 19:53:09 +0200 Subject: [PATCH 059/218] Switch to using gst.TAG_* in scanner --- mopidy/scanner.py | 48 +++++++++++++++++------------------------------ 1 file changed, 17 insertions(+), 31 deletions(-) diff --git a/mopidy/scanner.py b/mopidy/scanner.py index 93224331..c603c578 100644 --- a/mopidy/scanner.py +++ b/mopidy/scanner.py @@ -16,48 +16,34 @@ def translator(data): artist_kwargs = {} track_kwargs = {} - # FIXME replace with data.get('foo', None) ? + def _retrieve(source_key, target_key, target): + if source_key in data: + target[target_key] = data[source_key] - if 'album' in data: - album_kwargs['name'] = data['album'] + _retrieve(gst.TAG_ALBUM, 'name', album_kwargs) + _retrieve(gst.TAG_TRACK_COUNT, 'num_tracks', album_kwargs) + _retrieve(gst.TAG_ARTIST, 'name', artist_kwargs) - if 'track-count' in data: - album_kwargs['num_tracks'] = data['track-count'] - - if 'artist' in data: - artist_kwargs['name'] = data['artist'] - - if 'date' in data: - date = data['date'] + if gst.TAG_DATE in data: + date = data[gst.TAG_DATE] date = datetime.date(date.year, date.month, date.day) track_kwargs['date'] = date - if 'title' in data: - track_kwargs['name'] = data['title'] + _retrieve(gst.TAG_TITLE, 'name', track_kwargs) + _retrieve(gst.TAG_TRACK_NUMBER, 'track_no', track_kwargs) - if 'track-number' in data: - track_kwargs['track_no'] = data['track-number'] - - if 'album-artist' in data: - albumartist_kwargs['name'] = data['album-artist'] - - if 'musicbrainz-trackid' in data: - track_kwargs['musicbrainz_id'] = data['musicbrainz-trackid'] - - if 'musicbrainz-artistid' in data: - artist_kwargs['musicbrainz_id'] = data['musicbrainz-artistid'] - - if 'musicbrainz-albumid' in data: - album_kwargs['musicbrainz_id'] = data['musicbrainz-albumid'] - - if 'musicbrainz-albumartistid' in data: - albumartist_kwargs['musicbrainz_id'] = data['musicbrainz-albumartistid'] + # Following keys don't seem to have TAG_* constant. + _retrieve('album-artist', 'name', albumartist_kwargs) + _retrieve('musicbrainz-trackid', 'musicbrainz_id', track_kwargs) + _retrieve('musicbrainz-artistid', 'musicbrainz_id', artist_kwargs) + _retrieve('musicbrainz-albumid', 'musicbrainz_id', album_kwargs) + _retrieve('musicbrainz-albumartistid', 'musicbrainz_id', albumartist_kwargs) if albumartist_kwargs: album_kwargs['artists'] = [Artist(**albumartist_kwargs)] track_kwargs['uri'] = data['uri'] - track_kwargs['length'] = data['duration'] + track_kwargs['length'] = data[gst.TAG_DURATION] track_kwargs['album'] = Album(**album_kwargs) track_kwargs['artists'] = [Artist(**artist_kwargs)] From f035ea31ef45e66324e01944be974de60256b79e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 9 May 2011 23:13:06 +0200 Subject: [PATCH 060/218] Allow GStreamer to process --help-gst --- mopidy/core.py | 6 ++++-- tests/help_test.py | 7 +++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/mopidy/core.py b/mopidy/core.py index cd49dfa1..61a47c4b 100644 --- a/mopidy/core.py +++ b/mopidy/core.py @@ -8,8 +8,10 @@ import time # so that GStreamer doesn't hijack e.g. ``--help``. # NOTE This naive fix does not support values like ``bar`` in # ``--gst-foo bar``. Use equals to pass values, like ``--gst-foo=bar``. -gstreamer_args = [arg for arg in sys.argv[1:] if arg.startswith('--gst')] -mopidy_args = [arg for arg in sys.argv[1:] if not arg.startswith('--gst')] +def is_gst_arg(arg): + return arg.startswith('--gst') or arg == '--help-gst' +gstreamer_args = [arg for arg in sys.argv[1:] if is_gst_arg(arg)] +mopidy_args = [arg for arg in sys.argv[1:] if not is_gst_arg(arg)] sys.argv[1:] = gstreamer_args from pykka.registry import ActorRegistry diff --git a/tests/help_test.py b/tests/help_test.py index a64877c6..502673be 100644 --- a/tests/help_test.py +++ b/tests/help_test.py @@ -17,3 +17,10 @@ class HelpTest(unittest.TestCase): self.assert_('--verbose' in output) self.assert_('--save-debug-log' in output) self.assert_('--list-settings' in output) + + def test_help_gst_has_gstreamer_options(self): + mopidy_dir = os.path.dirname(mopidy.__file__) + args = [sys.executable, mopidy_dir, '--help-gst'] + process = subprocess.Popen(args, stdout=subprocess.PIPE) + output = process.communicate()[0] + self.assert_('--gst-version' in output) From b796661dc1cfd28853cb8bbf24485dbde8972c1a Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 9 May 2011 23:16:45 +0200 Subject: [PATCH 061/218] List --help-gst in Mopidy's --help listing --- mopidy/core.py | 3 +++ tests/help_test.py | 1 + 2 files changed, 4 insertions(+) diff --git a/mopidy/core.py b/mopidy/core.py index 61a47c4b..e510b698 100644 --- a/mopidy/core.py +++ b/mopidy/core.py @@ -45,6 +45,9 @@ def main(): def parse_options(): parser = optparse.OptionParser(version=u'Mopidy %s' % get_version()) + parser.add_option('--help-gst', + action='store_true', dest='help_gst', + help='show GStreamer help options') parser.add_option('-q', '--quiet', action='store_const', const=0, dest='verbosity_level', help='less output (warning level)') diff --git a/tests/help_test.py b/tests/help_test.py index 502673be..dccccc9c 100644 --- a/tests/help_test.py +++ b/tests/help_test.py @@ -13,6 +13,7 @@ class HelpTest(unittest.TestCase): output = process.communicate()[0] self.assert_('--version' in output) self.assert_('--help' in output) + self.assert_('--help-gst' in output) self.assert_('--quiet' in output) self.assert_('--verbose' in output) self.assert_('--save-debug-log' in output) From b6c1f5fc1518613aacfe3a5f6db2347a8a04f288 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 9 May 2011 23:25:29 +0200 Subject: [PATCH 062/218] Update changelog with fix for #95 --- docs/changes.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/changes.rst b/docs/changes.rst index cca12aab..f8f01129 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -21,6 +21,9 @@ No description yet. - Fix local backend time query errors that where coming from stopped pipeline. (Fixes: :issue:`87`) +- Support passing options to GStreamer. See :option:`--help-gst` for a list of + available options. (Fixes: :issue:`95`) + 0.4.1 (2011-05-06) ================== From 3dd9fc8564917eefef3d53f0d0b0e4b078a3d7b6 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 9 May 2011 23:37:56 +0200 Subject: [PATCH 063/218] Should be :command:, not :cmd: --- mopidy/outputs/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/outputs/__init__.py b/mopidy/outputs/__init__.py index d7aacaab..d1512617 100644 --- a/mopidy/outputs/__init__.py +++ b/mopidy/outputs/__init__.py @@ -96,7 +96,7 @@ class CustomOutput(BaseOutput): 2. Advanced setups that require complete control of the output bin. For these cases setup :attr:`mopidy.settings.CUSTOM_OUTPUT` with a - :cmd:`gst-launch` compatible string describing the target setup. + :command:`gst-launch` compatible string describing the target setup. """ def describe_bin(self): From 9d3a5aa054b1f309266688bea5db0efc58241e47 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 9 May 2011 23:47:11 +0200 Subject: [PATCH 064/218] Fix error due to including BaseOutput twice in the docs --- docs/api/outputs.rst | 3 +++ docs/modules/outputs.rst | 12 +++++++++--- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/docs/api/outputs.rst b/docs/api/outputs.rst index b96c909e..062eabdd 100644 --- a/docs/api/outputs.rst +++ b/docs/api/outputs.rst @@ -1,3 +1,5 @@ +.. _output-api: + ********** Output API ********** @@ -11,6 +13,7 @@ Outputs are used by :mod:`mopidy.gstreamer` to output audio in some way. Output implementations ====================== +* :class:`mopidy.outputs.CustomOutput` * :class:`mopidy.outputs.LocalOutput` * :class:`mopidy.outputs.NullOutput` * :class:`mopidy.outputs.ShoutcastOutput` diff --git a/docs/modules/outputs.rst b/docs/modules/outputs.rst index 0a6986dd..7da29fbc 100644 --- a/docs/modules/outputs.rst +++ b/docs/modules/outputs.rst @@ -2,8 +2,14 @@ :mod:`mopidy.outputs` -- GStreamer audio outputs ************************************************ +The following GStreamer audio outputs implements the :ref:`output-api`. + .. inheritance-diagram:: mopidy.outputs -.. automodule:: mopidy.outputs - :synopsis: GStreamer audio outputs - :members: +.. autoclass:: mopidy.outputs.CustomOutput + +.. autoclass:: mopidy.outputs.LocalOutput + +.. autoclass:: mopidy.outputs.NullOutput + +.. autoclass:: mopidy.outputs.ShoutcastOutput From 1c83fe236dfb2531fef5bee31227c9d124f7738f Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 10 May 2011 17:37:04 +0200 Subject: [PATCH 065/218] Remove unused import --- mopidy/utils/log.py | 1 - 1 file changed, 1 deletion(-) diff --git a/mopidy/utils/log.py b/mopidy/utils/log.py index 531b68b6..03b85b48 100644 --- a/mopidy/utils/log.py +++ b/mopidy/utils/log.py @@ -1,6 +1,5 @@ import logging import logging.handlers -import platform from mopidy import get_version, get_platform, get_python, settings From d98e218baa38749b6633f4fa2f2af3a9581567f7 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 10 May 2011 22:24:23 +0200 Subject: [PATCH 066/218] Explain a bit more about why prepare_change is needed --- mopidy/gstreamer.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/mopidy/gstreamer.py b/mopidy/gstreamer.py index 7125efe9..3c8941fa 100644 --- a/mopidy/gstreamer.py +++ b/mopidy/gstreamer.py @@ -184,7 +184,9 @@ class GStreamer(ThreadingActor): Notify GStreamer that we are about to change state of playback. This function always needs to be called before changing URIS or doing - changes like updating data that is being pushed. + changes like updating data that is being pushed. The reason for this + is that GStreamer will reset all its state when it changes to + :attr:`gst.STATE_READY`. """ return self._set_state(gst.STATE_READY) From d136984f9fcd2d18a2827ae94347ae2aece125f3 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Fri, 13 May 2011 20:13:00 +0200 Subject: [PATCH 067/218] Split outputs into seperate files --- docs/api/outputs.rst | 8 ++-- docs/modules/outputs.rst | 8 ++-- mopidy/outputs/__init__.py | 77 ------------------------------------- mopidy/outputs/custom.py | 26 +++++++++++++ mopidy/outputs/local.py | 12 ++++++ mopidy/outputs/null.py | 16 ++++++++ mopidy/outputs/shoutcast.py | 25 ++++++++++++ mopidy/settings.py | 4 +- 8 files changed, 89 insertions(+), 87 deletions(-) create mode 100644 mopidy/outputs/custom.py create mode 100644 mopidy/outputs/local.py create mode 100644 mopidy/outputs/null.py create mode 100644 mopidy/outputs/shoutcast.py diff --git a/docs/api/outputs.rst b/docs/api/outputs.rst index 062eabdd..d801116d 100644 --- a/docs/api/outputs.rst +++ b/docs/api/outputs.rst @@ -13,7 +13,7 @@ Outputs are used by :mod:`mopidy.gstreamer` to output audio in some way. Output implementations ====================== -* :class:`mopidy.outputs.CustomOutput` -* :class:`mopidy.outputs.LocalOutput` -* :class:`mopidy.outputs.NullOutput` -* :class:`mopidy.outputs.ShoutcastOutput` +* :class:`mopidy.outputs.custom.CustomOutput` +* :class:`mopidy.outputs.local.LocalOutput` +* :class:`mopidy.outputs.null.NullOutput` +* :class:`mopidy.outputs.shoutcast.ShoutcastOutput` diff --git a/docs/modules/outputs.rst b/docs/modules/outputs.rst index 7da29fbc..c7a45d84 100644 --- a/docs/modules/outputs.rst +++ b/docs/modules/outputs.rst @@ -6,10 +6,10 @@ The following GStreamer audio outputs implements the :ref:`output-api`. .. inheritance-diagram:: mopidy.outputs -.. autoclass:: mopidy.outputs.CustomOutput +.. autoclass:: mopidy.outputs.custom.CustomOutput -.. autoclass:: mopidy.outputs.LocalOutput +.. autoclass:: mopidy.outputs.local.LocalOutput -.. autoclass:: mopidy.outputs.NullOutput +.. autoclass:: mopidy.outputs.null.NullOutput -.. autoclass:: mopidy.outputs.ShoutcastOutput +.. autoclass:: mopidy.outputs.shoutcast.ShoutcastOutput diff --git a/mopidy/outputs/__init__.py b/mopidy/outputs/__init__.py index d1512617..a3aff0d8 100644 --- a/mopidy/outputs/__init__.py +++ b/mopidy/outputs/__init__.py @@ -4,8 +4,6 @@ import pygst pygst.require('0.10') import gst -from mopidy import settings - logger = logging.getLogger('mopidy.outputs') @@ -76,78 +74,3 @@ class BaseOutput(object): for key, value in properties.items(): if value is not None: element.set_property(key, value) - - -class CustomOutput(BaseOutput): - """ - Custom output for using alternate setups. - - This output is intended to handle two main cases: - - 1. Simple things like switching which sink to use. Say :class:`LocalOutput` - doesn't work for you and you want to switch to ALSA, simple. Set - :attr:`mopidy.settings.CUSTOM_OUTPUT` to ``alsasink`` and you are good - to go. Some possible sinks include: - - - alsasink - - osssink - - pulsesink - - ...and many more - - 2. Advanced setups that require complete control of the output bin. For - these cases setup :attr:`mopidy.settings.CUSTOM_OUTPUT` with a - :command:`gst-launch` compatible string describing the target setup. - - """ - def describe_bin(self): - return settings.CUSTOM_OUTPUT - - -class LocalOutput(BaseOutput): - """ - Basic output to local audio sink. - - 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. - """ - - def describe_bin(self): - return 'autoaudiosink' - - -class NullOutput(BaseOutput): - """ - Fall-back null output. - - This output will not output anything. It is intended as a fall-back for - when setup of all other outputs have failed and should not be used by end - users. Inserting this output in such a case ensures that the pipeline does - not crash. - """ - - def describe_bin(self): - return 'fakesink' - - -class ShoutcastOutput(BaseOutput): - """ - Shoutcast streaming output. - - This output allows for streaming to an icecast server or anything else that - 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. - """ - - def describe_bin(self): - return 'audioconvert ! %s ! shout2send name=shoutcast' \ - % settings.SHOUTCAST_OUTPUT_ENCODER - - def modify_bin(self, output): - self.set_properties(output.get_by_name('shoutcast'), { - u'ip': settings.SHOUTCAST_OUTPUT_SERVER, - u'mount': settings.SHOUTCAST_OUTPUT_MOUNT, - u'port': settings.SHOUTCAST_OUTPUT_PORT, - u'username': settings.SHOUTCAST_OUTPUT_USERNAME, - u'password': settings.SHOUTCAST_OUTPUT_PASSWORD, - }) diff --git a/mopidy/outputs/custom.py b/mopidy/outputs/custom.py new file mode 100644 index 00000000..c5ca30bb --- /dev/null +++ b/mopidy/outputs/custom.py @@ -0,0 +1,26 @@ +from mopidy import settings +from mopidy.outputs import BaseOutput + +class CustomOutput(BaseOutput): + """ + Custom output for using alternate setups. + + This output is intended to handle two main cases: + + 1. Simple things like switching which sink to use. Say :class:`LocalOutput` + doesn't work for you and you want to switch to ALSA, simple. Set + :attr:`mopidy.settings.CUSTOM_OUTPUT` to ``alsasink`` and you are good + to go. Some possible sinks include: + + - alsasink + - osssink + - pulsesink + - ...and many more + + 2. Advanced setups that require complete control of the output bin. For + these cases setup :attr:`mopidy.settings.CUSTOM_OUTPUT` with a + :command:`gst-launch` compatible string describing the target setup. + + """ + def describe_bin(self): + return settings.CUSTOM_OUTPUT diff --git a/mopidy/outputs/local.py b/mopidy/outputs/local.py new file mode 100644 index 00000000..e004a076 --- /dev/null +++ b/mopidy/outputs/local.py @@ -0,0 +1,12 @@ +from mopidy.outputs import BaseOutput + +class LocalOutput(BaseOutput): + """ + Basic output to local audio sink. + + 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. + """ + + def describe_bin(self): + return 'autoaudiosink' diff --git a/mopidy/outputs/null.py b/mopidy/outputs/null.py new file mode 100644 index 00000000..975b9724 --- /dev/null +++ b/mopidy/outputs/null.py @@ -0,0 +1,16 @@ +from mopidy.outputs import BaseOutput + +class NullOutput(BaseOutput): + """ + Fall-back null output. + + This output will not output anything. It is intended as a fall-back for + when setup of all other outputs have failed and should not be used by end + users. Inserting this output in such a case ensures that the pipeline does + not crash. + """ + + def describe_bin(self): + return 'fakesink' + + diff --git a/mopidy/outputs/shoutcast.py b/mopidy/outputs/shoutcast.py new file mode 100644 index 00000000..d13b1085 --- /dev/null +++ b/mopidy/outputs/shoutcast.py @@ -0,0 +1,25 @@ +from mopidy import settings +from mopidy.outputs import BaseOutput + +class ShoutcastOutput(BaseOutput): + """ + Shoutcast streaming output. + + This output allows for streaming to an icecast server or anything else that + 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. + """ + + def describe_bin(self): + return 'audioconvert ! %s ! shout2send name=shoutcast' \ + % settings.SHOUTCAST_OUTPUT_ENCODER + + def modify_bin(self, output): + self.set_properties(output.get_by_name('shoutcast'), { + u'ip': settings.SHOUTCAST_OUTPUT_SERVER, + u'mount': settings.SHOUTCAST_OUTPUT_MOUNT, + u'port': settings.SHOUTCAST_OUTPUT_PORT, + u'username': settings.SHOUTCAST_OUTPUT_USERNAME, + u'password': settings.SHOUTCAST_OUTPUT_PASSWORD, + }) diff --git a/mopidy/settings.py b/mopidy/settings.py index 1aa4a630..414c79ee 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -173,10 +173,10 @@ MPD_SERVER_PORT = 6600 #: Default:: #: #: OUTPUTS = ( -#: u'mopidy.outputs.LocalOutput', +#: u'mopidy.outputs.local.LocalOutput', #: ) OUTPUTS = ( - u'mopidy.outputs.LocalOutput', + u'mopidy.outputs.local.LocalOutput', ) #: Servar that runs Shoutcast server to send stream to. From 0ec662db63512d49a8bfc14ce026c0639d1f6968 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 14 May 2011 23:46:51 +0200 Subject: [PATCH 068/218] Move output connection code to gstreamer --- mopidy/gstreamer.py | 16 ++++++++++++++-- mopidy/outputs/__init__.py | 25 ++++++------------------- 2 files changed, 20 insertions(+), 21 deletions(-) diff --git a/mopidy/gstreamer.py b/mopidy/gstreamer.py index 3c8941fa..cd0a1f69 100644 --- a/mopidy/gstreamer.py +++ b/mopidy/gstreamer.py @@ -70,8 +70,7 @@ class GStreamer(ThreadingActor): self._pipeline.get_by_name('convert').get_pad('sink')) for output in settings.OUTPUTS: - output_cls = get_class(output)() - output_cls.connect_bin(self._pipeline, self._tee) + self.connect_output(get_class(output)) # Setup bus and message processor bus = self._pipeline.get_bus() @@ -262,3 +261,16 @@ class GStreamer(ThreadingActor): } logger.debug('Setting tags to: %s', tags) self._taginject.set_property('tags', tags) + + def connect_output(self, cls): + """ + Connect output to pipeline. + + :param output: output to connect to our pipeline. + :type output: :class:`BaseOutput` + """ + output = cls().get_bin() + + self._pipeline.add(output) + output.sync_state_with_parent() # Required to add to running pipe + gst.element_link_many(self._tee, output) diff --git a/mopidy/outputs/__init__.py b/mopidy/outputs/__init__.py index a3aff0d8..c2b2fc6d 100644 --- a/mopidy/outputs/__init__.py +++ b/mopidy/outputs/__init__.py @@ -1,38 +1,25 @@ -import logging - import pygst pygst.require('0.10') import gst -logger = logging.getLogger('mopidy.outputs') +import logging +logger = logging.getLogger('mopidy.outputs') class BaseOutput(object): """Base class for providing support for multiple pluggable outputs.""" - def connect_bin(self, pipeline, element): + def get_bin(self): """ - Connect output bin to pipeline and given element. - - In normal cases the element will probably be a `tee`, - thus allowing us to connect any number of outputs. This - however is why each bin is forced to have its own `queue` - after the `tee`. - - :param pipeline: gst.Pipeline to add output to. - :type pipeline: :class:`gst.Pipeline` - :param element: gst.Element in pipeline to connect output to. - :type element: :class:`gst.Element` + Build output bin that will attached to pipeline. """ description = 'queue ! %s' % self.describe_bin() - logger.debug('Adding new output to tee: %s', description) + logger.debug('Creating new output: %s', description) output = gst.parse_bin_from_description(description, True) self.modify_bin(output) - pipeline.add(output) - output.sync_state_with_parent() # Required to add to running pipe - gst.element_link_many(element, output) + return output def modify_bin(self, output): """ From fe3f5338dd32fb85c0a7165a64ccdf85ad30bbff Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 14 May 2011 23:54:19 +0200 Subject: [PATCH 069/218] Store outputs that have been added in gstreamer class --- mopidy/gstreamer.py | 6 ++++++ mopidy/outputs/__init__.py | 1 + 2 files changed, 7 insertions(+) diff --git a/mopidy/gstreamer.py b/mopidy/gstreamer.py index cd0a1f69..a581191d 100644 --- a/mopidy/gstreamer.py +++ b/mopidy/gstreamer.py @@ -40,6 +40,7 @@ class GStreamer(ThreadingActor): self._tee = None self._uridecodebin = None self._volume = None + self._outputs = {} def on_start(self): self._setup_gstreamer() @@ -274,3 +275,8 @@ class GStreamer(ThreadingActor): self._pipeline.add(output) output.sync_state_with_parent() # Required to add to running pipe gst.element_link_many(self._tee, output) + + self._outputs[output.get_name()] = output + + def list_outputs(self): + return self._outputs.keys() diff --git a/mopidy/outputs/__init__.py b/mopidy/outputs/__init__.py index c2b2fc6d..28bad40a 100644 --- a/mopidy/outputs/__init__.py +++ b/mopidy/outputs/__init__.py @@ -17,6 +17,7 @@ class BaseOutput(object): logger.debug('Creating new output: %s', description) output = gst.parse_bin_from_description(description, True) + output.set_name(self.__class__.__name__) self.modify_bin(output) return output From 743235b09d7bfc93febae1ba886c2a28e6f8cea7 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 15 May 2011 23:50:52 +0200 Subject: [PATCH 070/218] Add basic remove_output code --- mopidy/gstreamer.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/mopidy/gstreamer.py b/mopidy/gstreamer.py index a581191d..fbb5d44a 100644 --- a/mopidy/gstreamer.py +++ b/mopidy/gstreamer.py @@ -278,5 +278,22 @@ class GStreamer(ThreadingActor): self._outputs[output.get_name()] = output + logger.info('Added %s', output.get_name()) + def list_outputs(self): return self._outputs.keys() + + def remove_output(self, name): + if name not in self._outputs: + return # FIXME raise mopidy exception of some sort? + src = self._taginject.get_pad('src') + src.set_blocked_async(True, self._blocked_callback, name) + + def _blocked_callback(self, pad, blocked, name): + output = self._outputs.pop(name) + gst.element_unlink_many(self._tee, output) + output.set_state(gst.STATE_NULL) + self._pipeline.remove(output) + pad.set_blocked(False) + + logger.warning(u'Removed %s', name) From 3f35e9b3913bb99cf7b299c36528eefa878337f4 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 16 May 2011 00:14:33 +0200 Subject: [PATCH 071/218] Add method to determine output name --- mopidy/outputs/__init__.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/mopidy/outputs/__init__.py b/mopidy/outputs/__init__.py index 28bad40a..d2a67b88 100644 --- a/mopidy/outputs/__init__.py +++ b/mopidy/outputs/__init__.py @@ -17,11 +17,19 @@ class BaseOutput(object): logger.debug('Creating new output: %s', description) output = gst.parse_bin_from_description(description, True) - output.set_name(self.__class__.__name__) + output.set_name(self.get_name()) self.modify_bin(output) return output + def get_name(self): + """ + Return name of output in gstreamer context. + + Defaults to class name, can be overriden by sub classes if required. + """ + return self.__class__.__name__ + def modify_bin(self, output): """ Modifies bin before it is installed if needed. From 09a1d646f24efea5ff4d6170f370582d29a4eca9 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 16 May 2011 21:08:01 +0200 Subject: [PATCH 072/218] Refactor BaseOutput to prepare for better error handling --- mopidy/gstreamer.py | 25 +++++++++++-------------- mopidy/outputs/__init__.py | 28 ++++++++++++++++++---------- mopidy/outputs/shoutcast.py | 5 +++-- 3 files changed, 32 insertions(+), 26 deletions(-) diff --git a/mopidy/gstreamer.py b/mopidy/gstreamer.py index fbb5d44a..9b702b6b 100644 --- a/mopidy/gstreamer.py +++ b/mopidy/gstreamer.py @@ -40,7 +40,7 @@ class GStreamer(ThreadingActor): self._tee = None self._uridecodebin = None self._volume = None - self._outputs = {} + self._outputs = [] def on_start(self): self._setup_gstreamer() @@ -71,7 +71,7 @@ class GStreamer(ThreadingActor): self._pipeline.get_by_name('convert').get_pad('sink')) for output in settings.OUTPUTS: - self.connect_output(get_class(output)) + get_class(output)(self).connect() # Setup bus and message processor bus = self._pipeline.get_bus() @@ -263,37 +263,34 @@ class GStreamer(ThreadingActor): logger.debug('Setting tags to: %s', tags) self._taginject.set_property('tags', tags) - def connect_output(self, cls): + def connect_output(self, output): """ Connect output to pipeline. :param output: output to connect to our pipeline. - :type output: :class:`BaseOutput` + :type output: :class:`gst.Bin` """ - output = cls().get_bin() - self._pipeline.add(output) output.sync_state_with_parent() # Required to add to running pipe gst.element_link_many(self._tee, output) - - self._outputs[output.get_name()] = output - + self._outputs.append(output) logger.info('Added %s', output.get_name()) def list_outputs(self): return self._outputs.keys() - def remove_output(self, name): + def remove_output(self, output): + logger.debug('Trying to remove %s', output.get_name()) if name not in self._outputs: return # FIXME raise mopidy exception of some sort? src = self._taginject.get_pad('src') - src.set_blocked_async(True, self._blocked_callback, name) + src.set_blocked_async(True, self._blocked_callback, output) - def _blocked_callback(self, pad, blocked, name): - output = self._outputs.pop(name) + def _blocked_callback(self, pad, blocked, output): gst.element_unlink_many(self._tee, output) output.set_state(gst.STATE_NULL) self._pipeline.remove(output) + self._outputs.remove(output) pad.set_blocked(False) - logger.warning(u'Removed %s', name) + logger.warning(u'Removed %s', output.get_name()) diff --git a/mopidy/outputs/__init__.py b/mopidy/outputs/__init__.py index d2a67b88..ea1f511d 100644 --- a/mopidy/outputs/__init__.py +++ b/mopidy/outputs/__init__.py @@ -9,18 +9,29 @@ logger = logging.getLogger('mopidy.outputs') class BaseOutput(object): """Base class for providing support for multiple pluggable outputs.""" - def get_bin(self): + def __init__(self, gstreamer): + self.gstreamer = gstreamer + self.bin = self.build_bin() + self.bin.set_name(self.get_name()) + + self.modify_bin() + + def build_bin(self): """ Build output bin that will attached to pipeline. """ description = 'queue ! %s' % self.describe_bin() logger.debug('Creating new output: %s', description) - output = gst.parse_bin_from_description(description, True) - output.set_name(self.get_name()) - self.modify_bin(output) + return gst.parse_bin_from_description(description, True) - return output + def connect(self): + """Convenience wrapper to attach output to GStreamer pipeline""" + self.gstreamer.connect_output(self.bin) + + def remove(self): + """Convenience wrapper to remove output from GStreamer pipeline""" + self.gstreamer.remove_output(self.bin) def get_name(self): """ @@ -30,16 +41,13 @@ class BaseOutput(object): """ return self.__class__.__name__ - def modify_bin(self, output): + def modify_bin(self): """ - Modifies bin before it is installed if needed. + Modifies ``self.bin`` before it is installed if needed. Overriding this method allows for outputs to modify the constructed bin before it is installed. This can for instance be a good place to call `set_properties` on elements that need to be configured. - - :param output: gst.Bin to modify in some way. - :type output: :class:`gst.Bin` """ pass diff --git a/mopidy/outputs/shoutcast.py b/mopidy/outputs/shoutcast.py index d13b1085..26af449b 100644 --- a/mopidy/outputs/shoutcast.py +++ b/mopidy/outputs/shoutcast.py @@ -15,8 +15,9 @@ class ShoutcastOutput(BaseOutput): return 'audioconvert ! %s ! shout2send name=shoutcast' \ % settings.SHOUTCAST_OUTPUT_ENCODER - def modify_bin(self, output): - self.set_properties(output.get_by_name('shoutcast'), { + def modify_bin(self): + shoutcast = self.bin.get_by_name('shoutcast') + self.set_properties(shoutcast, { u'ip': settings.SHOUTCAST_OUTPUT_SERVER, u'mount': settings.SHOUTCAST_OUTPUT_MOUNT, u'port': settings.SHOUTCAST_OUTPUT_PORT, From f7f26403afc5215c204c710fbb459fc72e989a99 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 16 May 2011 21:49:19 +0200 Subject: [PATCH 073/218] Move stop playback in error handler to after the error is logged to make cause and effect more obvious --- mopidy/gstreamer.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/mopidy/gstreamer.py b/mopidy/gstreamer.py index 9b702b6b..7c12e579 100644 --- a/mopidy/gstreamer.py +++ b/mopidy/gstreamer.py @@ -96,11 +96,9 @@ class GStreamer(ThreadingActor): 'Telling backend ...') self._get_backend().playback.on_end_of_track() elif message.type == gst.MESSAGE_ERROR: - self.stop_playback() error, debug = message.parse_error() logger.error(u'%s %s', error, debug) - # FIXME Should we send 'stop_playback' to the backend here? Can we - # differentiate on how serious the error is? + self.stop_playback() elif message.type == gst.MESSAGE_WARNING: error, debug = message.parse_warning() logger.warning(u'%s %s', error, debug) From 64a6202ea124db6cab2d5b8030a3b97b511d5c53 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 16 May 2011 21:50:02 +0200 Subject: [PATCH 074/218] Add gst MESSAGE_* constansts to BaseOutput --- mopidy/outputs/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/mopidy/outputs/__init__.py b/mopidy/outputs/__init__.py index ea1f511d..0423a62b 100644 --- a/mopidy/outputs/__init__.py +++ b/mopidy/outputs/__init__.py @@ -9,6 +9,10 @@ logger = logging.getLogger('mopidy.outputs') class BaseOutput(object): """Base class for providing support for multiple pluggable outputs.""" + MESSAGE_EOS = gst.MESSAGE_EOS + MESSAGE_ERROR = gst.MESSAGE_ERROR + MESSAGE_WARNING = gst.MESSAGE_WARNING + def __init__(self, gstreamer): self.gstreamer = gstreamer self.bin = self.build_bin() From fae784b71edd23f0ed6ba5055152082e1eebff1c Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 16 May 2011 21:51:38 +0200 Subject: [PATCH 075/218] Add on_remove and on_connect hooks to BaseOutput --- mopidy/outputs/__init__.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/mopidy/outputs/__init__.py b/mopidy/outputs/__init__.py index 0423a62b..8cf57c53 100644 --- a/mopidy/outputs/__init__.py +++ b/mopidy/outputs/__init__.py @@ -30,12 +30,20 @@ class BaseOutput(object): return gst.parse_bin_from_description(description, True) def connect(self): - """Convenience wrapper to attach output to GStreamer pipeline""" - self.gstreamer.connect_output(self.bin) + """Attach output to GStreamer pipeline""" + self.gstreamer.connect_output(self) + self.on_connect() + + def on_connect(self): + pass def remove(self): - """Convenience wrapper to remove output from GStreamer pipeline""" - self.gstreamer.remove_output(self.bin) + """Remove output from GStreamer pipeline""" + self.gstreamer.remove_output(self) + self.on_remove() + + def on_remove(self): + pass def get_name(self): """ From 943645aecf4e7c0ef1b27c84494372e4212f46b8 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 16 May 2011 21:53:01 +0200 Subject: [PATCH 076/218] Add methods to register and register message handlers to GStreamer --- mopidy/gstreamer.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/mopidy/gstreamer.py b/mopidy/gstreamer.py index 7c12e579..69258f81 100644 --- a/mopidy/gstreamer.py +++ b/mopidy/gstreamer.py @@ -41,6 +41,7 @@ class GStreamer(ThreadingActor): self._uridecodebin = None self._volume = None self._outputs = [] + self._handlers = {} def on_start(self): self._setup_gstreamer() @@ -292,3 +293,9 @@ class GStreamer(ThreadingActor): pad.set_blocked(False) logger.warning(u'Removed %s', output.get_name()) + + def connect_message_handler(self, element, handler): + self._handlers[element] = handler + + def remove_message_handler(self, element): + self._handlers.pop(element, None) From 217472362051f5be52c48e9d7fcfaa2d27c702aa Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 16 May 2011 21:53:53 +0200 Subject: [PATCH 077/218] Add conditional to message handler so that attached handlers can take over --- mopidy/gstreamer.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/mopidy/gstreamer.py b/mopidy/gstreamer.py index 69258f81..66e46c14 100644 --- a/mopidy/gstreamer.py +++ b/mopidy/gstreamer.py @@ -92,6 +92,10 @@ class GStreamer(ThreadingActor): def _process_gstreamer_message(self, bus, message): """Process messages from GStreamer.""" + if message.src in self._handlers: + if self._handlers[message.src](message): + return # Message was handeled by output + if message.type == gst.MESSAGE_EOS: logger.debug(u'GStreamer signalled end-of-stream. ' 'Telling backend ...') From 94efb083ee3a5b0fc9ebac9266a48ac4bf2fd9a6 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 16 May 2011 21:55:00 +0200 Subject: [PATCH 078/218] Fixed list outputs method in GStreamer class --- mopidy/gstreamer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/gstreamer.py b/mopidy/gstreamer.py index 66e46c14..255e7d7d 100644 --- a/mopidy/gstreamer.py +++ b/mopidy/gstreamer.py @@ -280,7 +280,7 @@ class GStreamer(ThreadingActor): logger.info('Added %s', output.get_name()) def list_outputs(self): - return self._outputs.keys() + return [output.get_name() for output in self._outputs] def remove_output(self, output): logger.debug('Trying to remove %s', output.get_name()) From a4b03aa292c6532aa90d5e04163fb604278bd777 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 16 May 2011 21:57:23 +0200 Subject: [PATCH 079/218] Cleanup some of output code api --- mopidy/gstreamer.py | 5 ++--- mopidy/outputs/__init__.py | 4 ++-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/mopidy/gstreamer.py b/mopidy/gstreamer.py index 255e7d7d..0e866135 100644 --- a/mopidy/gstreamer.py +++ b/mopidy/gstreamer.py @@ -284,7 +284,7 @@ class GStreamer(ThreadingActor): def remove_output(self, output): logger.debug('Trying to remove %s', output.get_name()) - if name not in self._outputs: + if output not in self._outputs: return # FIXME raise mopidy exception of some sort? src = self._taginject.get_pad('src') src.set_blocked_async(True, self._blocked_callback, output) @@ -293,9 +293,8 @@ class GStreamer(ThreadingActor): gst.element_unlink_many(self._tee, output) output.set_state(gst.STATE_NULL) self._pipeline.remove(output) - self._outputs.remove(output) pad.set_blocked(False) - + self._outputs.remove(output) logger.warning(u'Removed %s', output.get_name()) def connect_message_handler(self, element, handler): diff --git a/mopidy/outputs/__init__.py b/mopidy/outputs/__init__.py index 8cf57c53..b59566b3 100644 --- a/mopidy/outputs/__init__.py +++ b/mopidy/outputs/__init__.py @@ -31,7 +31,7 @@ class BaseOutput(object): def connect(self): """Attach output to GStreamer pipeline""" - self.gstreamer.connect_output(self) + self.gstreamer.connect_output(self.bin) self.on_connect() def on_connect(self): @@ -39,7 +39,7 @@ class BaseOutput(object): def remove(self): """Remove output from GStreamer pipeline""" - self.gstreamer.remove_output(self) + self.gstreamer.remove_output(self.bin) self.on_remove() def on_remove(self): From 2ef550eb7bb8506cf5621c5404c58443576a7fc3 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 16 May 2011 22:01:30 +0200 Subject: [PATCH 080/218] Add error handling code for shoutcast errors --- mopidy/outputs/shoutcast.py | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/mopidy/outputs/shoutcast.py b/mopidy/outputs/shoutcast.py index 26af449b..4298bba5 100644 --- a/mopidy/outputs/shoutcast.py +++ b/mopidy/outputs/shoutcast.py @@ -1,6 +1,10 @@ +import logging + from mopidy import settings from mopidy.outputs import BaseOutput +logger = logging.getLogger('mopidy.outputs.shoutcast') + class ShoutcastOutput(BaseOutput): """ Shoutcast streaming output. @@ -16,11 +20,26 @@ class ShoutcastOutput(BaseOutput): % settings.SHOUTCAST_OUTPUT_ENCODER def modify_bin(self): - shoutcast = self.bin.get_by_name('shoutcast') - self.set_properties(shoutcast, { + self.set_properties(self.bin.get_by_name('shoutcast'), { u'ip': settings.SHOUTCAST_OUTPUT_SERVER, u'mount': settings.SHOUTCAST_OUTPUT_MOUNT, u'port': settings.SHOUTCAST_OUTPUT_PORT, u'username': settings.SHOUTCAST_OUTPUT_USERNAME, u'password': settings.SHOUTCAST_OUTPUT_PASSWORD, }) + + def on_connect(self): + self.gstreamer.connect_message_handler( + self.bin.get_by_name('shoutcast'), self.message_handler) + + def on_remove(self): + self.gstreamer.remove_message_handler( + self.bin.get_by_name('shoutcast')) + + def message_handler(self, message): + if message.type != self.MESSAGE_ERROR: + return False + error, debug = message.parse_error() + logger.warning('%s (%s)', error, debug) + self.remove() + return True From 82bd77e24b2042c7d046e9ef5c3e46eb96959fb2 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 16 May 2011 23:14:53 +0200 Subject: [PATCH 081/218] Fixed use of callbacks to prevent removeall of broken output from stopping playback --- mopidy/gstreamer.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/mopidy/gstreamer.py b/mopidy/gstreamer.py index 0e866135..36fda08b 100644 --- a/mopidy/gstreamer.py +++ b/mopidy/gstreamer.py @@ -283,17 +283,18 @@ class GStreamer(ThreadingActor): return [output.get_name() for output in self._outputs] def remove_output(self, output): - logger.debug('Trying to remove %s', output.get_name()) if output not in self._outputs: return # FIXME raise mopidy exception of some sort? - src = self._taginject.get_pad('src') + src = output.get_pad('sink').get_peer() src.set_blocked_async(True, self._blocked_callback, output) def _blocked_callback(self, pad, blocked, output): gst.element_unlink_many(self._tee, output) + pad.set_blocked_async(False, self._unblocked_callback, output) + + def _unblocked_callback(self, pad, blocked, output): output.set_state(gst.STATE_NULL) self._pipeline.remove(output) - pad.set_blocked(False) self._outputs.remove(output) logger.warning(u'Removed %s', output.get_name()) From 801b3d1155c00a89d2438d1bad439d296a146e1f Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 17 May 2011 01:21:50 +0200 Subject: [PATCH 082/218] Switch to event probe based solution to removing outputs. Based on http://lists.freedesktop.org/archives/gstreamer-devel/2009-August/023708.html --- mopidy/gstreamer.py | 40 ++++++++++++++++++++++++++++++---------- 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/mopidy/gstreamer.py b/mopidy/gstreamer.py index 36fda08b..78a3c9bf 100644 --- a/mopidy/gstreamer.py +++ b/mopidy/gstreamer.py @@ -285,18 +285,38 @@ class GStreamer(ThreadingActor): def remove_output(self, output): if output not in self._outputs: return # FIXME raise mopidy exception of some sort? - src = output.get_pad('sink').get_peer() - src.set_blocked_async(True, self._blocked_callback, output) + teesrc = output.get_pad('sink').get_peer() + handler = teesrc.add_event_probe(self._handle_event_probe) - def _blocked_callback(self, pad, blocked, output): - gst.element_unlink_many(self._tee, output) - pad.set_blocked_async(False, self._unblocked_callback, output) + struct = gst.Structure('mopidy-unlink-tee') + struct.set_value('handler', handler) - def _unblocked_callback(self, pad, blocked, output): - output.set_state(gst.STATE_NULL) - self._pipeline.remove(output) - self._outputs.remove(output) - logger.warning(u'Removed %s', output.get_name()) + event = gst.event_new_custom(gst.EVENT_CUSTOM_DOWNSTREAM, struct) + self._tee.send_event(event) + + def _handle_event_probe(self, teesrc, event): + if event.type == gst.EVENT_CUSTOM_DOWNSTREAM and event.has_name('mopidy-unlink-tee'): + data = self._get_structure_data(event.get_structure()) + + output = teesrc.get_peer().get_parent() + + teesrc.unlink(teesrc.get_peer()) + teesrc.remove_event_probe(data['handler']) + + output.set_state(gst.STATE_NULL) + self._pipeline.remove(output) + + logger.warning('Removed %s', output.get_name()) + return False + return True + + def _get_structure_data(self, struct): + # Ugly hack to get around missing get_value in pygst bindings :/ + data = {} + def get_data(key, value): + data[key] = value + struct.foreach(get_data) + return data def connect_message_handler(self, element, handler): self._handlers[element] = handler From eb5facd4b6cc234d9fb7c738845bef1c4fc427a6 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 17 May 2011 16:10:28 +0200 Subject: [PATCH 083/218] Add docstrings --- mopidy/gstreamer.py | 33 +++++++++++++++++++++++++++++++++ mopidy/outputs/__init__.py | 2 ++ 2 files changed, 35 insertions(+) diff --git a/mopidy/gstreamer.py b/mopidy/gstreamer.py index 78a3c9bf..dc1bd73f 100644 --- a/mopidy/gstreamer.py +++ b/mopidy/gstreamer.py @@ -283,6 +283,12 @@ class GStreamer(ThreadingActor): return [output.get_name() for output in self._outputs] def remove_output(self, output): + """ + Remove output from our pipeline. + + :param output: output to remove from our pipeline. + :type output: :class:`gst.Bin` + """ if output not in self._outputs: return # FIXME raise mopidy exception of some sort? teesrc = output.get_pad('sink').get_peer() @@ -319,7 +325,34 @@ class GStreamer(ThreadingActor): return data def connect_message_handler(self, element, handler): + """ + Attach custom message handler for given element. + + Hook to allow outputs (or other code) to register custom message + handlers for all message comming from the element in question. + + In the case of outputs ``on_connect`` should be used to attach such + handlers and care should be taken to remove them in ``on_remove``. + + The handler callback will only be given the message in question, and + is free to ignore the message. However, if the handler wants to prevent + the default handling of the message it should return ``True`` indicating + that the message has been handeled. + + (Note that there can only be on handler per element) + + :param element: element to watch messages from. + :type element: :class:`gst.Element` + :param handler: function that expects `gst.Message`, should return + ``True`` if message has been handeled. + """ self._handlers[element] = handler def remove_message_handler(self, element): + """ + Remove custom message handler. + + :param element: element to remove message handling from. + :type element: :class:`gst.Element` + """ self._handlers.pop(element, None) diff --git a/mopidy/outputs/__init__.py b/mopidy/outputs/__init__.py index b59566b3..ab27b87f 100644 --- a/mopidy/outputs/__init__.py +++ b/mopidy/outputs/__init__.py @@ -35,6 +35,7 @@ class BaseOutput(object): self.on_connect() def on_connect(self): + """Called after output has been connected to GStreamer pipeline""" pass def remove(self): @@ -43,6 +44,7 @@ class BaseOutput(object): self.on_remove() def on_remove(self): + """Called after output has been remove from GStreamer pipeline""" pass def get_name(self): From d57bb281c3c5ed3d7ca2888e8408bc64941b2033 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 18 May 2011 21:16:58 +0200 Subject: [PATCH 084/218] Fixed docs based on comments on pull request --- mopidy/gstreamer.py | 16 +++++++++------- mopidy/outputs/__init__.py | 2 +- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/mopidy/gstreamer.py b/mopidy/gstreamer.py index dc1bd73f..6e4ad05f 100644 --- a/mopidy/gstreamer.py +++ b/mopidy/gstreamer.py @@ -290,7 +290,8 @@ class GStreamer(ThreadingActor): :type output: :class:`gst.Bin` """ if output not in self._outputs: - return # FIXME raise mopidy exception of some sort? + raise LookupError('Ouput %s not present in pipeline' + % output.get_name) teesrc = output.get_pad('sink').get_peer() handler = teesrc.add_event_probe(self._handle_event_probe) @@ -329,19 +330,20 @@ class GStreamer(ThreadingActor): Attach custom message handler for given element. Hook to allow outputs (or other code) to register custom message - handlers for all message comming from the element in question. + handlers for all messages coming from the element in question. - In the case of outputs ``on_connect`` should be used to attach such - handlers and care should be taken to remove them in ``on_remove``. + In the case of outputs :meth:`mopidy.outputs.BaseOuptut.on_connect` + should be used to attach such handlers and care should be taken to + remove them in :meth:`mopidy.outputs.BaseOuptut.on_remove`. The handler callback will only be given the message in question, and is free to ignore the message. However, if the handler wants to prevent - the default handling of the message it should return ``True`` indicating - that the message has been handeled. + the default handling of the message it should return :class:`True` + indicating that the message has been handled. (Note that there can only be on handler per element) - :param element: element to watch messages from. + :param element: element to watch messages from :type element: :class:`gst.Element` :param handler: function that expects `gst.Message`, should return ``True`` if message has been handeled. diff --git a/mopidy/outputs/__init__.py b/mopidy/outputs/__init__.py index ab27b87f..a85088a6 100644 --- a/mopidy/outputs/__init__.py +++ b/mopidy/outputs/__init__.py @@ -51,7 +51,7 @@ class BaseOutput(object): """ Return name of output in gstreamer context. - Defaults to class name, can be overriden by sub classes if required. + Defaults to class name, can be overriden by subclasses if required. """ return self.__class__.__name__ From 31aaec883069ba7a1090d2f3f1d8f205a0825515 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 19 May 2011 19:31:14 +0200 Subject: [PATCH 085/218] Update docs on GStreamer module. Some refactoring of (mostly internal) methods. --- mopidy/backends/spotify/session_manager.py | 4 +- mopidy/gstreamer.py | 102 +++++++++++++-------- 2 files changed, 66 insertions(+), 40 deletions(-) diff --git a/mopidy/backends/spotify/session_manager.py b/mopidy/backends/spotify/session_manager.py index 09064db2..f34283c6 100644 --- a/mopidy/backends/spotify/session_manager.py +++ b/mopidy/backends/spotify/session_manager.py @@ -106,7 +106,7 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager): 'sample_rate': sample_rate, 'channels': channels, } - self.gstreamer.deliver_data(capabilites, bytes(frames)) + self.gstreamer.emit_data(capabilites, bytes(frames)) def play_token_lost(self, session): """Callback used by pyspotify""" @@ -120,7 +120,7 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager): def end_of_track(self, session): """Callback used by pyspotify""" logger.debug(u'End of data stream reached') - self.gstreamer.end_of_data_stream() + self.gstreamer.emit_end_of_stream() def refresh_stored_playlists(self): """Refresh the stored playlists in the backend with fresh meta data diff --git a/mopidy/gstreamer.py b/mopidy/gstreamer.py index 6e4ad05f..c79b742f 100644 --- a/mopidy/gstreamer.py +++ b/mopidy/gstreamer.py @@ -44,14 +44,14 @@ class GStreamer(ThreadingActor): self._handlers = {} def on_start(self): - self._setup_gstreamer() + # **Warning:** :class:`GStreamer` requires + # :class:`mopidy.utils.process.GObjectEventThread` to be running. This + # is not enforced by :class:`GStreamer` itself. + self._setup_pipeline() + self._setup_outputs() + self._setup_message_processor() - def _setup_gstreamer(self): - """ - **Warning:** :class:`GStreamer` requires - :class:`mopidy.utils.process.GObjectEventThread` to be running. This is - not enforced by :class:`GStreamer` itself. - """ + def _setup_pipeline(self): description = ' ! '.join([ 'uridecodebin name=uri', 'audioconvert name=convert', @@ -67,31 +67,31 @@ class GStreamer(ThreadingActor): self._volume = self._pipeline.get_by_name('volume') self._uridecodebin = self._pipeline.get_by_name('uri') - self._uridecodebin.connect('notify::source', self._process_new_source) - self._uridecodebin.connect('pad-added', self._process_new_pad, + self._uridecodebin.connect('notify::source', self._on_new_source) + self._uridecodebin.connect('pad-added', self._on_new_pad, self._pipeline.get_by_name('convert').get_pad('sink')) + def _setup_outputs(self): for output in settings.OUTPUTS: get_class(output)(self).connect() - # Setup bus and message processor + def _setup_message_processor(self): bus = self._pipeline.get_bus() bus.add_signal_watch() - bus.connect('message', self._process_gstreamer_message) + bus.connect('message', self._on_message) - def _process_new_source(self, element, pad): + def _on_new_source(self, element, pad): self._source = element.get_by_name('source') try: self._source.set_property('caps', default_caps) except TypeError: pass - def _process_new_pad(self, source, pad, target_pad): + def _on_new_pad(self, source, pad, target_pad): if not pad.is_linked(): pad.link(target_pad) - def _process_gstreamer_message(self, bus, message): - """Process messages from GStreamer.""" + def _on_message(self, bus, message): if message.src in self._handlers: if self._handlers[message.src](message): return # Message was handeled by output @@ -115,16 +115,18 @@ class GStreamer(ThreadingActor): def set_uri(self, uri): """ - Change internal uridecodebin's URI + Set URI of audio to be played. + + You *MUST* call :meth:`prepare_change` before calling this method. :param uri: the URI to play :type uri: string """ self._uridecodebin.set_property('uri', uri) - def deliver_data(self, capabilities, data): + def emit_data(self, capabilities, data): """ - Deliver audio data to be played + Call this to deliver raw audio data to be played. :param capabilities: a GStreamer capabilities string :type capabilities: string @@ -136,9 +138,10 @@ class GStreamer(ThreadingActor): self._source.set_property('caps', caps) self._source.emit('push-buffer', buffer_) - def end_of_data_stream(self): + def emit_end_of_stream(self): """ - Add end-of-stream token to source. + Put an end-of-stream token on the pipeline. This is typically used in + combination with :meth:`emit_data`. We will get a GStreamer message when the stream playback reaches the token, and can then do any end-of-stream related tasks. @@ -175,18 +178,26 @@ class GStreamer(ThreadingActor): return handeled def start_playback(self): - """Notify GStreamer that it should start playback""" + """ + Notify GStreamer that it should start playback. + + :rtype: :class:`True` if successfull, else :class:`False` + """ return self._set_state(gst.STATE_PLAYING) def pause_playback(self): - """Notify GStreamer that it should pause playback""" + """ + Notify GStreamer that it should pause playback. + + :rtype: :class:`True` if successfull, else :class:`False` + """ return self._set_state(gst.STATE_PAUSED) def prepare_change(self): """ Notify GStreamer that we are about to change state of playback. - This function always needs to be called before changing URIS or doing + This function *MUST* be called before changing URIs or doing changes like updating data that is being pushed. The reason for this is that GStreamer will reset all its state when it changes to :attr:`gst.STATE_READY`. @@ -194,15 +205,22 @@ class GStreamer(ThreadingActor): return self._set_state(gst.STATE_READY) def stop_playback(self): - """Notify GStreamer that is should stop playback""" + """ + Notify GStreamer that is should stop playback. + + :rtype: :class:`True` if successfull, else :class:`False` + """ return self._set_state(gst.STATE_NULL) def _set_state(self, state): """ - Set the GStreamer state. Returns :class:`True` if successful. + Internal method for setting the raw GStreamer state. .. digraph:: gst_state_transitions + graph [rankdir="LR"]; + node [fontsize=10]; + "NULL" -> "READY" "PAUSED" -> "PLAYING" "PAUSED" -> "READY" @@ -213,7 +231,7 @@ class GStreamer(ThreadingActor): :param state: State to set pipeline to. One of: `gst.STATE_NULL`, `gst.STATE_READY`, `gst.STATE_PAUSED` and `gst.STATE_PLAYING`. :type state: :class:`gst.State` - :rtype: :class:`True` or :class:`False` + :rtype: :class:`True` if successfull, else :class:`False` """ result = self._pipeline.set_state(state) if result == gst.STATE_CHANGE_FAILURE: @@ -231,7 +249,7 @@ class GStreamer(ThreadingActor): def get_volume(self): """ - Get volume level for software mixer. + Get volume level of the GStreamer software mixer. :rtype: int in range [0..100] """ @@ -239,7 +257,7 @@ class GStreamer(ThreadingActor): def set_volume(self, volume): """ - Set volume level for software mixer. + Set volume level of the GStreamer software mixer. :param volume: the volume in the range [0..100] :type volume: int @@ -252,10 +270,11 @@ class GStreamer(ThreadingActor): """ Set track metadata for currently playing song. - Only needs to be called by sources such as appsrc which don't already - inject tags in pipeline. + Only needs to be called by sources such as `appsrc` which do not + already inject tags in pipeline, e.g. when using :meth:`emit_data` to + deliver raw audio data to GStreamer. - :param track: Track containing metadata for current song. + :param track: the current track :type track: :class:`mopidy.modes.Track` """ # FIXME what if we want to unset taginject tags? @@ -270,7 +289,7 @@ class GStreamer(ThreadingActor): """ Connect output to pipeline. - :param output: output to connect to our pipeline. + :param output: output to connect to the pipeline :type output: :class:`gst.Bin` """ self._pipeline.add(output) @@ -280,13 +299,18 @@ class GStreamer(ThreadingActor): logger.info('Added %s', output.get_name()) def list_outputs(self): + """ + Get list with the name of all active outputs. + + :rtype: list of strings + """ return [output.get_name() for output in self._outputs] def remove_output(self, output): """ Remove output from our pipeline. - :param output: output to remove from our pipeline. + :param output: output to remove from the pipeline :type output: :class:`gst.Bin` """ if output not in self._outputs: @@ -332,21 +356,23 @@ class GStreamer(ThreadingActor): Hook to allow outputs (or other code) to register custom message handlers for all messages coming from the element in question. - In the case of outputs :meth:`mopidy.outputs.BaseOuptut.on_connect` + In the case of outputs, :meth:`mopidy.outputs.BaseOutput.on_connect` should be used to attach such handlers and care should be taken to - remove them in :meth:`mopidy.outputs.BaseOuptut.on_remove`. + remove them in :meth:`mopidy.outputs.BaseOutput.on_remove` using + :meth:`remove_message_handler`. The handler callback will only be given the message in question, and is free to ignore the message. However, if the handler wants to prevent the default handling of the message it should return :class:`True` indicating that the message has been handled. - (Note that there can only be on handler per element) + Note that there can only be one handler per element. :param element: element to watch messages from :type element: :class:`gst.Element` - :param handler: function that expects `gst.Message`, should return - ``True`` if message has been handeled. + :param handler: callable that takes :class:`gst.Message` and returns + :class:`True` if the message has been handeled + :type handler: callable """ self._handlers[element] = handler From e53e0aa78f5afae7e50d1acbf75123e40613634e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 19 May 2011 20:51:30 +0200 Subject: [PATCH 086/218] Update BaseOutput docs. Make build_bin() private. --- mopidy/outputs/__init__.py | 55 +++++++++++++++++++++++--------------- 1 file changed, 33 insertions(+), 22 deletions(-) diff --git a/mopidy/outputs/__init__.py b/mopidy/outputs/__init__.py index a85088a6..ba242c4b 100644 --- a/mopidy/outputs/__init__.py +++ b/mopidy/outputs/__init__.py @@ -7,7 +7,7 @@ import logging logger = logging.getLogger('mopidy.outputs') class BaseOutput(object): - """Base class for providing support for multiple pluggable outputs.""" + """Base class for pluggable audio outputs.""" MESSAGE_EOS = gst.MESSAGE_EOS MESSAGE_ERROR = gst.MESSAGE_ERROR @@ -15,43 +15,49 @@ class BaseOutput(object): def __init__(self, gstreamer): self.gstreamer = gstreamer - self.bin = self.build_bin() + self.bin = self._build_bin() self.bin.set_name(self.get_name()) self.modify_bin() - def build_bin(self): - """ - Build output bin that will attached to pipeline. - """ + def _build_bin(self): description = 'queue ! %s' % self.describe_bin() logger.debug('Creating new output: %s', description) - return gst.parse_bin_from_description(description, True) def connect(self): - """Attach output to GStreamer pipeline""" + """Attach output to GStreamer pipeline.""" self.gstreamer.connect_output(self.bin) self.on_connect() def on_connect(self): - """Called after output has been connected to GStreamer pipeline""" + """ + Called after output has been connected to GStreamer pipeline. + + *MAY be implemented by subclass.* + """ pass def remove(self): - """Remove output from GStreamer pipeline""" + """Remove output from GStreamer pipeline.""" self.gstreamer.remove_output(self.bin) self.on_remove() def on_remove(self): - """Called after output has been remove from GStreamer pipeline""" + """ + Called after output has been removed from GStreamer pipeline. + + *MAY be implemented by subclass.* + """ pass def get_name(self): """ - Return name of output in gstreamer context. + Get name of the output. Defaults to the output's class name. - Defaults to class name, can be overriden by subclasses if required. + *MAY be implemented by subclass.* + + :rtype: string """ return self.__class__.__name__ @@ -62,31 +68,36 @@ class BaseOutput(object): Overriding this method allows for outputs to modify the constructed bin before it is installed. This can for instance be a good place to call `set_properties` on elements that need to be configured. + + *MAY be implemented by subclass.* """ pass def describe_bin(self): """ - Return text string describing bin in gst-launch format. + Return string describing the output bin in :command:`gst-launch` + format. - For simple cases this can just be a plain sink such as `autoaudiosink` - or it can be a chain `element1 ! element2 ! sink`. See `man - gst-launch0.10` for details on format. + For simple cases this can just be a sink such as ``autoaudiosink``, + or it can be a chain like ``element1 ! element2 ! sink``. See the + manpage of :command:`gst-launch` for details on the format. *MUST be implemented by subclass.* + + :rtype: string """ raise NotImplementedError def set_properties(self, element, properties): """ - Helper to allow for simple setting of properties on elements. + Helper method for setting of properties on elements. - Will call `set_property` on the element for each key that has a value - that is not None. + Will call :meth:`gst.Element.set_property` on ``element`` for each key + in ``properties`` that has a value that is not :class:`None`. - :param element: gst.Element to set properties on. + :param element: element to set properties on :type element: :class:`gst.Element` - :param properties: Dictionary of properties to set on element. + :param properties: properties to set on element :type properties: dict """ for key, value in properties.items(): From 7074b4928c2be867b375212db903a75f5a12ca70 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 19 May 2011 22:30:09 +0200 Subject: [PATCH 087/218] Add some tests for --list-settings formatting --- mopidy/utils/settings.py | 7 +++++-- tests/utils/settings_test.py | 35 +++++++++++++++++++++++++++++++++-- 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/mopidy/utils/settings.py b/mopidy/utils/settings.py index 7f541c21..eeb5c32f 100644 --- a/mopidy/utils/settings.py +++ b/mopidy/utils/settings.py @@ -140,6 +140,10 @@ def list_settings_optparse_callback(*args): option. """ from mopidy import settings + print u'Settings: %s' % indent(format_settings_list(settings), places=2) + sys.exit(0) + +def format_settings_list(settings): errors = settings.get_errors() lines = [] for (key, value) in sorted(settings.current.iteritems()): @@ -151,8 +155,7 @@ def list_settings_optparse_callback(*args): lines.append(u' Default: %s' % repr(default_value)) if errors.get(key) is not None: lines.append(u' Error: %s' % errors[key]) - print u'Settings: %s' % indent('\n'.join(lines), places=2) - sys.exit(0) + return '\n'.join(lines) def mask_value_if_secret(key, value): if key.endswith('PASSWORD') and value: diff --git a/tests/utils/settings_test.py b/tests/utils/settings_test.py index 11914f61..f23ce06d 100644 --- a/tests/utils/settings_test.py +++ b/tests/utils/settings_test.py @@ -2,8 +2,8 @@ import os import unittest from mopidy import settings as default_settings_module, SettingsError -from mopidy.utils.settings import validate_settings, SettingsProxy -from mopidy.utils.settings import mask_value_if_secret +from mopidy.utils.settings import (format_settings_list, mask_value_if_secret, + SettingsProxy, validate_settings) class ValidateSettingsTest(unittest.TestCase): def setUp(self): @@ -140,3 +140,34 @@ class SettingsProxyTest(unittest.TestCase): self.settings.TEST = './test' actual = self.settings.TEST self.assertEqual(actual, './test') + + +class FormatSettingListTest(unittest.TestCase): + def setUp(self): + self.settings = SettingsProxy(default_settings_module) + + def test_contains_the_setting_name(self): + self.settings.TEST = u'test' + result = format_settings_list(self.settings) + self.assert_('TEST:' in result, result) + + def test_repr_of_a_string_value(self): + self.settings.TEST = u'test' + result = format_settings_list(self.settings) + self.assert_("Value: u'test'" in result, result) + + def test_repr_of_an_int_value(self): + self.settings.TEST = 123 + result = format_settings_list(self.settings) + self.assert_("Value: 123" in result, result) + + def test_repr_of_a_tuple_value(self): + self.settings.TEST = (123, u'abc') + result = format_settings_list(self.settings) + self.assert_("Value: (123, u'abc')" in result, result) + + def test_passwords_are_masked(self): + self.settings.TEST_PASSWORD = u'secret' + result = format_settings_list(self.settings) + self.assert_("Value: u'secret'" not in result, result) + self.assert_("Value: u'********'" in result, result) From a760a7fdb4be2c0840752f3262abe8033676bc5b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 19 May 2011 22:31:04 +0200 Subject: [PATCH 088/218] Use pprint to format long setting values --- mopidy/utils/settings.py | 5 +++-- tests/utils/settings_test.py | 15 +++++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/mopidy/utils/settings.py b/mopidy/utils/settings.py index eeb5c32f..e80731af 100644 --- a/mopidy/utils/settings.py +++ b/mopidy/utils/settings.py @@ -3,6 +3,7 @@ from __future__ import absolute_import from copy import copy import logging import os +from pprint import pformat import sys from mopidy import SettingsError @@ -150,9 +151,9 @@ def format_settings_list(settings): default_value = settings.default.get(key) value = mask_value_if_secret(key, value) lines.append(u'%s:' % key) - lines.append(u' Value: %s' % repr(value)) + lines.append(u' Value: %s' % indent(pformat(value))) if value != default_value and default_value is not None: - lines.append(u' Default: %s' % repr(default_value)) + lines.append(u' Default: %s' % indent(pformat(default_value))) if errors.get(key) is not None: lines.append(u' Error: %s' % errors[key]) return '\n'.join(lines) diff --git a/tests/utils/settings_test.py b/tests/utils/settings_test.py index f23ce06d..896f1b6a 100644 --- a/tests/utils/settings_test.py +++ b/tests/utils/settings_test.py @@ -171,3 +171,18 @@ class FormatSettingListTest(unittest.TestCase): result = format_settings_list(self.settings) self.assert_("Value: u'secret'" not in result, result) self.assert_("Value: u'********'" in result, result) + + def test_short_values_are_not_pretty_printed(self): + self.settings.FRONTEND = (u'mopidy.frontends.mpd.MpdFrontend',) + result = format_settings_list(self.settings) + self.assert_("Value: (u'mopidy.frontends.mpd.MpdFrontend',)" in result, + result) + + def test_long_values_are_pretty_printed(self): + self.settings.FRONTEND = (u'mopidy.frontends.mpd.MpdFrontend', + u'mopidy.frontends.lastfm.LastfmFrontend') + result = format_settings_list(self.settings) + self.assert_("""FRONTEND: + Value: + (u'mopidy.frontends.mpd.MpdFrontend', + u'mopidy.frontends.lastfm.LastfmFrontend')""" in result, result) From 0b2b0c5c3c8480f677979d5774477946d2a8ef5a Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 19 May 2011 22:37:09 +0200 Subject: [PATCH 089/218] Show current setting value on same line as setting name --- mopidy/utils/settings.py | 6 +++--- tests/utils/settings_test.py | 19 +++++++++---------- 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/mopidy/utils/settings.py b/mopidy/utils/settings.py index e80731af..0a8dbb1a 100644 --- a/mopidy/utils/settings.py +++ b/mopidy/utils/settings.py @@ -150,10 +150,10 @@ def format_settings_list(settings): for (key, value) in sorted(settings.current.iteritems()): default_value = settings.default.get(key) value = mask_value_if_secret(key, value) - lines.append(u'%s:' % key) - lines.append(u' Value: %s' % indent(pformat(value))) + lines.append(u'%s: %s' % (key, indent(pformat(value), places=2))) if value != default_value and default_value is not None: - lines.append(u' Default: %s' % indent(pformat(default_value))) + lines.append(u' Default: %s' % + indent(pformat(default_value), places=4)) if errors.get(key) is not None: lines.append(u' Error: %s' % errors[key]) return '\n'.join(lines) diff --git a/tests/utils/settings_test.py b/tests/utils/settings_test.py index 896f1b6a..1ffff9a6 100644 --- a/tests/utils/settings_test.py +++ b/tests/utils/settings_test.py @@ -154,35 +154,34 @@ class FormatSettingListTest(unittest.TestCase): def test_repr_of_a_string_value(self): self.settings.TEST = u'test' result = format_settings_list(self.settings) - self.assert_("Value: u'test'" in result, result) + self.assert_("TEST: u'test'" in result, result) def test_repr_of_an_int_value(self): self.settings.TEST = 123 result = format_settings_list(self.settings) - self.assert_("Value: 123" in result, result) + self.assert_("TEST: 123" in result, result) def test_repr_of_a_tuple_value(self): self.settings.TEST = (123, u'abc') result = format_settings_list(self.settings) - self.assert_("Value: (123, u'abc')" in result, result) + self.assert_("TEST: (123, u'abc')" in result, result) def test_passwords_are_masked(self): self.settings.TEST_PASSWORD = u'secret' result = format_settings_list(self.settings) - self.assert_("Value: u'secret'" not in result, result) - self.assert_("Value: u'********'" in result, result) + self.assert_("TEST_PASSWORD: u'secret'" not in result, result) + self.assert_("TEST_PASSWORD: u'********'" in result, result) def test_short_values_are_not_pretty_printed(self): self.settings.FRONTEND = (u'mopidy.frontends.mpd.MpdFrontend',) result = format_settings_list(self.settings) - self.assert_("Value: (u'mopidy.frontends.mpd.MpdFrontend',)" in result, + self.assert_("FRONTEND: (u'mopidy.frontends.mpd.MpdFrontend',)" in result, result) def test_long_values_are_pretty_printed(self): self.settings.FRONTEND = (u'mopidy.frontends.mpd.MpdFrontend', u'mopidy.frontends.lastfm.LastfmFrontend') result = format_settings_list(self.settings) - self.assert_("""FRONTEND: - Value: - (u'mopidy.frontends.mpd.MpdFrontend', - u'mopidy.frontends.lastfm.LastfmFrontend')""" in result, result) + self.assert_("""FRONTEND: + (u'mopidy.frontends.mpd.MpdFrontend', + u'mopidy.frontends.lastfm.LastfmFrontend')""" in result, result) From 67787188371b2286a602f135f6baae25816bae87 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 19 May 2011 22:38:16 +0200 Subject: [PATCH 090/218] Remove 'Settings:' label and indentation of all settings --- 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 0a8dbb1a..cd8bd940 100644 --- a/mopidy/utils/settings.py +++ b/mopidy/utils/settings.py @@ -141,7 +141,7 @@ def list_settings_optparse_callback(*args): option. """ from mopidy import settings - print u'Settings: %s' % indent(format_settings_list(settings), places=2) + print format_settings_list(settings) sys.exit(0) def format_settings_list(settings): From 7d7fb8d9069666eb5f5f038ba37048581c9e588c Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 19 May 2011 22:44:31 +0200 Subject: [PATCH 091/218] Compare default passwords with the unmasked current password --- mopidy/utils/settings.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/utils/settings.py b/mopidy/utils/settings.py index cd8bd940..2bd6e6f3 100644 --- a/mopidy/utils/settings.py +++ b/mopidy/utils/settings.py @@ -149,8 +149,8 @@ def format_settings_list(settings): lines = [] for (key, value) in sorted(settings.current.iteritems()): default_value = settings.default.get(key) - value = mask_value_if_secret(key, value) - lines.append(u'%s: %s' % (key, indent(pformat(value), places=2))) + masked_value = mask_value_if_secret(key, value) + lines.append(u'%s: %s' % (key, indent(pformat(masked_value), places=2))) if value != default_value and default_value is not None: lines.append(u' Default: %s' % indent(pformat(default_value), places=4)) From fe76528738ac97710e42fa0d6d7cd68efb4abef5 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 19 May 2011 23:17:10 +0200 Subject: [PATCH 092/218] Update changelog with --list-settings improvements. (Fixes #91) --- docs/changes.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/changes.rst b/docs/changes.rst index f8f01129..d0af422b 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -24,6 +24,8 @@ No description yet. - 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`) + 0.4.1 (2011-05-06) ================== From 38733c9d9a6574b2c85487231feffdf1387a92e7 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 19 May 2011 23:23:31 +0200 Subject: [PATCH 093/218] Change changelog header format, so we get permanent anchors instead of #id1 and so on --- docs/changes.rst | 48 ++++++++++++++++++++++++------------------------ 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index d0af422b..6d7117c6 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -5,8 +5,8 @@ Changes This change log is used to track all major changes to Mopidy. -0.5.0 (in development) -====================== +v0.5.0 (in development) +======================= No description yet. @@ -27,8 +27,8 @@ No description yet. - Improve :option:`--list-settings` output. (Fixes: :issue:`91`) -0.4.1 (2011-05-06) -================== +v0.4.1 (2011-05-06) +=================== This is a bug fix release fixing audio problems on older GStreamer and some minor bugs. @@ -57,8 +57,8 @@ minor bugs. configured. (Fixes: :issue:`84`) -0.4.0 (2011-04-27) -================== +v0.4.0 (2011-04-27) +=================== Mopidy 0.4.0 is another release without major feature additions. In 0.4.0 we've fixed a bunch of issues and bugs, with the help of several new contributors @@ -156,8 +156,8 @@ loading from Mopidy 0.3.0 is still present. the debug log, to ease debugging of issues with attached debug logs. -0.3.1 (2010-01-22) -================== +v0.3.1 (2010-01-22) +=================== A couple of fixes to the 0.3.0 release is needed to get a smooth installation. @@ -170,8 +170,8 @@ A couple of fixes to the 0.3.0 release is needed to get a smooth installation. installed if the installation is executed as the root user. -0.3.0 (2010-01-22) -================== +v0.3.0 (2010-01-22) +=================== Mopidy 0.3.0 brings a bunch of small changes all over the place, but no large changes. The main features are support for high bitrate audio from Spotify, and @@ -325,8 +325,8 @@ to this problem. :class:`mopidy.outputs.base.BaseOutput`. -0.2.1 (2011-01-07) -================== +v0.2.1 (2011-01-07) +=================== This is a maintenance release without any new features. @@ -338,8 +338,8 @@ This is a maintenance release without any new features. failure. -0.2.0 (2010-10-24) -================== +v0.2.0 (2010-10-24) +=================== In Mopidy 0.2.0 we've added a `Last.fm `_ scrobbling support, which means that Mopidy now can submit meta data about the tracks you @@ -406,8 +406,8 @@ searching at the same time, thanks to Valentin David. should now exit immediately. -0.1.0 (2010-08-23) -================== +v0.1.0 (2010-08-23) +=================== After three weeks of long nights and sprints we're finally pleased enough with the state of Mopidy to remove the alpha label, and do a regular release. @@ -538,8 +538,8 @@ fixing the OS X issues for a future release. You can track the progress at :meth:`mopidy.backends.base.BaseStoredPlaylistsController.get()` instead. -0.1.0a3 (2010-08-03) -==================== +v0.1.0a3 (2010-08-03) +===================== In the last two months, Mopidy's MPD frontend has gotten lots of stability fixes and error handling improvements, proper support for having the same track @@ -616,8 +616,8 @@ Enjoy the best alpha relase of Mopidy ever :-) ``cp_track``. -0.1.0a2 (2010-06-02) -==================== +v0.1.0a2 (2010-06-02) +===================== It has been a rather slow month for Mopidy, but we would like to keep up with the established pace of at least a release per month. @@ -632,8 +632,8 @@ the established pace of at least a release per month. control :class:`mopidy.mixers.alsa.AlsaMixer` should use. -0.1.0a1 (2010-05-04) -==================== +v0.1.0a1 (2010-05-04) +===================== Since the previous release Mopidy has seen about 300 commits, more than 200 new tests, a libspotify release, and major feature additions to Spotify. The new @@ -673,8 +673,8 @@ As always, report problems at our IRC channel or our issue tracker. Thanks! - And much more. -0.1.0a0 (2010-03-27) -==================== +v0.1.0a0 (2010-03-27) +===================== "*Release early. Release often. Listen to your customers.*" wrote Eric S. Raymond in *The Cathedral and the Bazaar*. From 6640e2da172b1fd43a6c64f3626679149cfe3f41 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Fri, 20 May 2011 22:02:14 +0200 Subject: [PATCH 094/218] Use tag event to set tags instead of taginject which is a debug feature (fixes #93) --- mopidy/gstreamer.py | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/mopidy/gstreamer.py b/mopidy/gstreamer.py index c79b742f..45cd39dc 100644 --- a/mopidy/gstreamer.py +++ b/mopidy/gstreamer.py @@ -36,7 +36,6 @@ class GStreamer(ThreadingActor): def __init__(self): self._pipeline = None self._source = None - self._taginject = None self._tee = None self._uridecodebin = None self._volume = None @@ -56,13 +55,11 @@ class GStreamer(ThreadingActor): 'uridecodebin name=uri', 'audioconvert name=convert', 'volume name=volume', - 'taginject name=inject', 'tee name=tee']) logger.debug(u'Setting up base GStreamer pipeline: %s', description) self._pipeline = gst.parse_launch(description) - self._taginject = self._pipeline.get_by_name('inject') self._tee = self._pipeline.get_by_name('tee') self._volume = self._pipeline.get_by_name('volume') self._uridecodebin = self._pipeline.get_by_name('uri') @@ -277,13 +274,19 @@ class GStreamer(ThreadingActor): :param track: the current track :type track: :class:`mopidy.modes.Track` """ - # FIXME what if we want to unset taginject tags? - tags = u'artist="%(artist)s",title="%(title)s"' % { - 'artist': u', '.join([a.name for a in track.artists]), - 'title': track.name, - } - logger.debug('Setting tags to: %s', tags) - self._taginject.set_property('tags', tags) + taglist = gst.TagList() + artists = [a for a in (track.artists or []) if a.name] + + if artists: + taglist[gst.TAG_ARTIST] = u', '.join([a.name for a in artists]) + if track.name: + taglist[gst.TAG_TITLE] = track.name + if track.album and track.album.name: + taglist[gst.TAG_ALBUM] = track.album.name + + event = gst.event_new_tag(taglist) + self._pipeline.send_event(event) + logger.debug('Setting tags to: %s', taglist) def connect_output(self, output): """ From 3ce42b2f6b21ce51aa63d227abcd2d463c2b084c Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Fri, 20 May 2011 22:07:57 +0200 Subject: [PATCH 095/218] Remove logging of taglist --- mopidy/gstreamer.py | 1 - 1 file changed, 1 deletion(-) diff --git a/mopidy/gstreamer.py b/mopidy/gstreamer.py index 45cd39dc..f507cd28 100644 --- a/mopidy/gstreamer.py +++ b/mopidy/gstreamer.py @@ -286,7 +286,6 @@ class GStreamer(ThreadingActor): event = gst.event_new_tag(taglist) self._pipeline.send_event(event) - logger.debug('Setting tags to: %s', taglist) def connect_output(self, output): """ From 35a169b8e78ba7ec62d24cdb58bf97cdebb3d7cb Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Fri, 20 May 2011 22:30:59 +0200 Subject: [PATCH 096/218] Remove null output as it turns out we can run fine without any outputs --- docs/api/outputs.rst | 1 - docs/modules/outputs.rst | 2 -- mopidy/outputs/null.py | 16 ---------------- 3 files changed, 19 deletions(-) delete mode 100644 mopidy/outputs/null.py diff --git a/docs/api/outputs.rst b/docs/api/outputs.rst index d801116d..7f487881 100644 --- a/docs/api/outputs.rst +++ b/docs/api/outputs.rst @@ -15,5 +15,4 @@ Output implementations * :class:`mopidy.outputs.custom.CustomOutput` * :class:`mopidy.outputs.local.LocalOutput` -* :class:`mopidy.outputs.null.NullOutput` * :class:`mopidy.outputs.shoutcast.ShoutcastOutput` diff --git a/docs/modules/outputs.rst b/docs/modules/outputs.rst index c7a45d84..37ff0390 100644 --- a/docs/modules/outputs.rst +++ b/docs/modules/outputs.rst @@ -10,6 +10,4 @@ The following GStreamer audio outputs implements the :ref:`output-api`. .. autoclass:: mopidy.outputs.local.LocalOutput -.. autoclass:: mopidy.outputs.null.NullOutput - .. autoclass:: mopidy.outputs.shoutcast.ShoutcastOutput diff --git a/mopidy/outputs/null.py b/mopidy/outputs/null.py deleted file mode 100644 index 975b9724..00000000 --- a/mopidy/outputs/null.py +++ /dev/null @@ -1,16 +0,0 @@ -from mopidy.outputs import BaseOutput - -class NullOutput(BaseOutput): - """ - Fall-back null output. - - This output will not output anything. It is intended as a fall-back for - when setup of all other outputs have failed and should not be used by end - users. Inserting this output in such a case ensures that the pipeline does - not crash. - """ - - def describe_bin(self): - return 'fakesink' - - From 94cae3be4298d9038f7b1513880ab2fab8e0f542 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Fri, 20 May 2011 23:20:01 +0200 Subject: [PATCH 097/218] Lookup new source via property instead of name --- mopidy/gstreamer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/gstreamer.py b/mopidy/gstreamer.py index c79b742f..6a7db6db 100644 --- a/mopidy/gstreamer.py +++ b/mopidy/gstreamer.py @@ -81,7 +81,7 @@ class GStreamer(ThreadingActor): bus.connect('message', self._on_message) def _on_new_source(self, element, pad): - self._source = element.get_by_name('source') + self._source = element.get_property('source') try: self._source.set_property('caps', default_caps) except TypeError: From 9c23949284bf2927e76649a6c981e1eeedbed0ee Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 23 May 2011 10:13:57 +0200 Subject: [PATCH 098/218] Homebrew no longer carries pip --- docs/installation/index.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/installation/index.rst b/docs/installation/index.rst index d1fbd0f6..1f497e3a 100644 --- a/docs/installation/index.rst +++ b/docs/installation/index.rst @@ -98,7 +98,7 @@ install Mopidy from PyPI using Pip. #. Then, you need to install Pip:: sudo aptitude install python-setuptools python-pip # On Ubuntu/Debian - sudo brew install pip # On OS X + sudo easy_install pip # On OS X #. To install the currently latest stable release of Mopidy:: @@ -132,7 +132,7 @@ Mopidy's ``develop`` branch. #. Then, you need to install Pip:: sudo aptitude install python-setuptools python-pip # On Ubuntu/Debian - sudo brew install pip # On OS X + sudo easy_install pip # On OS X #. To install the latest snapshot of Mopidy, run:: @@ -155,7 +155,7 @@ If you want to contribute to Mopidy, you should install Mopidy using Git. #. Then install Git, if haven't already:: sudo aptitude install git-core # On Ubuntu/Debian - sudo brew install git # On OS X + sudo brew install git # On OS X using Homebrew #. Clone the official Mopidy repository, or your own fork of it:: From b992277e7fa669bdf1f5cb8408959fd4b558e044 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 May 2011 21:15:12 +0200 Subject: [PATCH 099/218] docs: Move changes further down the front page --- docs/index.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/index.rst b/docs/index.rst index 0af45835..769aed20 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -33,13 +33,13 @@ User documentation .. toctree:: :maxdepth: 3 - changes installation/index settings running clients/index authors licenses + changes Reference documentation ======================= From 86e90f14c6233467a5f73dbd530fb048591e7a99 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 25 May 2011 20:47:52 +0200 Subject: [PATCH 100/218] Add MpdSystemError exception --- mopidy/frontends/mpd/exceptions.py | 5 +++++ tests/frontends/mpd/exception_test.py | 9 ++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/mopidy/frontends/mpd/exceptions.py b/mopidy/frontends/mpd/exceptions.py index faf4ce2f..df90aed7 100644 --- a/mopidy/frontends/mpd/exceptions.py +++ b/mopidy/frontends/mpd/exceptions.py @@ -54,6 +54,11 @@ class MpdNoExistError(MpdAckError): super(MpdNoExistError, self).__init__(*args, **kwargs) self.error_code = MpdAckError.ACK_ERROR_NO_EXIST +class MpdSystemError(MpdAckError): + def __init__(self, *args, **kwargs): + super(MpdSystemError, self).__init__(*args, **kwargs) + self.error_code = MpdAckError.ACK_ERROR_SYSTEM + class MpdNotImplemented(MpdAckError): def __init__(self, *args, **kwargs): super(MpdNotImplemented, self).__init__(*args, **kwargs) diff --git a/tests/frontends/mpd/exception_test.py b/tests/frontends/mpd/exception_test.py index ef222d46..9b1b47a2 100644 --- a/tests/frontends/mpd/exception_test.py +++ b/tests/frontends/mpd/exception_test.py @@ -1,7 +1,7 @@ import unittest from mopidy.frontends.mpd.exceptions import (MpdAckError, MpdUnknownCommand, - MpdNotImplemented) + MpdSystemError, MpdNotImplemented) class MpdExceptionsTest(unittest.TestCase): def test_key_error_wrapped_in_mpd_ack_error(self): @@ -36,3 +36,10 @@ class MpdExceptionsTest(unittest.TestCase): except MpdAckError as e: self.assertEqual(e.get_mpd_ack(), u'ACK [5@0] {} unknown command "play"') + + def test_mpd_system_error(self): + try: + raise MpdSystemError('foo') + except MpdSystemError as e: + self.assertEqual(e.get_mpd_ack(), + u'ACK [52@0] {} foo') From 63918ac3f3864ec315b1b0be63d5eb54e8fb750c Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 25 May 2011 20:52:54 +0200 Subject: [PATCH 101/218] Log a warning if MPD tries to communicate with dead actors. --- mopidy/frontends/mpd/dispatcher.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/mopidy/frontends/mpd/dispatcher.py b/mopidy/frontends/mpd/dispatcher.py index f5c30b23..a72789f1 100644 --- a/mopidy/frontends/mpd/dispatcher.py +++ b/mopidy/frontends/mpd/dispatcher.py @@ -1,10 +1,12 @@ +import logging import re +from pykka import ActorDeadError from pykka.registry import ActorRegistry from mopidy.backends.base import Backend from mopidy.frontends.mpd.exceptions import (MpdAckError, MpdArgError, - MpdUnknownCommand) + MpdUnknownCommand, MpdSystemError) from mopidy.frontends.mpd.protocol import mpd_commands, request_handlers # Do not remove the following import. The protocol modules must be imported to # get them registered as request handlers. @@ -16,6 +18,8 @@ from mopidy.frontends.mpd.protocol import (audio_output, command_list, from mopidy.mixers.base import BaseMixer from mopidy.utils import flatten +logger = logging.getLogger('mopidy.frontends.mpd.dispatcher') + class MpdDispatcher(object): """ The MPD session feeds the MPD dispatcher with requests. The dispatcher @@ -49,6 +53,10 @@ class MpdDispatcher(object): if command_list_index is not None: e.index = command_list_index return self.handle_response(e.get_mpd_ack(), add_ok=False) + except ActorDeadError as e: + logger.warning(u'Tried to communicate with dead actor.') + mpd_error = MpdSystemError(e.message) + return self.handle_response(mpd_error.get_mpd_ack(), add_ok=False) if request in (u'command_list_begin', u'command_list_ok_begin'): return None if command_list_index is not None: From fc9875bf3d39da85026fcd564ee7acae257972ec Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 25 May 2011 21:08:34 +0200 Subject: [PATCH 102/218] Spotify albums may be None --- mopidy/backends/spotify/translator.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/mopidy/backends/spotify/translator.py b/mopidy/backends/spotify/translator.py index dca2b285..15aa59ac 100644 --- a/mopidy/backends/spotify/translator.py +++ b/mopidy/backends/spotify/translator.py @@ -21,7 +21,7 @@ class SpotifyTranslator(object): @classmethod def to_mopidy_album(cls, spotify_album): - if not spotify_album.is_loaded(): + 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)) @@ -31,7 +31,8 @@ class SpotifyTranslator(object): uri = str(Link.from_track(spotify_track, 0)) if not spotify_track.is_loaded(): return Track(uri=uri, name=u'[loading...]') - if dt.MINYEAR <= int(spotify_track.album().year()) <= dt.MAXYEAR: + if (spotify_track.album() is not None and + dt.MINYEAR <= int(spotify_track.album().year()) <= dt.MAXYEAR): date = dt.date(spotify_track.album().year(), 1, 1) else: date = None From 55bc7b19fb1d4a1f7c311cf608176767b24031d5 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 25 May 2011 23:05:54 +0200 Subject: [PATCH 103/218] Remove dead code in BaseThread --- mopidy/utils/process.py | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/mopidy/utils/process.py b/mopidy/utils/process.py index dbc6cada..cf676519 100644 --- a/mopidy/utils/process.py +++ b/mopidy/utils/process.py @@ -21,26 +21,17 @@ class BaseThread(threading.Thread): self.run_inside_try() except KeyboardInterrupt: logger.info(u'Interrupted by user') - self.exit(0, u'Interrupted by user') except SettingsError as e: logger.error(e.message) - self.exit(1, u'Settings error') except ImportError as e: logger.error(e) - self.exit(2, u'Import error') except Exception as e: logger.exception(e) - self.exit(3, u'Unknown error') + logger.debug(u'%s: Exiting thread', self.name) def run_inside_try(self): raise NotImplementedError - def destroy(self): - pass - - def exit(self, status=0, reason=None): - self.destroy() - class GObjectEventThread(BaseThread): """ From 3b27ba47464aad9d79b8378d19c30e23bfca809d Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 25 May 2011 23:06:15 +0200 Subject: [PATCH 104/218] Catch and log ActorDeadError in BaseThread --- mopidy/utils/process.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/mopidy/utils/process.py b/mopidy/utils/process.py index cf676519..7f6cf664 100644 --- a/mopidy/utils/process.py +++ b/mopidy/utils/process.py @@ -4,6 +4,8 @@ import threading import gobject gobject.threads_init() +from pykka import ActorDeadError + from mopidy import SettingsError logger = logging.getLogger('mopidy.utils.process') @@ -25,6 +27,8 @@ class BaseThread(threading.Thread): logger.error(e.message) except ImportError as e: logger.error(e) + except ActorDeadError as e: + logger.warning(e) except Exception as e: logger.exception(e) logger.debug(u'%s: Exiting thread', self.name) From f73ba3bd62eb2bb13db36d4c3e0c30196becd29a Mon Sep 17 00:00:00 2001 From: Antoine Pierlot-Garcin Date: Mon, 30 May 2011 22:22:23 -0400 Subject: [PATCH 105/218] 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 0e098e9b60a2665d0d29ee4a357c36f673b26ef2 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 2 Jun 2011 18:38:11 +0200 Subject: [PATCH 106/218] Replace not decodable characters returned from Spotify instead of crashing --- docs/changes.rst | 3 +++ mopidy/backends/spotify/translator.py | 8 ++++---- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 6d7117c6..b9df87df 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -26,6 +26,9 @@ No description yet. - 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. + v0.4.1 (2011-05-06) =================== diff --git a/mopidy/backends/spotify/translator.py b/mopidy/backends/spotify/translator.py index 15aa59ac..21abdf78 100644 --- a/mopidy/backends/spotify/translator.py +++ b/mopidy/backends/spotify/translator.py @@ -16,7 +16,7 @@ class SpotifyTranslator(object): return Artist(name=u'[loading...]') return Artist( uri=str(Link.from_artist(spotify_artist)), - name=spotify_artist.name().decode(ENCODING), + name=spotify_artist.name().decode(ENCODING, 'replace'), ) @classmethod @@ -24,7 +24,7 @@ class SpotifyTranslator(object): if spotify_album is None or not spotify_album.is_loaded(): return Album(name=u'[loading...]') # TODO pyspotify got much more data on albums than this - return Album(name=spotify_album.name().decode(ENCODING)) + return Album(name=spotify_album.name().decode(ENCODING, 'replace')) @classmethod def to_mopidy_track(cls, spotify_track): @@ -38,7 +38,7 @@ class SpotifyTranslator(object): date = None return Track( uri=uri, - name=spotify_track.name().decode(ENCODING), + name=spotify_track.name().decode(ENCODING, 'replace'), artists=[cls.to_mopidy_artist(a) for a in spotify_track.artists()], album=cls.to_mopidy_album(spotify_track.album()), track_no=spotify_track.index(), @@ -57,7 +57,7 @@ class SpotifyTranslator(object): try: return Playlist( uri=str(Link.from_playlist(spotify_playlist)), - name=spotify_playlist.name().decode(ENCODING), + name=spotify_playlist.name().decode(ENCODING, 'replace'), # 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 acad477c8a2debe60f6bddd40cd37ac2277ea3cc Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 2 Jun 2011 18:41:43 +0200 Subject: [PATCH 107/218] 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 76d0314eff7854db33e4bb4b1ff7b475e9d0e7a3 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 3 Jun 2011 16:08:53 +0200 Subject: [PATCH 108/218] Replace 'frontend' with 'context' in MPD protocol impl --- mopidy/frontends/mpd/protocol/audio_output.py | 6 +- mopidy/frontends/mpd/protocol/command_list.py | 24 ++-- mopidy/frontends/mpd/protocol/connection.py | 8 +- .../mpd/protocol/current_playlist.py | 132 +++++++++--------- mopidy/frontends/mpd/protocol/empty.py | 2 +- mopidy/frontends/mpd/protocol/music_db.py | 48 +++---- mopidy/frontends/mpd/protocol/playback.py | 116 +++++++-------- mopidy/frontends/mpd/protocol/reflection.py | 12 +- mopidy/frontends/mpd/protocol/status.py | 110 +++++++-------- mopidy/frontends/mpd/protocol/stickers.py | 10 +- .../mpd/protocol/stored_playlists.py | 32 ++--- 11 files changed, 250 insertions(+), 250 deletions(-) diff --git a/mopidy/frontends/mpd/protocol/audio_output.py b/mopidy/frontends/mpd/protocol/audio_output.py index 98c1d645..6111332a 100644 --- a/mopidy/frontends/mpd/protocol/audio_output.py +++ b/mopidy/frontends/mpd/protocol/audio_output.py @@ -2,7 +2,7 @@ from mopidy.frontends.mpd.protocol import handle_pattern from mopidy.frontends.mpd.exceptions import MpdNotImplemented @handle_pattern(r'^disableoutput "(?P\d+)"$') -def disableoutput(frontend, outputid): +def disableoutput(context, outputid): """ *musicpd.org, audio output section:* @@ -13,7 +13,7 @@ def disableoutput(frontend, outputid): raise MpdNotImplemented # TODO @handle_pattern(r'^enableoutput "(?P\d+)"$') -def enableoutput(frontend, outputid): +def enableoutput(context, outputid): """ *musicpd.org, audio output section:* @@ -24,7 +24,7 @@ def enableoutput(frontend, outputid): raise MpdNotImplemented # TODO @handle_pattern(r'^outputs$') -def outputs(frontend): +def outputs(context): """ *musicpd.org, audio output section:* diff --git a/mopidy/frontends/mpd/protocol/command_list.py b/mopidy/frontends/mpd/protocol/command_list.py index b3df0be6..cecff9fd 100644 --- a/mopidy/frontends/mpd/protocol/command_list.py +++ b/mopidy/frontends/mpd/protocol/command_list.py @@ -2,7 +2,7 @@ from mopidy.frontends.mpd.protocol import handle_pattern from mopidy.frontends.mpd.exceptions import MpdUnknownCommand @handle_pattern(r'^command_list_begin$') -def command_list_begin(frontend): +def command_list_begin(context): """ *musicpd.org, command list section:* @@ -18,21 +18,21 @@ def command_list_begin(frontend): returned. If ``command_list_ok_begin`` is used, ``list_OK`` is returned for each successful command executed in the command list. """ - frontend.command_list = [] - frontend.command_list_ok = False + context.command_list = [] + context.command_list_ok = False @handle_pattern(r'^command_list_end$') -def command_list_end(frontend): +def command_list_end(context): """See :meth:`command_list_begin()`.""" - if frontend.command_list is False: + if context.command_list is False: # Test for False exactly, and not e.g. empty list raise MpdUnknownCommand(command='command_list_end') - (command_list, frontend.command_list) = (frontend.command_list, False) - (command_list_ok, frontend.command_list_ok) = ( - frontend.command_list_ok, False) + (command_list, context.command_list) = (context.command_list, False) + (command_list_ok, context.command_list_ok) = ( + context.command_list_ok, False) result = [] for i, command in enumerate(command_list): - response = frontend.handle_request(command, command_list_index=i) + response = context.handle_request(command, command_list_index=i) if response is not None: result.append(response) if response and response[-1].startswith(u'ACK'): @@ -42,7 +42,7 @@ def command_list_end(frontend): return result @handle_pattern(r'^command_list_ok_begin$') -def command_list_ok_begin(frontend): +def command_list_ok_begin(context): """See :meth:`command_list_begin()`.""" - frontend.command_list = [] - frontend.command_list_ok = True + context.command_list = [] + context.command_list_ok = True diff --git a/mopidy/frontends/mpd/protocol/connection.py b/mopidy/frontends/mpd/protocol/connection.py index 65811d09..48e5ebd4 100644 --- a/mopidy/frontends/mpd/protocol/connection.py +++ b/mopidy/frontends/mpd/protocol/connection.py @@ -3,7 +3,7 @@ from mopidy.frontends.mpd.protocol import handle_pattern from mopidy.frontends.mpd.exceptions import MpdPasswordError @handle_pattern(r'^close$') -def close(frontend): +def close(context): """ *musicpd.org, connection section:* @@ -14,7 +14,7 @@ def close(frontend): pass # TODO @handle_pattern(r'^kill$') -def kill(frontend): +def kill(context): """ *musicpd.org, connection section:* @@ -25,7 +25,7 @@ def kill(frontend): pass # TODO @handle_pattern(r'^password "(?P[^"]+)"$') -def password_(frontend, password): +def password_(context, password): """ *musicpd.org, connection section:* @@ -41,7 +41,7 @@ def password_(frontend, password): raise MpdPasswordError(u'incorrect password', command=u'password') @handle_pattern(r'^ping$') -def ping(frontend): +def ping(context): """ *musicpd.org, connection section:* diff --git a/mopidy/frontends/mpd/protocol/current_playlist.py b/mopidy/frontends/mpd/protocol/current_playlist.py index 8ef5e026..e73e0a9c 100644 --- a/mopidy/frontends/mpd/protocol/current_playlist.py +++ b/mopidy/frontends/mpd/protocol/current_playlist.py @@ -4,7 +4,7 @@ from mopidy.frontends.mpd.protocol import handle_pattern from mopidy.frontends.mpd.translator import tracks_to_mpd_format @handle_pattern(r'^add "(?P[^"]*)"$') -def add(frontend, uri): +def add(context, uri): """ *musicpd.org, current playlist section:* @@ -19,17 +19,17 @@ def add(frontend, uri): """ if not uri: return - for handler_prefix in frontend.backend.uri_handlers.get(): + for handler_prefix in context.backend.uri_handlers.get(): if uri.startswith(handler_prefix): - track = frontend.backend.library.lookup(uri).get() + track = context.backend.library.lookup(uri).get() if track is not None: - frontend.backend.current_playlist.add(track) + context.backend.current_playlist.add(track) return raise MpdNoExistError( u'directory or file not found', command=u'add') @handle_pattern(r'^addid "(?P[^"]*)"( "(?P\d+)")*$') -def addid(frontend, uri, songpos=None): +def addid(context, uri, songpos=None): """ *musicpd.org, current playlist section:* @@ -51,18 +51,18 @@ def addid(frontend, uri, songpos=None): raise MpdNoExistError(u'No such song', command=u'addid') if songpos is not None: songpos = int(songpos) - track = frontend.backend.library.lookup(uri).get() + track = context.backend.library.lookup(uri).get() if track is None: raise MpdNoExistError(u'No such song', command=u'addid') if songpos and songpos > len( - frontend.backend.current_playlist.tracks.get()): + context.backend.current_playlist.tracks.get()): raise MpdArgError(u'Bad song index', command=u'addid') - cp_track = frontend.backend.current_playlist.add(track, + cp_track = context.backend.current_playlist.add(track, at_position=songpos).get() return ('Id', cp_track[0]) @handle_pattern(r'^delete "(?P\d+):(?P\d+)*"$') -def delete_range(frontend, start, end=None): +def delete_range(context, start, end=None): """ *musicpd.org, current playlist section:* @@ -74,25 +74,25 @@ def delete_range(frontend, start, end=None): if end is not None: end = int(end) else: - end = len(frontend.backend.current_playlist.tracks.get()) - cp_tracks = frontend.backend.current_playlist.cp_tracks.get()[start:end] + end = len(context.backend.current_playlist.tracks.get()) + cp_tracks = context.backend.current_playlist.cp_tracks.get()[start:end] if not cp_tracks: raise MpdArgError(u'Bad song index', command=u'delete') for (cpid, _) in cp_tracks: - frontend.backend.current_playlist.remove(cpid=cpid) + context.backend.current_playlist.remove(cpid=cpid) @handle_pattern(r'^delete "(?P\d+)"$') -def delete_songpos(frontend, songpos): +def delete_songpos(context, songpos): """See :meth:`delete_range`""" try: songpos = int(songpos) - (cpid, _) = frontend.backend.current_playlist.cp_tracks.get()[songpos] - frontend.backend.current_playlist.remove(cpid=cpid) + (cpid, _) = context.backend.current_playlist.cp_tracks.get()[songpos] + context.backend.current_playlist.remove(cpid=cpid) except IndexError: raise MpdArgError(u'Bad song index', command=u'delete') @handle_pattern(r'^deleteid "(?P\d+)"$') -def deleteid(frontend, cpid): +def deleteid(context, cpid): """ *musicpd.org, current playlist section:* @@ -102,14 +102,14 @@ def deleteid(frontend, cpid): """ try: cpid = int(cpid) - if frontend.backend.playback.current_cpid.get() == cpid: - frontend.backend.playback.next() - return frontend.backend.current_playlist.remove(cpid=cpid).get() + if context.backend.playback.current_cpid.get() == cpid: + context.backend.playback.next() + return context.backend.current_playlist.remove(cpid=cpid).get() except LookupError: raise MpdNoExistError(u'No such song', command=u'deleteid') @handle_pattern(r'^clear$') -def clear(frontend): +def clear(context): """ *musicpd.org, current playlist section:* @@ -117,10 +117,10 @@ def clear(frontend): Clears the current playlist. """ - frontend.backend.current_playlist.clear() + context.backend.current_playlist.clear() @handle_pattern(r'^move "(?P\d+):(?P\d+)*" "(?P\d+)"$') -def move_range(frontend, start, to, end=None): +def move_range(context, start, to, end=None): """ *musicpd.org, current playlist section:* @@ -130,21 +130,21 @@ def move_range(frontend, start, to, end=None): ``TO`` in the playlist. """ if end is None: - end = len(frontend.backend.current_playlist.tracks.get()) + end = len(context.backend.current_playlist.tracks.get()) start = int(start) end = int(end) to = int(to) - frontend.backend.current_playlist.move(start, end, to) + context.backend.current_playlist.move(start, end, to) @handle_pattern(r'^move "(?P\d+)" "(?P\d+)"$') -def move_songpos(frontend, songpos, to): +def move_songpos(context, songpos, to): """See :meth:`move_range`.""" songpos = int(songpos) to = int(to) - frontend.backend.current_playlist.move(songpos, songpos + 1, to) + context.backend.current_playlist.move(songpos, songpos + 1, to) @handle_pattern(r'^moveid "(?P\d+)" "(?P\d+)"$') -def moveid(frontend, cpid, to): +def moveid(context, cpid, to): """ *musicpd.org, current playlist section:* @@ -156,13 +156,13 @@ def moveid(frontend, cpid, to): """ cpid = int(cpid) to = int(to) - cp_track = frontend.backend.current_playlist.get(cpid=cpid).get() - position = frontend.backend.current_playlist.cp_tracks.get().index( + cp_track = context.backend.current_playlist.get(cpid=cpid).get() + position = context.backend.current_playlist.cp_tracks.get().index( cp_track) - frontend.backend.current_playlist.move(position, position + 1, to) + context.backend.current_playlist.move(position, position + 1, to) @handle_pattern(r'^playlist$') -def playlist(frontend): +def playlist(context): """ *musicpd.org, current playlist section:* @@ -174,11 +174,11 @@ def playlist(frontend): Do not use this, instead use ``playlistinfo``. """ - return playlistinfo(frontend) + return playlistinfo(context) @handle_pattern(r'^playlistfind (?P[^"]+) "(?P[^"]+)"$') @handle_pattern(r'^playlistfind "(?P[^"]+)" "(?P[^"]+)"$') -def playlistfind(frontend, tag, needle): +def playlistfind(context, tag, needle): """ *musicpd.org, current playlist section:* @@ -192,9 +192,9 @@ def playlistfind(frontend, tag, needle): """ if tag == 'filename': try: - cp_track = frontend.backend.current_playlist.get(uri=needle).get() + cp_track = context.backend.current_playlist.get(uri=needle).get() (cpid, track) = cp_track - position = frontend.backend.current_playlist.cp_tracks.get().index( + position = context.backend.current_playlist.cp_tracks.get().index( cp_track) return track.mpd_format(cpid=cpid, position=position) except LookupError: @@ -202,7 +202,7 @@ def playlistfind(frontend, tag, needle): raise MpdNotImplemented # TODO @handle_pattern(r'^playlistid( "(?P\d+)")*$') -def playlistid(frontend, cpid=None): +def playlistid(context, cpid=None): """ *musicpd.org, current playlist section:* @@ -214,22 +214,22 @@ def playlistid(frontend, cpid=None): if cpid is not None: try: cpid = int(cpid) - cp_track = frontend.backend.current_playlist.get(cpid=cpid).get() - position = frontend.backend.current_playlist.cp_tracks.get().index( + cp_track = context.backend.current_playlist.get(cpid=cpid).get() + position = context.backend.current_playlist.cp_tracks.get().index( cp_track) return cp_track[1].mpd_format(position=position, cpid=cpid) except LookupError: raise MpdNoExistError(u'No such song', command=u'playlistid') else: cpids = [ct[0] for ct in - frontend.backend.current_playlist.cp_tracks.get()] + context.backend.current_playlist.cp_tracks.get()] return tracks_to_mpd_format( - frontend.backend.current_playlist.tracks.get(), cpids=cpids) + context.backend.current_playlist.tracks.get(), cpids=cpids) @handle_pattern(r'^playlistinfo$') @handle_pattern(r'^playlistinfo "(?P-?\d+)"$') @handle_pattern(r'^playlistinfo "(?P\d+):(?P\d+)*"$') -def playlistinfo(frontend, songpos=None, +def playlistinfo(context, songpos=None, start=None, end=None): """ *musicpd.org, current playlist section:* @@ -255,30 +255,30 @@ def playlistinfo(frontend, songpos=None, if start == -1: end = None cpids = [ct[0] for ct in - frontend.backend.current_playlist.cp_tracks.get()] + context.backend.current_playlist.cp_tracks.get()] return tracks_to_mpd_format( - frontend.backend.current_playlist.tracks.get(), + context.backend.current_playlist.tracks.get(), start, end, cpids=cpids) else: if start is None: start = 0 start = int(start) if not (0 <= start <= len( - frontend.backend.current_playlist.tracks.get())): + context.backend.current_playlist.tracks.get())): raise MpdArgError(u'Bad song index', command=u'playlistinfo') if end is not None: end = int(end) - if end > len(frontend.backend.current_playlist.tracks.get()): + if end > len(context.backend.current_playlist.tracks.get()): end = None cpids = [ct[0] for ct in - frontend.backend.current_playlist.cp_tracks.get()] + context.backend.current_playlist.cp_tracks.get()] return tracks_to_mpd_format( - frontend.backend.current_playlist.tracks.get(), + context.backend.current_playlist.tracks.get(), start, end, cpids=cpids) @handle_pattern(r'^playlistsearch "(?P[^"]+)" "(?P[^"]+)"$') @handle_pattern(r'^playlistsearch (?P\S+) "(?P[^"]+)"$') -def playlistsearch(frontend, tag, needle): +def playlistsearch(context, tag, needle): """ *musicpd.org, current playlist section:* @@ -296,7 +296,7 @@ def playlistsearch(frontend, tag, needle): @handle_pattern(r'^plchanges (?P-?\d+)$') @handle_pattern(r'^plchanges "(?P-?\d+)"$') -def plchanges(frontend, version): +def plchanges(context, version): """ *musicpd.org, current playlist section:* @@ -312,14 +312,14 @@ def plchanges(frontend, version): - Calls ``plchanges "-1"`` two times per second to get the entire playlist. """ # XXX Naive implementation that returns all tracks as changed - if int(version) < frontend.backend.current_playlist.version: + if int(version) < context.backend.current_playlist.version: cpids = [ct[0] for ct in - frontend.backend.current_playlist.cp_tracks.get()] + context.backend.current_playlist.cp_tracks.get()] return tracks_to_mpd_format( - frontend.backend.current_playlist.tracks.get(), cpids=cpids) + context.backend.current_playlist.tracks.get(), cpids=cpids) @handle_pattern(r'^plchangesposid "(?P\d+)"$') -def plchangesposid(frontend, version): +def plchangesposid(context, version): """ *musicpd.org, current playlist section:* @@ -333,17 +333,17 @@ def plchangesposid(frontend, version): ``playlistlength`` returned by status command. """ # XXX Naive implementation that returns all tracks as changed - if int(version) != frontend.backend.current_playlist.version.get(): + if int(version) != context.backend.current_playlist.version.get(): result = [] for (position, (cpid, _)) in enumerate( - frontend.backend.current_playlist.cp_tracks.get()): + context.backend.current_playlist.cp_tracks.get()): result.append((u'cpos', position)) result.append((u'Id', cpid)) return result @handle_pattern(r'^shuffle$') @handle_pattern(r'^shuffle "(?P\d+):(?P\d+)*"$') -def shuffle(frontend, start=None, end=None): +def shuffle(context, start=None, end=None): """ *musicpd.org, current playlist section:* @@ -356,10 +356,10 @@ def shuffle(frontend, start=None, end=None): start = int(start) if end is not None: end = int(end) - frontend.backend.current_playlist.shuffle(start, end) + context.backend.current_playlist.shuffle(start, end) @handle_pattern(r'^swap "(?P\d+)" "(?P\d+)"$') -def swap(frontend, songpos1, songpos2): +def swap(context, songpos1, songpos2): """ *musicpd.org, current playlist section:* @@ -369,18 +369,18 @@ def swap(frontend, songpos1, songpos2): """ songpos1 = int(songpos1) songpos2 = int(songpos2) - tracks = frontend.backend.current_playlist.tracks.get() + tracks = context.backend.current_playlist.tracks.get() song1 = tracks[songpos1] song2 = tracks[songpos2] del tracks[songpos1] tracks.insert(songpos1, song2) del tracks[songpos2] tracks.insert(songpos2, song1) - frontend.backend.current_playlist.clear() - frontend.backend.current_playlist.append(tracks) + context.backend.current_playlist.clear() + context.backend.current_playlist.append(tracks) @handle_pattern(r'^swapid "(?P\d+)" "(?P\d+)"$') -def swapid(frontend, cpid1, cpid2): +def swapid(context, cpid1, cpid2): """ *musicpd.org, current playlist section:* @@ -390,9 +390,9 @@ def swapid(frontend, cpid1, cpid2): """ cpid1 = int(cpid1) cpid2 = int(cpid2) - cp_track1 = frontend.backend.current_playlist.get(cpid=cpid1).get() - cp_track2 = frontend.backend.current_playlist.get(cpid=cpid2).get() - cp_tracks = frontend.backend.current_playlist.cp_tracks.get() + cp_track1 = context.backend.current_playlist.get(cpid=cpid1).get() + cp_track2 = context.backend.current_playlist.get(cpid=cpid2).get() + cp_tracks = context.backend.current_playlist.cp_tracks.get() position1 = cp_tracks.index(cp_track1) position2 = cp_tracks.index(cp_track2) - swap(frontend, position1, position2) + swap(context, position1, position2) diff --git a/mopidy/frontends/mpd/protocol/empty.py b/mopidy/frontends/mpd/protocol/empty.py index a39d79eb..b84f08f4 100644 --- a/mopidy/frontends/mpd/protocol/empty.py +++ b/mopidy/frontends/mpd/protocol/empty.py @@ -1,6 +1,6 @@ from mopidy.frontends.mpd.protocol import handle_pattern @handle_pattern(r'^$') -def empty(frontend): +def empty(context): """The original MPD server returns ``OK`` on an empty request.""" pass diff --git a/mopidy/frontends/mpd/protocol/music_db.py b/mopidy/frontends/mpd/protocol/music_db.py index a6836533..0183d471 100644 --- a/mopidy/frontends/mpd/protocol/music_db.py +++ b/mopidy/frontends/mpd/protocol/music_db.py @@ -29,7 +29,7 @@ def _build_query(mpd_query): return query @handle_pattern(r'^count "(?P[^"]+)" "(?P[^"]*)"$') -def count(frontend, tag, needle): +def count(context, tag, needle): """ *musicpd.org, music database section:* @@ -43,7 +43,7 @@ def count(frontend, tag, needle): @handle_pattern(r'^find ' r'(?P("?([Aa]lbum|[Aa]rtist|[Dd]ate|[Ff]ilename|' r'[Tt]itle|[Aa]ny)"? "[^"]+"\s?)+)$') -def find(frontend, mpd_query): +def find(context, mpd_query): """ *musicpd.org, music database section:* @@ -68,12 +68,12 @@ def find(frontend, mpd_query): - also uses the search type "date". """ query = _build_query(mpd_query) - return frontend.backend.library.find_exact(**query).get().mpd_format() + return context.backend.library.find_exact(**query).get().mpd_format() @handle_pattern(r'^findadd ' r'(?P("?([Aa]lbum|[Aa]rtist|[Ff]ilename|[Tt]itle|[Aa]ny)"? ' '"[^"]+"\s?)+)$') -def findadd(frontend, query): +def findadd(context, query): """ *musicpd.org, music database section:* @@ -84,11 +84,11 @@ def findadd(frontend, query): ``WHAT`` is what to find. """ # TODO Add result to current playlist - #result = frontend.find(query) + #result = context.find(query) @handle_pattern(r'^list "?(?P([Aa]rtist|[Aa]lbum|[Dd]ate|[Gg]enre))"?' '( (?P.*))?$') -def list_(frontend, field, mpd_query=None): +def list_(context, field, mpd_query=None): """ *musicpd.org, music database section:* @@ -175,11 +175,11 @@ def list_(frontend, field, mpd_query=None): field = field.lower() query = _list_build_query(field, mpd_query) if field == u'artist': - return _list_artist(frontend, query) + return _list_artist(context, query) elif field == u'album': - return _list_album(frontend, query) + return _list_album(context, query) elif field == u'date': - return _list_date(frontend, query) + return _list_date(context, query) elif field == u'genre': pass # TODO We don't have genre in our internal data structures yet @@ -213,32 +213,32 @@ def _list_build_query(field, mpd_query): else: raise MpdArgError(u'not able to parse args', command=u'list') -def _list_artist(frontend, query): +def _list_artist(context, query): artists = set() - playlist = frontend.backend.library.find_exact(**query).get() + playlist = context.backend.library.find_exact(**query).get() for track in playlist.tracks: for artist in track.artists: artists.add((u'Artist', artist.name)) return artists -def _list_album(frontend, query): +def _list_album(context, query): albums = set() - playlist = frontend.backend.library.find_exact(**query).get() + playlist = context.backend.library.find_exact(**query).get() for track in playlist.tracks: if track.album is not None: albums.add((u'Album', track.album.name)) return albums -def _list_date(frontend, query): +def _list_date(context, query): dates = set() - playlist = frontend.backend.library.find_exact(**query).get() + playlist = context.backend.library.find_exact(**query).get() for track in playlist.tracks: if track.date is not None: dates.add((u'Date', track.date.strftime('%Y-%m-%d'))) return dates @handle_pattern(r'^listall "(?P[^"]+)"') -def listall(frontend, uri): +def listall(context, uri): """ *musicpd.org, music database section:* @@ -249,7 +249,7 @@ def listall(frontend, uri): raise MpdNotImplemented # TODO @handle_pattern(r'^listallinfo "(?P[^"]+)"') -def listallinfo(frontend, uri): +def listallinfo(context, uri): """ *musicpd.org, music database section:* @@ -262,7 +262,7 @@ def listallinfo(frontend, uri): @handle_pattern(r'^lsinfo$') @handle_pattern(r'^lsinfo "(?P[^"]*)"$') -def lsinfo(frontend, uri=None): +def lsinfo(context, uri=None): """ *musicpd.org, music database section:* @@ -279,11 +279,11 @@ def lsinfo(frontend, uri=None): ""``, and ``lsinfo "/"``. """ if uri is None or uri == u'/' or uri == u'': - return stored_playlists.listplaylists(frontend) + return stored_playlists.listplaylists(context) raise MpdNotImplemented # TODO @handle_pattern(r'^rescan( "(?P[^"]+)")*$') -def rescan(frontend, uri=None): +def rescan(context, uri=None): """ *musicpd.org, music database section:* @@ -291,12 +291,12 @@ def rescan(frontend, uri=None): Same as ``update``, but also rescans unmodified files. """ - return update(frontend, uri, rescan_unmodified_files=True) + return update(context, uri, rescan_unmodified_files=True) @handle_pattern(r'^search ' r'(?P("?([Aa]lbum|[Aa]rtist|[Dd]ate|[Ff]ilename|' r'[Tt]itle|[Aa]ny)"? "[^"]+"\s?)+)$') -def search(frontend, mpd_query): +def search(context, mpd_query): """ *musicpd.org, music database section:* @@ -324,10 +324,10 @@ def search(frontend, mpd_query): - also uses the search type "date". """ query = _build_query(mpd_query) - return frontend.backend.library.search(**query).get().mpd_format() + return context.backend.library.search(**query).get().mpd_format() @handle_pattern(r'^update( "(?P[^"]+)")*$') -def update(frontend, uri=None, rescan_unmodified_files=False): +def update(context, uri=None, rescan_unmodified_files=False): """ *musicpd.org, music database section:* diff --git a/mopidy/frontends/mpd/protocol/playback.py b/mopidy/frontends/mpd/protocol/playback.py index 65282f42..b8646a8b 100644 --- a/mopidy/frontends/mpd/protocol/playback.py +++ b/mopidy/frontends/mpd/protocol/playback.py @@ -5,7 +5,7 @@ from mopidy.frontends.mpd.exceptions import (MpdArgError, MpdNoExistError, @handle_pattern(r'^consume (?P[01])$') @handle_pattern(r'^consume "(?P[01])"$') -def consume(frontend, state): +def consume(context, state): """ *musicpd.org, playback section:* @@ -16,12 +16,12 @@ def consume(frontend, state): playlist. """ if int(state): - frontend.backend.playback.consume = True + context.backend.playback.consume = True else: - frontend.backend.playback.consume = False + context.backend.playback.consume = False @handle_pattern(r'^crossfade "(?P\d+)"$') -def crossfade(frontend, seconds): +def crossfade(context, seconds): """ *musicpd.org, playback section:* @@ -33,7 +33,7 @@ def crossfade(frontend, seconds): raise MpdNotImplemented # TODO @handle_pattern(r'^next$') -def next_(frontend): +def next_(context): """ *musicpd.org, playback section:* @@ -87,11 +87,11 @@ def next_(frontend): order as the first time. """ - return frontend.backend.playback.next().get() + return context.backend.playback.next().get() @handle_pattern(r'^pause$') @handle_pattern(r'^pause "(?P[01])"$') -def pause(frontend, state=None): +def pause(context, state=None): """ *musicpd.org, playback section:* @@ -104,28 +104,28 @@ def pause(frontend, state=None): - Calls ``pause`` without any arguments to toogle pause. """ if state is None: - if (frontend.backend.playback.state.get() == + if (context.backend.playback.state.get() == PlaybackController.PLAYING): - frontend.backend.playback.pause() - elif (frontend.backend.playback.state.get() == + context.backend.playback.pause() + elif (context.backend.playback.state.get() == PlaybackController.PAUSED): - frontend.backend.playback.resume() + context.backend.playback.resume() elif int(state): - frontend.backend.playback.pause() + context.backend.playback.pause() else: - frontend.backend.playback.resume() + context.backend.playback.resume() @handle_pattern(r'^play$') -def play(frontend): +def play(context): """ The original MPD server resumes from the paused state on ``play`` without arguments. """ - return frontend.backend.playback.play().get() + return context.backend.playback.play().get() @handle_pattern(r'^playid "(?P\d+)"$') @handle_pattern(r'^playid "(?P-1)"$') -def playid(frontend, cpid): +def playid(context, cpid): """ *musicpd.org, playback section:* @@ -144,16 +144,16 @@ def playid(frontend, cpid): """ cpid = int(cpid) if cpid == -1: - return _play_minus_one(frontend) + return _play_minus_one(context) try: - cp_track = frontend.backend.current_playlist.get(cpid=cpid).get() - return frontend.backend.playback.play(cp_track).get() + cp_track = context.backend.current_playlist.get(cpid=cpid).get() + return context.backend.playback.play(cp_track).get() except LookupError: raise MpdNoExistError(u'No such song', command=u'playid') @handle_pattern(r'^play (?P-?\d+)$') @handle_pattern(r'^play "(?P-?\d+)"$') -def playpos(frontend, songpos): +def playpos(context, songpos): """ *musicpd.org, playback section:* @@ -176,29 +176,29 @@ def playpos(frontend, songpos): """ songpos = int(songpos) if songpos == -1: - return _play_minus_one(frontend) + return _play_minus_one(context) try: - cp_track = frontend.backend.current_playlist.cp_tracks.get()[songpos] - return frontend.backend.playback.play(cp_track).get() + cp_track = context.backend.current_playlist.cp_tracks.get()[songpos] + return context.backend.playback.play(cp_track).get() except IndexError: raise MpdArgError(u'Bad song index', command=u'play') -def _play_minus_one(frontend): - if (frontend.backend.playback.state.get() == PlaybackController.PLAYING): +def _play_minus_one(context): + if (context.backend.playback.state.get() == PlaybackController.PLAYING): return # Nothing to do - elif (frontend.backend.playback.state.get() == PlaybackController.PAUSED): - return frontend.backend.playback.resume().get() - elif frontend.backend.playback.current_cp_track.get() is not None: - cp_track = frontend.backend.playback.current_cp_track.get() - return frontend.backend.playback.play(cp_track).get() - elif frontend.backend.current_playlist.cp_tracks.get(): - cp_track = frontend.backend.current_playlist.cp_tracks.get()[0] - return frontend.backend.playback.play(cp_track).get() + elif (context.backend.playback.state.get() == PlaybackController.PAUSED): + return context.backend.playback.resume().get() + elif context.backend.playback.current_cp_track.get() is not None: + cp_track = context.backend.playback.current_cp_track.get() + return context.backend.playback.play(cp_track).get() + elif context.backend.current_playlist.cp_tracks.get(): + cp_track = context.backend.current_playlist.cp_tracks.get()[0] + return context.backend.playback.play(cp_track).get() else: return # Fail silently @handle_pattern(r'^previous$') -def previous(frontend): +def previous(context): """ *musicpd.org, playback section:* @@ -241,11 +241,11 @@ def previous(frontend): ``previous`` should do a seek to time position 0. """ - return frontend.backend.playback.previous().get() + return context.backend.playback.previous().get() @handle_pattern(r'^random (?P[01])$') @handle_pattern(r'^random "(?P[01])"$') -def random(frontend, state): +def random(context, state): """ *musicpd.org, playback section:* @@ -254,13 +254,13 @@ def random(frontend, state): Sets random state to ``STATE``, ``STATE`` should be 0 or 1. """ if int(state): - frontend.backend.playback.random = True + context.backend.playback.random = True else: - frontend.backend.playback.random = False + context.backend.playback.random = False @handle_pattern(r'^repeat (?P[01])$') @handle_pattern(r'^repeat "(?P[01])"$') -def repeat(frontend, state): +def repeat(context, state): """ *musicpd.org, playback section:* @@ -269,12 +269,12 @@ def repeat(frontend, state): Sets repeat state to ``STATE``, ``STATE`` should be 0 or 1. """ if int(state): - frontend.backend.playback.repeat = True + context.backend.playback.repeat = True else: - frontend.backend.playback.repeat = False + context.backend.playback.repeat = False @handle_pattern(r'^replay_gain_mode "(?P(off|track|album))"$') -def replay_gain_mode(frontend, mode): +def replay_gain_mode(context, mode): """ *musicpd.org, playback section:* @@ -290,7 +290,7 @@ def replay_gain_mode(frontend, mode): raise MpdNotImplemented # TODO @handle_pattern(r'^replay_gain_status$') -def replay_gain_status(frontend): +def replay_gain_status(context): """ *musicpd.org, playback section:* @@ -303,7 +303,7 @@ def replay_gain_status(frontend): @handle_pattern(r'^seek (?P\d+) (?P\d+)$') @handle_pattern(r'^seek "(?P\d+)" "(?P\d+)"$') -def seek(frontend, songpos, seconds): +def seek(context, songpos, seconds): """ *musicpd.org, playback section:* @@ -316,12 +316,12 @@ def seek(frontend, songpos, seconds): - issues ``seek 1 120`` without quotes around the arguments. """ - if frontend.backend.playback.current_playlist_position != songpos: - playpos(frontend, songpos) - frontend.backend.playback.seek(int(seconds) * 1000) + if context.backend.playback.current_playlist_position != songpos: + playpos(context, songpos) + context.backend.playback.seek(int(seconds) * 1000) @handle_pattern(r'^seekid "(?P\d+)" "(?P\d+)"$') -def seekid(frontend, cpid, seconds): +def seekid(context, cpid, seconds): """ *musicpd.org, playback section:* @@ -329,13 +329,13 @@ def seekid(frontend, cpid, seconds): Seeks to the position ``TIME`` (in seconds) of song ``SONGID``. """ - if frontend.backend.playback.current_cpid != cpid: - playid(frontend, cpid) - frontend.backend.playback.seek(int(seconds) * 1000) + if context.backend.playback.current_cpid != cpid: + playid(context, cpid) + context.backend.playback.seek(int(seconds) * 1000) @handle_pattern(r'^setvol (?P[-+]*\d+)$') @handle_pattern(r'^setvol "(?P[-+]*\d+)"$') -def setvol(frontend, volume): +def setvol(context, volume): """ *musicpd.org, playback section:* @@ -352,11 +352,11 @@ def setvol(frontend, volume): volume = 0 if volume > 100: volume = 100 - frontend.mixer.volume = volume + context.mixer.volume = volume @handle_pattern(r'^single (?P[01])$') @handle_pattern(r'^single "(?P[01])"$') -def single(frontend, state): +def single(context, state): """ *musicpd.org, playback section:* @@ -367,12 +367,12 @@ def single(frontend, state): song is repeated if the ``repeat`` mode is enabled. """ if int(state): - frontend.backend.playback.single = True + context.backend.playback.single = True else: - frontend.backend.playback.single = False + context.backend.playback.single = False @handle_pattern(r'^stop$') -def stop(frontend): +def stop(context): """ *musicpd.org, playback section:* @@ -380,4 +380,4 @@ def stop(frontend): Stops playing. """ - frontend.backend.playback.stop() + context.backend.playback.stop() diff --git a/mopidy/frontends/mpd/protocol/reflection.py b/mopidy/frontends/mpd/protocol/reflection.py index ab782440..181dce54 100644 --- a/mopidy/frontends/mpd/protocol/reflection.py +++ b/mopidy/frontends/mpd/protocol/reflection.py @@ -2,7 +2,7 @@ from mopidy.frontends.mpd.protocol import handle_pattern, mpd_commands from mopidy.frontends.mpd.exceptions import MpdNotImplemented @handle_pattern(r'^commands$') -def commands(frontend): +def commands(context): """ *musicpd.org, reflection section:* @@ -28,7 +28,7 @@ def commands(frontend): return [('command', c) for c in sorted_commands] @handle_pattern(r'^decoders$') -def decoders(frontend): +def decoders(context): """ *musicpd.org, reflection section:* @@ -47,7 +47,7 @@ def decoders(frontend): raise MpdNotImplemented # TODO @handle_pattern(r'^notcommands$') -def notcommands(frontend): +def notcommands(context): """ *musicpd.org, reflection section:* @@ -62,7 +62,7 @@ def notcommands(frontend): pass @handle_pattern(r'^tagtypes$') -def tagtypes(frontend): +def tagtypes(context): """ *musicpd.org, reflection section:* @@ -73,7 +73,7 @@ def tagtypes(frontend): pass # TODO @handle_pattern(r'^urlhandlers$') -def urlhandlers(frontend): +def urlhandlers(context): """ *musicpd.org, reflection section:* @@ -81,4 +81,4 @@ def urlhandlers(frontend): Gets a list of available URL handlers. """ - return [(u'handler', uri) for uri in frontend.backend.uri_handlers.get()] + return [(u'handler', uri) for uri in context.backend.uri_handlers.get()] diff --git a/mopidy/frontends/mpd/protocol/status.py b/mopidy/frontends/mpd/protocol/status.py index a78efc0a..4f9e00cd 100644 --- a/mopidy/frontends/mpd/protocol/status.py +++ b/mopidy/frontends/mpd/protocol/status.py @@ -3,7 +3,7 @@ from mopidy.frontends.mpd.protocol import handle_pattern from mopidy.frontends.mpd.exceptions import MpdNotImplemented @handle_pattern(r'^clearerror$') -def clearerror(frontend): +def clearerror(context): """ *musicpd.org, status section:* @@ -15,7 +15,7 @@ def clearerror(frontend): raise MpdNotImplemented # TODO @handle_pattern(r'^currentsong$') -def currentsong(frontend): +def currentsong(context): """ *musicpd.org, status section:* @@ -24,15 +24,15 @@ def currentsong(frontend): Displays the song info of the current song (same song that is identified in status). """ - current_cp_track = frontend.backend.playback.current_cp_track.get() + current_cp_track = context.backend.playback.current_cp_track.get() if current_cp_track is not None: return current_cp_track[1].mpd_format( - position=frontend.backend.playback.current_playlist_position.get(), + position=context.backend.playback.current_playlist_position.get(), cpid=current_cp_track[0]) @handle_pattern(r'^idle$') @handle_pattern(r'^idle (?P.+)$') -def idle(frontend, subsystems=None): +def idle(context, subsystems=None): """ *musicpd.org, status section:* @@ -68,12 +68,12 @@ def idle(frontend, subsystems=None): pass # TODO @handle_pattern(r'^noidle$') -def noidle(frontend): +def noidle(context): """See :meth:`_status_idle`.""" pass # TODO @handle_pattern(r'^stats$') -def stats(frontend): +def stats(context): """ *musicpd.org, status section:* @@ -99,7 +99,7 @@ def stats(frontend): } @handle_pattern(r'^status$') -def status(frontend): +def status(context): """ *musicpd.org, status section:* @@ -131,64 +131,64 @@ def status(frontend): - ``error``: if there is an error, returns message here """ result = [ - ('volume', _status_volume(frontend)), - ('repeat', _status_repeat(frontend)), - ('random', _status_random(frontend)), - ('single', _status_single(frontend)), - ('consume', _status_consume(frontend)), - ('playlist', _status_playlist_version(frontend)), - ('playlistlength', _status_playlist_length(frontend)), - ('xfade', _status_xfade(frontend)), - ('state', _status_state(frontend)), + ('volume', _status_volume(context)), + ('repeat', _status_repeat(context)), + ('random', _status_random(context)), + ('single', _status_single(context)), + ('consume', _status_consume(context)), + ('playlist', _status_playlist_version(context)), + ('playlistlength', _status_playlist_length(context)), + ('xfade', _status_xfade(context)), + ('state', _status_state(context)), ] - if frontend.backend.playback.current_track.get() is not None: - result.append(('song', _status_songpos(frontend))) - result.append(('songid', _status_songid(frontend))) - if frontend.backend.playback.state.get() in (PlaybackController.PLAYING, + if context.backend.playback.current_track.get() is not None: + result.append(('song', _status_songpos(context))) + result.append(('songid', _status_songid(context))) + if context.backend.playback.state.get() in (PlaybackController.PLAYING, PlaybackController.PAUSED): - result.append(('time', _status_time(frontend))) - result.append(('elapsed', _status_time_elapsed(frontend))) - result.append(('bitrate', _status_bitrate(frontend))) + result.append(('time', _status_time(context))) + result.append(('elapsed', _status_time_elapsed(context))) + result.append(('bitrate', _status_bitrate(context))) return result -def _status_bitrate(frontend): - current_track = frontend.backend.playback.current_track.get() +def _status_bitrate(context): + current_track = context.backend.playback.current_track.get() if current_track is not None: return current_track.bitrate -def _status_consume(frontend): - if frontend.backend.playback.consume.get(): +def _status_consume(context): + if context.backend.playback.consume.get(): return 1 else: return 0 -def _status_playlist_length(frontend): - return len(frontend.backend.current_playlist.tracks.get()) +def _status_playlist_length(context): + return len(context.backend.current_playlist.tracks.get()) -def _status_playlist_version(frontend): - return frontend.backend.current_playlist.version.get() +def _status_playlist_version(context): + return context.backend.current_playlist.version.get() -def _status_random(frontend): - return int(frontend.backend.playback.random.get()) +def _status_random(context): + return int(context.backend.playback.random.get()) -def _status_repeat(frontend): - return int(frontend.backend.playback.repeat.get()) +def _status_repeat(context): + return int(context.backend.playback.repeat.get()) -def _status_single(frontend): - return int(frontend.backend.playback.single.get()) +def _status_single(context): + return int(context.backend.playback.single.get()) -def _status_songid(frontend): - current_cpid = frontend.backend.playback.current_cpid.get() +def _status_songid(context): + current_cpid = context.backend.playback.current_cpid.get() if current_cpid is not None: return current_cpid else: - return _status_songpos(frontend) + return _status_songpos(context) -def _status_songpos(frontend): - return frontend.backend.playback.current_playlist_position.get() +def _status_songpos(context): + return context.backend.playback.current_playlist_position.get() -def _status_state(frontend): - state = frontend.backend.playback.state.get() +def _status_state(context): + state = context.backend.playback.state.get() if state == PlaybackController.PLAYING: return u'play' elif state == PlaybackController.STOPPED: @@ -196,15 +196,15 @@ def _status_state(frontend): elif state == PlaybackController.PAUSED: return u'pause' -def _status_time(frontend): - return u'%s:%s' % (_status_time_elapsed(frontend) // 1000, - _status_time_total(frontend) // 1000) +def _status_time(context): + return u'%s:%s' % (_status_time_elapsed(context) // 1000, + _status_time_total(context) // 1000) -def _status_time_elapsed(frontend): - return frontend.backend.playback.time_position.get() +def _status_time_elapsed(context): + return context.backend.playback.time_position.get() -def _status_time_total(frontend): - current_track = frontend.backend.playback.current_track.get() +def _status_time_total(context): + current_track = context.backend.playback.current_track.get() if current_track is None: return 0 elif current_track.length is None: @@ -212,12 +212,12 @@ def _status_time_total(frontend): else: return current_track.length -def _status_volume(frontend): - volume = frontend.mixer.volume.get() +def _status_volume(context): + volume = context.mixer.volume.get() if volume is not None: return volume else: return 0 -def _status_xfade(frontend): +def _status_xfade(context): return 0 # TODO diff --git a/mopidy/frontends/mpd/protocol/stickers.py b/mopidy/frontends/mpd/protocol/stickers.py index 145665eb..c1b7be16 100644 --- a/mopidy/frontends/mpd/protocol/stickers.py +++ b/mopidy/frontends/mpd/protocol/stickers.py @@ -3,7 +3,7 @@ from mopidy.frontends.mpd.exceptions import MpdNotImplemented @handle_pattern(r'^sticker delete "(?P[^"]+)" ' r'"(?P[^"]+)"( "(?P[^"]+)")*$') -def sticker_delete(frontend, field, uri, name=None): +def sticker_delete(context, field, uri, name=None): """ *musicpd.org, sticker section:* @@ -16,7 +16,7 @@ def sticker_delete(frontend, field, uri, name=None): @handle_pattern(r'^sticker find "(?P[^"]+)" "(?P[^"]+)" ' r'"(?P[^"]+)"$') -def sticker_find(frontend, field, uri, name): +def sticker_find(context, field, uri, name): """ *musicpd.org, sticker section:* @@ -30,7 +30,7 @@ def sticker_find(frontend, field, uri, name): @handle_pattern(r'^sticker get "(?P[^"]+)" "(?P[^"]+)" ' r'"(?P[^"]+)"$') -def sticker_get(frontend, field, uri, name): +def sticker_get(context, field, uri, name): """ *musicpd.org, sticker section:* @@ -41,7 +41,7 @@ def sticker_get(frontend, field, uri, name): raise MpdNotImplemented # TODO @handle_pattern(r'^sticker list "(?P[^"]+)" "(?P[^"]+)"$') -def sticker_list(frontend, field, uri): +def sticker_list(context, field, uri): """ *musicpd.org, sticker section:* @@ -53,7 +53,7 @@ def sticker_list(frontend, field, uri): @handle_pattern(r'^sticker set "(?P[^"]+)" "(?P[^"]+)" ' r'"(?P[^"]+)" "(?P[^"]+)"$') -def sticker_set(frontend, field, uri, name, value): +def sticker_set(context, field, uri, name, value): """ *musicpd.org, sticker section:* diff --git a/mopidy/frontends/mpd/protocol/stored_playlists.py b/mopidy/frontends/mpd/protocol/stored_playlists.py index 6eccffac..764a8e12 100644 --- a/mopidy/frontends/mpd/protocol/stored_playlists.py +++ b/mopidy/frontends/mpd/protocol/stored_playlists.py @@ -4,7 +4,7 @@ from mopidy.frontends.mpd.protocol import handle_pattern from mopidy.frontends.mpd.exceptions import MpdNoExistError, MpdNotImplemented @handle_pattern(r'^listplaylist "(?P[^"]+)"$') -def listplaylist(frontend, name): +def listplaylist(context, name): """ *musicpd.org, stored playlists section:* @@ -19,13 +19,13 @@ def listplaylist(frontend, name): file: relative/path/to/file3.mp3 """ try: - playlist = frontend.backend.stored_playlists.get(name=name).get() + playlist = context.backend.stored_playlists.get(name=name).get() return ['file: %s' % t.uri for t in playlist.tracks] except LookupError: raise MpdNoExistError(u'No such playlist', command=u'listplaylist') @handle_pattern(r'^listplaylistinfo "(?P[^"]+)"$') -def listplaylistinfo(frontend, name): +def listplaylistinfo(context, name): """ *musicpd.org, stored playlists section:* @@ -39,14 +39,14 @@ def listplaylistinfo(frontend, name): Album, Artist, Track """ try: - playlist = frontend.backend.stored_playlists.get(name=name).get() + playlist = context.backend.stored_playlists.get(name=name).get() return playlist.mpd_format() except LookupError: raise MpdNoExistError( u'No such playlist', command=u'listplaylistinfo') @handle_pattern(r'^listplaylists$') -def listplaylists(frontend): +def listplaylists(context): """ *musicpd.org, stored playlists section:* @@ -67,7 +67,7 @@ def listplaylists(frontend): Last-Modified: 2010-02-06T02:11:08Z """ result = [] - for playlist in frontend.backend.stored_playlists.playlists.get(): + for playlist in context.backend.stored_playlists.playlists.get(): result.append((u'playlist', playlist.name)) last_modified = (playlist.last_modified or dt.datetime.now()).isoformat() @@ -80,7 +80,7 @@ def listplaylists(frontend): return result @handle_pattern(r'^load "(?P[^"]+)"$') -def load(frontend, name): +def load(context, name): """ *musicpd.org, stored playlists section:* @@ -93,13 +93,13 @@ def load(frontend, name): - ``load`` appends the given playlist to the current playlist. """ try: - playlist = frontend.backend.stored_playlists.get(name=name).get() - frontend.backend.current_playlist.append(playlist.tracks) + playlist = context.backend.stored_playlists.get(name=name).get() + context.backend.current_playlist.append(playlist.tracks) except LookupError: raise MpdNoExistError(u'No such playlist', command=u'load') @handle_pattern(r'^playlistadd "(?P[^"]+)" "(?P[^"]+)"$') -def playlistadd(frontend, name, uri): +def playlistadd(context, name, uri): """ *musicpd.org, stored playlists section:* @@ -112,7 +112,7 @@ def playlistadd(frontend, name, uri): raise MpdNotImplemented # TODO @handle_pattern(r'^playlistclear "(?P[^"]+)"$') -def playlistclear(frontend, name): +def playlistclear(context, name): """ *musicpd.org, stored playlists section:* @@ -123,7 +123,7 @@ def playlistclear(frontend, name): raise MpdNotImplemented # TODO @handle_pattern(r'^playlistdelete "(?P[^"]+)" "(?P\d+)"$') -def playlistdelete(frontend, name, songpos): +def playlistdelete(context, name, songpos): """ *musicpd.org, stored playlists section:* @@ -135,7 +135,7 @@ def playlistdelete(frontend, name, songpos): @handle_pattern(r'^playlistmove "(?P[^"]+)" ' r'"(?P\d+)" "(?P\d+)"$') -def playlistmove(frontend, name, from_pos, to_pos): +def playlistmove(context, name, from_pos, to_pos): """ *musicpd.org, stored playlists section:* @@ -153,7 +153,7 @@ def playlistmove(frontend, name, from_pos, to_pos): raise MpdNotImplemented # TODO @handle_pattern(r'^rename "(?P[^"]+)" "(?P[^"]+)"$') -def rename(frontend, old_name, new_name): +def rename(context, old_name, new_name): """ *musicpd.org, stored playlists section:* @@ -164,7 +164,7 @@ def rename(frontend, old_name, new_name): raise MpdNotImplemented # TODO @handle_pattern(r'^rm "(?P[^"]+)"$') -def rm(frontend, name): +def rm(context, name): """ *musicpd.org, stored playlists section:* @@ -175,7 +175,7 @@ def rm(frontend, name): raise MpdNotImplemented # TODO @handle_pattern(r'^save "(?P[^"]+)"$') -def save(frontend, name): +def save(context, name): """ *musicpd.org, stored playlists section:* From d57727282e94026cc140e495d56f85f5e41c8334 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 3 Jun 2011 16:34:47 +0200 Subject: [PATCH 109/218] Create new MpdContext object which is passed to command handlers --- mopidy/frontends/mpd/dispatcher.py | 42 +++++++++--- mopidy/frontends/mpd/protocol/command_list.py | 20 +++--- tests/frontends/mpd/command_list_test.py | 50 +++++++------- tests/frontends/mpd/status_test.py | 66 ++++++++++--------- 4 files changed, 103 insertions(+), 75 deletions(-) diff --git a/mopidy/frontends/mpd/dispatcher.py b/mopidy/frontends/mpd/dispatcher.py index f5c30b23..87a7112c 100644 --- a/mopidy/frontends/mpd/dispatcher.py +++ b/mopidy/frontends/mpd/dispatcher.py @@ -26,16 +26,9 @@ class MpdDispatcher(object): # XXX Consider merging MpdDispatcher into MpdSession def __init__(self): - backend_refs = ActorRegistry.get_by_class(Backend) - assert len(backend_refs) == 1, 'Expected exactly one running backend.' - self.backend = backend_refs[0].proxy() - - mixer_refs = ActorRegistry.get_by_class(BaseMixer) - assert len(mixer_refs) == 1, 'Expected exactly one running mixer.' - self.mixer = mixer_refs[0].proxy() - self.command_list = False self.command_list_ok = False + self.context = MpdContext(self) def handle_request(self, request, command_list_index=None): """Dispatch incoming requests to the correct handler.""" @@ -44,7 +37,7 @@ class MpdDispatcher(object): return None try: (handler, kwargs) = self.find_handler(request) - result = handler(self, **kwargs) + result = handler(self.context, **kwargs) except MpdAckError as e: if command_list_index is not None: e.index = command_list_index @@ -87,3 +80,34 @@ class MpdDispatcher(object): if add_ok and (not response or not response[-1].startswith(u'ACK')): response.append(u'OK') return response + + +class MpdContext(object): + """ + This object is passed as the first argument to all MPD command handlers to + give the command handlers access to important parts of Mopidy. + """ + + #: The current :class:`MpdDispatcher`. + dispatcher = None + + #: The backend. An instance of :class:`mopidy.backends.base.Backend`. + backend = None + + #: The mixer. An instance of :class:`mopidy.mixers.base.BaseMixer`. + mixer = None + + def __init__(self, dispatcher): + self.dispatcher = dispatcher + self.backend = self._get_backend() + self.mixer = self._get_mixer() + + def _get_backend(self): + backend_refs = ActorRegistry.get_by_class(Backend) + assert len(backend_refs) == 1, 'Expected exactly one running backend.' + return backend_refs[0].proxy() + + def _get_mixer(self): + mixer_refs = ActorRegistry.get_by_class(BaseMixer) + assert len(mixer_refs) == 1, 'Expected exactly one running mixer.' + return mixer_refs[0].proxy() diff --git a/mopidy/frontends/mpd/protocol/command_list.py b/mopidy/frontends/mpd/protocol/command_list.py index cecff9fd..78fccec6 100644 --- a/mopidy/frontends/mpd/protocol/command_list.py +++ b/mopidy/frontends/mpd/protocol/command_list.py @@ -18,21 +18,23 @@ def command_list_begin(context): returned. If ``command_list_ok_begin`` is used, ``list_OK`` is returned for each successful command executed in the command list. """ - context.command_list = [] - context.command_list_ok = False + context.dispatcher.command_list = [] + context.dispatcher.command_list_ok = False @handle_pattern(r'^command_list_end$') def command_list_end(context): """See :meth:`command_list_begin()`.""" - if context.command_list is False: + if context.dispatcher.command_list is False: # Test for False exactly, and not e.g. empty list raise MpdUnknownCommand(command='command_list_end') - (command_list, context.command_list) = (context.command_list, False) - (command_list_ok, context.command_list_ok) = ( - context.command_list_ok, False) + (command_list, context.dispatcher.command_list) = ( + context.dispatcher.command_list, False) + (command_list_ok, context.dispatcher.command_list_ok) = ( + context.dispatcher.command_list_ok, False) result = [] for i, command in enumerate(command_list): - response = context.handle_request(command, command_list_index=i) + response = context.dispatcher.handle_request( + command, command_list_index=i) if response is not None: result.append(response) if response and response[-1].startswith(u'ACK'): @@ -44,5 +46,5 @@ def command_list_end(context): @handle_pattern(r'^command_list_ok_begin$') def command_list_ok_begin(context): """See :meth:`command_list_begin()`.""" - context.command_list = [] - context.command_list_ok = True + context.dispatcher.command_list = [] + context.dispatcher.command_list_ok = True diff --git a/tests/frontends/mpd/command_list_test.py b/tests/frontends/mpd/command_list_test.py index 7ff96bac..3537ee77 100644 --- a/tests/frontends/mpd/command_list_test.py +++ b/tests/frontends/mpd/command_list_test.py @@ -8,55 +8,55 @@ class CommandListsTest(unittest.TestCase): def setUp(self): self.b = DummyBackend.start().proxy() self.mixer = DummyMixer.start().proxy() - self.h = dispatcher.MpdDispatcher() + self.dispatcher = dispatcher.MpdDispatcher() def tearDown(self): self.b.stop().get() self.mixer.stop().get() def test_command_list_begin(self): - result = self.h.handle_request(u'command_list_begin') + result = self.dispatcher.handle_request(u'command_list_begin') self.assert_(result is None) def test_command_list_end(self): - self.h.handle_request(u'command_list_begin') - result = self.h.handle_request(u'command_list_end') + self.dispatcher.handle_request(u'command_list_begin') + result = self.dispatcher.handle_request(u'command_list_end') self.assert_(u'OK' in result) def test_command_list_end_without_start_first_is_an_unknown_command(self): - result = self.h.handle_request(u'command_list_end') + result = self.dispatcher.handle_request(u'command_list_end') self.assertEquals(result[0], u'ACK [5@0] {} unknown command "command_list_end"') def test_command_list_with_ping(self): - self.h.handle_request(u'command_list_begin') - self.assertEqual([], self.h.command_list) - self.assertEqual(False, self.h.command_list_ok) - self.h.handle_request(u'ping') - self.assert_(u'ping' in self.h.command_list) - result = self.h.handle_request(u'command_list_end') + self.dispatcher.handle_request(u'command_list_begin') + self.assertEqual([], self.dispatcher.command_list) + self.assertEqual(False, self.dispatcher.command_list_ok) + self.dispatcher.handle_request(u'ping') + self.assert_(u'ping' in self.dispatcher.command_list) + result = self.dispatcher.handle_request(u'command_list_end') self.assert_(u'OK' in result) - self.assertEqual(False, self.h.command_list) + self.assertEqual(False, self.dispatcher.command_list) def test_command_list_with_error_returns_ack_with_correct_index(self): - self.h.handle_request(u'command_list_begin') - self.h.handle_request(u'play') # Known command - self.h.handle_request(u'paly') # Unknown command - result = self.h.handle_request(u'command_list_end') + self.dispatcher.handle_request(u'command_list_begin') + self.dispatcher.handle_request(u'play') # Known command + self.dispatcher.handle_request(u'paly') # Unknown command + result = self.dispatcher.handle_request(u'command_list_end') self.assertEqual(result[0], u'ACK [5@1] {} unknown command "paly"') def test_command_list_ok_begin(self): - result = self.h.handle_request(u'command_list_ok_begin') + result = self.dispatcher.handle_request(u'command_list_ok_begin') self.assert_(result is None) def test_command_list_ok_with_ping(self): - self.h.handle_request(u'command_list_ok_begin') - self.assertEqual([], self.h.command_list) - self.assertEqual(True, self.h.command_list_ok) - self.h.handle_request(u'ping') - self.assert_(u'ping' in self.h.command_list) - result = self.h.handle_request(u'command_list_end') + self.dispatcher.handle_request(u'command_list_ok_begin') + self.assertEqual([], self.dispatcher.command_list) + self.assertEqual(True, self.dispatcher.command_list_ok) + self.dispatcher.handle_request(u'ping') + self.assert_(u'ping' in self.dispatcher.command_list) + result = self.dispatcher.handle_request(u'command_list_end') self.assert_(u'list_OK' in result) self.assert_(u'OK' in result) - self.assertEqual(False, self.h.command_list) - self.assertEqual(False, self.h.command_list_ok) + self.assertEqual(False, self.dispatcher.command_list) + self.assertEqual(False, self.dispatcher.command_list_ok) diff --git a/tests/frontends/mpd/status_test.py b/tests/frontends/mpd/status_test.py index 791d734f..7fa9d8de 100644 --- a/tests/frontends/mpd/status_test.py +++ b/tests/frontends/mpd/status_test.py @@ -2,7 +2,8 @@ import unittest from mopidy.backends.base import PlaybackController from mopidy.backends.dummy import DummyBackend -from mopidy.frontends.mpd import dispatcher +from mopidy.frontends.mpd.dispatcher import MpdDispatcher +from mopidy.frontends.mpd.protocol import status from mopidy.mixers.dummy import DummyMixer from mopidy.models import Track @@ -14,21 +15,22 @@ class StatusHandlerTest(unittest.TestCase): def setUp(self): self.b = DummyBackend.start().proxy() self.mixer = DummyMixer.start().proxy() - self.h = dispatcher.MpdDispatcher() + self.dispatcher = MpdDispatcher() + self.context = self.dispatcher.context def tearDown(self): self.b.stop().get() self.mixer.stop().get() def test_clearerror(self): - result = self.h.handle_request(u'clearerror') + result = self.dispatcher.handle_request(u'clearerror') self.assert_(u'ACK [0@0] {} Not implemented' in result) def test_currentsong(self): track = Track() self.b.current_playlist.append([track]) self.b.playback.play() - result = self.h.handle_request(u'currentsong') + result = self.dispatcher.handle_request(u'currentsong') self.assert_(u'file: ' in result) self.assert_(u'Time: 0' in result) self.assert_(u'Artist: ' in result) @@ -41,27 +43,27 @@ class StatusHandlerTest(unittest.TestCase): self.assert_(u'OK' in result) def test_currentsong_without_song(self): - result = self.h.handle_request(u'currentsong') + result = self.dispatcher.handle_request(u'currentsong') self.assert_(u'OK' in result) def test_idle_without_subsystems(self): - result = self.h.handle_request(u'idle') + result = self.dispatcher.handle_request(u'idle') self.assert_(u'OK' in result) def test_idle_with_subsystems(self): - result = self.h.handle_request(u'idle database playlist') + result = self.dispatcher.handle_request(u'idle database playlist') self.assert_(u'OK' in result) def test_noidle(self): - result = self.h.handle_request(u'noidle') + result = self.dispatcher.handle_request(u'noidle') self.assert_(u'OK' in result) def test_stats_command(self): - result = self.h.handle_request(u'stats') + result = self.dispatcher.handle_request(u'stats') self.assert_(u'OK' in result) def test_stats_method(self): - result = dispatcher.status.stats(self.h) + result = status.stats(self.context) self.assert_('artists' in result) self.assert_(int(result['artists']) >= 0) self.assert_('albums' in result) @@ -78,110 +80,110 @@ class StatusHandlerTest(unittest.TestCase): self.assert_(int(result['playtime']) >= 0) def test_status_command(self): - result = self.h.handle_request(u'status') + result = self.dispatcher.handle_request(u'status') self.assert_(u'OK' in result) def test_status_method_contains_volume_which_defaults_to_0(self): - result = dict(dispatcher.status.status(self.h)) + result = dict(status.status(self.context)) self.assert_('volume' in result) self.assertEqual(int(result['volume']), 0) def test_status_method_contains_volume(self): self.mixer.volume = 17 - result = dict(dispatcher.status.status(self.h)) + result = dict(status.status(self.context)) self.assert_('volume' in result) self.assertEqual(int(result['volume']), 17) def test_status_method_contains_repeat_is_0(self): - result = dict(dispatcher.status.status(self.h)) + result = dict(status.status(self.context)) self.assert_('repeat' in result) self.assertEqual(int(result['repeat']), 0) def test_status_method_contains_repeat_is_1(self): self.b.playback.repeat = 1 - result = dict(dispatcher.status.status(self.h)) + result = dict(status.status(self.context)) self.assert_('repeat' in result) self.assertEqual(int(result['repeat']), 1) def test_status_method_contains_random_is_0(self): - result = dict(dispatcher.status.status(self.h)) + result = dict(status.status(self.context)) self.assert_('random' in result) self.assertEqual(int(result['random']), 0) def test_status_method_contains_random_is_1(self): self.b.playback.random = 1 - result = dict(dispatcher.status.status(self.h)) + result = dict(status.status(self.context)) self.assert_('random' in result) self.assertEqual(int(result['random']), 1) def test_status_method_contains_single(self): - result = dict(dispatcher.status.status(self.h)) + result = dict(status.status(self.context)) self.assert_('single' in result) self.assert_(int(result['single']) in (0, 1)) def test_status_method_contains_consume_is_0(self): - result = dict(dispatcher.status.status(self.h)) + result = dict(status.status(self.context)) self.assert_('consume' in result) self.assertEqual(int(result['consume']), 0) def test_status_method_contains_consume_is_1(self): self.b.playback.consume = 1 - result = dict(dispatcher.status.status(self.h)) + result = dict(status.status(self.context)) self.assert_('consume' in result) self.assertEqual(int(result['consume']), 1) def test_status_method_contains_playlist(self): - result = dict(dispatcher.status.status(self.h)) + result = dict(status.status(self.context)) self.assert_('playlist' in result) self.assert_(int(result['playlist']) in xrange(0, 2**31 - 1)) def test_status_method_contains_playlistlength(self): - result = dict(dispatcher.status.status(self.h)) + result = dict(status.status(self.context)) self.assert_('playlistlength' in result) self.assert_(int(result['playlistlength']) >= 0) def test_status_method_contains_xfade(self): - result = dict(dispatcher.status.status(self.h)) + result = dict(status.status(self.context)) self.assert_('xfade' in result) self.assert_(int(result['xfade']) >= 0) def test_status_method_contains_state_is_play(self): self.b.playback.state = PLAYING - result = dict(dispatcher.status.status(self.h)) + result = dict(status.status(self.context)) self.assert_('state' in result) self.assertEqual(result['state'], 'play') def test_status_method_contains_state_is_stop(self): self.b.playback.state = STOPPED - result = dict(dispatcher.status.status(self.h)) + result = dict(status.status(self.context)) self.assert_('state' in result) self.assertEqual(result['state'], 'stop') def test_status_method_contains_state_is_pause(self): self.b.playback.state = PLAYING self.b.playback.state = PAUSED - result = dict(dispatcher.status.status(self.h)) + result = dict(status.status(self.context)) self.assert_('state' in result) self.assertEqual(result['state'], 'pause') def test_status_method_when_playlist_loaded_contains_song(self): self.b.current_playlist.append([Track()]) self.b.playback.play() - result = dict(dispatcher.status.status(self.h)) + result = dict(status.status(self.context)) self.assert_('song' in result) self.assert_(int(result['song']) >= 0) def test_status_method_when_playlist_loaded_contains_cpid_as_songid(self): self.b.current_playlist.append([Track()]) self.b.playback.play() - result = dict(dispatcher.status.status(self.h)) + result = dict(status.status(self.context)) self.assert_('songid' in result) self.assertEqual(int(result['songid']), 0) def test_status_method_when_playing_contains_time_with_no_length(self): self.b.current_playlist.append([Track(length=None)]) self.b.playback.play() - result = dict(dispatcher.status.status(self.h)) + result = dict(status.status(self.context)) self.assert_('time' in result) (position, total) = result['time'].split(':') position = int(position) @@ -191,7 +193,7 @@ class StatusHandlerTest(unittest.TestCase): def test_status_method_when_playing_contains_time_with_length(self): self.b.current_playlist.append([Track(length=10000)]) self.b.playback.play() - result = dict(dispatcher.status.status(self.h)) + result = dict(status.status(self.context)) self.assert_('time' in result) (position, total) = result['time'].split(':') position = int(position) @@ -201,13 +203,13 @@ class StatusHandlerTest(unittest.TestCase): def test_status_method_when_playing_contains_elapsed(self): self.b.playback.state = PAUSED self.b.playback.play_time_accumulated = 59123 - result = dict(dispatcher.status.status(self.h)) + result = dict(status.status(self.context)) self.assert_('elapsed' in result) self.assertEqual(int(result['elapsed']), 59123) def test_status_method_when_playing_contains_bitrate(self): self.b.current_playlist.append([Track(bitrate=320)]) self.b.playback.play() - result = dict(dispatcher.status.status(self.h)) + result = dict(status.status(self.context)) self.assert_('bitrate' in result) self.assertEqual(int(result['bitrate']), 320) From cbdc37a24da152c9d3d007a9fccd89e8131f4b04 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 3 Jun 2011 16:52:52 +0200 Subject: [PATCH 110/218] Replace all single letter instance variables in MPD protocol tests --- tests/frontends/mpd/audio_output_test.py | 14 +- tests/frontends/mpd/connection_test.py | 22 +- tests/frontends/mpd/current_playlist_test.py | 273 +++++++------- tests/frontends/mpd/dispatcher_test.py | 16 +- tests/frontends/mpd/music_db_test.py | 210 ++++++----- tests/frontends/mpd/playback_test.py | 371 +++++++++---------- tests/frontends/mpd/reflection_test.py | 18 +- tests/frontends/mpd/stickers_test.py | 20 +- tests/frontends/mpd/stored_playlists_test.py | 52 +-- 9 files changed, 502 insertions(+), 494 deletions(-) diff --git a/tests/frontends/mpd/audio_output_test.py b/tests/frontends/mpd/audio_output_test.py index afa99d26..82d9e203 100644 --- a/tests/frontends/mpd/audio_output_test.py +++ b/tests/frontends/mpd/audio_output_test.py @@ -1,29 +1,29 @@ import unittest from mopidy.backends.dummy import DummyBackend -from mopidy.frontends.mpd import dispatcher +from mopidy.frontends.mpd.dispatcher import MpdDispatcher from mopidy.mixers.dummy import DummyMixer class AudioOutputHandlerTest(unittest.TestCase): def setUp(self): - self.b = DummyBackend.start().proxy() + self.backend = DummyBackend.start().proxy() self.mixer = DummyMixer.start().proxy() - self.h = dispatcher.MpdDispatcher() + self.dispatcher = MpdDispatcher() def tearDown(self): - self.b.stop().get() + self.backend.stop().get() self.mixer.stop().get() def test_enableoutput(self): - result = self.h.handle_request(u'enableoutput "0"') + result = self.dispatcher.handle_request(u'enableoutput "0"') self.assert_(u'ACK [0@0] {} Not implemented' in result) def test_disableoutput(self): - result = self.h.handle_request(u'disableoutput "0"') + result = self.dispatcher.handle_request(u'disableoutput "0"') self.assert_(u'ACK [0@0] {} Not implemented' in result) def test_outputs(self): - result = self.h.handle_request(u'outputs') + result = self.dispatcher.handle_request(u'outputs') self.assert_(u'outputid: 0' in result) self.assert_(u'outputname: None' in result) self.assert_(u'outputenabled: 1' in result) diff --git a/tests/frontends/mpd/connection_test.py b/tests/frontends/mpd/connection_test.py index cf161a5a..e34265d4 100644 --- a/tests/frontends/mpd/connection_test.py +++ b/tests/frontends/mpd/connection_test.py @@ -2,47 +2,47 @@ import unittest from mopidy import settings from mopidy.backends.dummy import DummyBackend -from mopidy.frontends.mpd import dispatcher +from mopidy.frontends.mpd.dispatcher import MpdDispatcher from mopidy.mixers.dummy import DummyMixer class ConnectionHandlerTest(unittest.TestCase): def setUp(self): - self.b = DummyBackend.start().proxy() + self.backend = DummyBackend.start().proxy() self.mixer = DummyMixer.start().proxy() - self.h = dispatcher.MpdDispatcher() + self.dispatcher = MpdDispatcher() def tearDown(self): - self.b.stop().get() + self.backend.stop().get() self.mixer.stop().get() settings.runtime.clear() def test_close(self): - result = self.h.handle_request(u'close') + result = self.dispatcher.handle_request(u'close') self.assert_(u'OK' in result) def test_empty_request(self): - result = self.h.handle_request(u'') + result = self.dispatcher.handle_request(u'') self.assert_(u'OK' in result) def test_kill(self): - result = self.h.handle_request(u'kill') + result = self.dispatcher.handle_request(u'kill') self.assert_(u'OK' in result) def test_valid_password_is_accepted(self): settings.MPD_SERVER_PASSWORD = u'topsecret' - result = self.h.handle_request(u'password "topsecret"') + result = self.dispatcher.handle_request(u'password "topsecret"') self.assert_(u'OK' in result) def test_invalid_password_is_not_accepted(self): settings.MPD_SERVER_PASSWORD = u'topsecret' - result = self.h.handle_request(u'password "secret"') + result = self.dispatcher.handle_request(u'password "secret"') self.assert_(u'ACK [3@0] {password} incorrect password' in result) def test_any_password_is_not_accepted_when_password_check_turned_off(self): settings.MPD_SERVER_PASSWORD = None - result = self.h.handle_request(u'password "secret"') + result = self.dispatcher.handle_request(u'password "secret"') self.assert_(u'ACK [3@0] {password} incorrect password' in result) def test_ping(self): - result = self.h.handle_request(u'ping') + result = self.dispatcher.handle_request(u'ping') self.assert_(u'OK' in result) diff --git a/tests/frontends/mpd/current_playlist_test.py b/tests/frontends/mpd/current_playlist_test.py index eb113ed7..c7f47429 100644 --- a/tests/frontends/mpd/current_playlist_test.py +++ b/tests/frontends/mpd/current_playlist_test.py @@ -1,160 +1,160 @@ import unittest from mopidy.backends.dummy import DummyBackend -from mopidy.frontends.mpd import dispatcher +from mopidy.frontends.mpd.dispatcher import MpdDispatcher from mopidy.mixers.dummy import DummyMixer from mopidy.models import Track class CurrentPlaylistHandlerTest(unittest.TestCase): def setUp(self): - self.b = DummyBackend.start().proxy() + self.backend = DummyBackend.start().proxy() self.mixer = DummyMixer.start().proxy() - self.h = dispatcher.MpdDispatcher() + self.dispatcher = MpdDispatcher() def tearDown(self): - self.b.stop().get() + self.backend.stop().get() self.mixer.stop().get() def test_add(self): needle = Track(uri='dummy://foo') - self.b.library.provider.dummy_library = [ + self.backend.library.provider.dummy_library = [ Track(), Track(), needle, Track()] - self.b.current_playlist.append( + self.backend.current_playlist.append( [Track(), Track(), Track(), Track(), Track()]) - self.assertEqual(len(self.b.current_playlist.tracks.get()), 5) - result = self.h.handle_request(u'add "dummy://foo"') + self.assertEqual(len(self.backend.current_playlist.tracks.get()), 5) + result = self.dispatcher.handle_request(u'add "dummy://foo"') self.assertEqual(len(result), 1) self.assertEqual(result[0], u'OK') - self.assertEqual(len(self.b.current_playlist.tracks.get()), 6) - self.assertEqual(self.b.current_playlist.tracks.get()[5], needle) + self.assertEqual(len(self.backend.current_playlist.tracks.get()), 6) + self.assertEqual(self.backend.current_playlist.tracks.get()[5], needle) def test_add_with_uri_not_found_in_library_should_ack(self): - result = self.h.handle_request(u'add "dummy://foo"') + result = self.dispatcher.handle_request(u'add "dummy://foo"') self.assertEqual(result[0], u'ACK [50@0] {add} directory or file not found') def test_add_with_empty_uri_should_add_all_known_tracks_and_ok(self): - result = self.h.handle_request(u'add ""') + result = self.dispatcher.handle_request(u'add ""') # TODO check that we add all tracks (we currently don't) self.assert_(u'OK' in result) def test_addid_without_songpos(self): needle = Track(uri='dummy://foo') - self.b.library.provider.dummy_library = [ + self.backend.library.provider.dummy_library = [ Track(), Track(), needle, Track()] - self.b.current_playlist.append( + self.backend.current_playlist.append( [Track(), Track(), Track(), Track(), Track()]) - self.assertEqual(len(self.b.current_playlist.tracks.get()), 5) - result = self.h.handle_request(u'addid "dummy://foo"') - self.assertEqual(len(self.b.current_playlist.tracks.get()), 6) - self.assertEqual(self.b.current_playlist.tracks.get()[5], needle) - self.assert_(u'Id: %d' % self.b.current_playlist.cp_tracks.get()[5][0] - in result) + self.assertEqual(len(self.backend.current_playlist.tracks.get()), 5) + result = self.dispatcher.handle_request(u'addid "dummy://foo"') + self.assertEqual(len(self.backend.current_playlist.tracks.get()), 6) + self.assertEqual(self.backend.current_playlist.tracks.get()[5], needle) + self.assert_(u'Id: %d' % + self.backend.current_playlist.cp_tracks.get()[5][0] in result) self.assert_(u'OK' in result) def test_addid_with_empty_uri_acks(self): - result = self.h.handle_request(u'addid ""') + result = self.dispatcher.handle_request(u'addid ""') self.assertEqual(result[0], u'ACK [50@0] {addid} No such song') def test_addid_with_songpos(self): needle = Track(uri='dummy://foo') - self.b.library.provider.dummy_library = [ + self.backend.library.provider.dummy_library = [ Track(), Track(), needle, Track()] - self.b.current_playlist.append( + self.backend.current_playlist.append( [Track(), Track(), Track(), Track(), Track()]) - self.assertEqual(len(self.b.current_playlist.tracks.get()), 5) - result = self.h.handle_request(u'addid "dummy://foo" "3"') - self.assertEqual(len(self.b.current_playlist.tracks.get()), 6) - self.assertEqual(self.b.current_playlist.tracks.get()[3], needle) - self.assert_(u'Id: %d' % self.b.current_playlist.cp_tracks.get()[3][0] - in result) + self.assertEqual(len(self.backend.current_playlist.tracks.get()), 5) + result = self.dispatcher.handle_request(u'addid "dummy://foo" "3"') + self.assertEqual(len(self.backend.current_playlist.tracks.get()), 6) + self.assertEqual(self.backend.current_playlist.tracks.get()[3], needle) + self.assert_(u'Id: %d' % + self.backend.current_playlist.cp_tracks.get()[3][0] in result) self.assert_(u'OK' in result) def test_addid_with_songpos_out_of_bounds_should_ack(self): needle = Track(uri='dummy://foo') - self.b.library.provider.dummy_library = [ + self.backend.library.provider.dummy_library = [ Track(), Track(), needle, Track()] - self.b.current_playlist.append( + self.backend.current_playlist.append( [Track(), Track(), Track(), Track(), Track()]) - self.assertEqual(len(self.b.current_playlist.tracks.get()), 5) - result = self.h.handle_request(u'addid "dummy://foo" "6"') + self.assertEqual(len(self.backend.current_playlist.tracks.get()), 5) + result = self.dispatcher.handle_request(u'addid "dummy://foo" "6"') self.assertEqual(result[0], u'ACK [2@0] {addid} Bad song index') def test_addid_with_uri_not_found_in_library_should_ack(self): - result = self.h.handle_request(u'addid "dummy://foo"') + result = self.dispatcher.handle_request(u'addid "dummy://foo"') self.assertEqual(result[0], u'ACK [50@0] {addid} No such song') def test_clear(self): - self.b.current_playlist.append( + self.backend.current_playlist.append( [Track(), Track(), Track(), Track(), Track()]) - self.assertEqual(len(self.b.current_playlist.tracks.get()), 5) - result = self.h.handle_request(u'clear') - self.assertEqual(len(self.b.current_playlist.tracks.get()), 0) - self.assertEqual(self.b.playback.current_track.get(), None) + self.assertEqual(len(self.backend.current_playlist.tracks.get()), 5) + result = self.dispatcher.handle_request(u'clear') + self.assertEqual(len(self.backend.current_playlist.tracks.get()), 0) + self.assertEqual(self.backend.playback.current_track.get(), None) self.assert_(u'OK' in result) def test_delete_songpos(self): - self.b.current_playlist.append( + self.backend.current_playlist.append( [Track(), Track(), Track(), Track(), Track()]) - self.assertEqual(len(self.b.current_playlist.tracks.get()), 5) - result = self.h.handle_request(u'delete "%d"' % - self.b.current_playlist.cp_tracks.get()[2][0]) - self.assertEqual(len(self.b.current_playlist.tracks.get()), 4) + self.assertEqual(len(self.backend.current_playlist.tracks.get()), 5) + result = self.dispatcher.handle_request(u'delete "%d"' % + self.backend.current_playlist.cp_tracks.get()[2][0]) + self.assertEqual(len(self.backend.current_playlist.tracks.get()), 4) self.assert_(u'OK' in result) def test_delete_songpos_out_of_bounds(self): - self.b.current_playlist.append( + self.backend.current_playlist.append( [Track(), Track(), Track(), Track(), Track()]) - self.assertEqual(len(self.b.current_playlist.tracks.get()), 5) - result = self.h.handle_request(u'delete "5"') - self.assertEqual(len(self.b.current_playlist.tracks.get()), 5) + self.assertEqual(len(self.backend.current_playlist.tracks.get()), 5) + result = self.dispatcher.handle_request(u'delete "5"') + self.assertEqual(len(self.backend.current_playlist.tracks.get()), 5) self.assertEqual(result[0], u'ACK [2@0] {delete} Bad song index') def test_delete_open_range(self): - self.b.current_playlist.append( + self.backend.current_playlist.append( [Track(), Track(), Track(), Track(), Track()]) - self.assertEqual(len(self.b.current_playlist.tracks.get()), 5) - result = self.h.handle_request(u'delete "1:"') - self.assertEqual(len(self.b.current_playlist.tracks.get()), 1) + self.assertEqual(len(self.backend.current_playlist.tracks.get()), 5) + result = self.dispatcher.handle_request(u'delete "1:"') + self.assertEqual(len(self.backend.current_playlist.tracks.get()), 1) self.assert_(u'OK' in result) def test_delete_closed_range(self): - self.b.current_playlist.append( + self.backend.current_playlist.append( [Track(), Track(), Track(), Track(), Track()]) - self.assertEqual(len(self.b.current_playlist.tracks.get()), 5) - result = self.h.handle_request(u'delete "1:3"') - self.assertEqual(len(self.b.current_playlist.tracks.get()), 3) + self.assertEqual(len(self.backend.current_playlist.tracks.get()), 5) + result = self.dispatcher.handle_request(u'delete "1:3"') + self.assertEqual(len(self.backend.current_playlist.tracks.get()), 3) self.assert_(u'OK' in result) def test_delete_range_out_of_bounds(self): - self.b.current_playlist.append( + self.backend.current_playlist.append( [Track(), Track(), Track(), Track(), Track()]) - self.assertEqual(len(self.b.current_playlist.tracks.get()), 5) - result = self.h.handle_request(u'delete "5:7"') - self.assertEqual(len(self.b.current_playlist.tracks.get()), 5) + self.assertEqual(len(self.backend.current_playlist.tracks.get()), 5) + result = self.dispatcher.handle_request(u'delete "5:7"') + self.assertEqual(len(self.backend.current_playlist.tracks.get()), 5) self.assertEqual(result[0], u'ACK [2@0] {delete} Bad song index') def test_deleteid(self): - self.b.current_playlist.append([Track(), Track()]) - self.assertEqual(len(self.b.current_playlist.tracks.get()), 2) - result = self.h.handle_request(u'deleteid "1"') - self.assertEqual(len(self.b.current_playlist.tracks.get()), 1) + self.backend.current_playlist.append([Track(), Track()]) + self.assertEqual(len(self.backend.current_playlist.tracks.get()), 2) + result = self.dispatcher.handle_request(u'deleteid "1"') + self.assertEqual(len(self.backend.current_playlist.tracks.get()), 1) self.assert_(u'OK' in result) def test_deleteid_does_not_exist(self): - self.b.current_playlist.append([Track(), Track()]) - self.assertEqual(len(self.b.current_playlist.tracks.get()), 2) - result = self.h.handle_request(u'deleteid "12345"') - self.assertEqual(len(self.b.current_playlist.tracks.get()), 2) + self.backend.current_playlist.append([Track(), Track()]) + self.assertEqual(len(self.backend.current_playlist.tracks.get()), 2) + result = self.dispatcher.handle_request(u'deleteid "12345"') + self.assertEqual(len(self.backend.current_playlist.tracks.get()), 2) self.assertEqual(result[0], u'ACK [50@0] {deleteid} No such song') def test_move_songpos(self): - self.b.current_playlist.append([ + self.backend.current_playlist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) - result = self.h.handle_request(u'move "1" "0"') - tracks = self.b.current_playlist.tracks.get() + result = self.dispatcher.handle_request(u'move "1" "0"') + tracks = self.backend.current_playlist.tracks.get() self.assertEqual(tracks[0].name, 'b') self.assertEqual(tracks[1].name, 'a') self.assertEqual(tracks[2].name, 'c') @@ -164,12 +164,12 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): self.assert_(u'OK' in result) def test_move_open_range(self): - self.b.current_playlist.append([ + self.backend.current_playlist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) - result = self.h.handle_request(u'move "2:" "0"') - tracks = self.b.current_playlist.tracks.get() + result = self.dispatcher.handle_request(u'move "2:" "0"') + tracks = self.backend.current_playlist.tracks.get() self.assertEqual(tracks[0].name, 'c') self.assertEqual(tracks[1].name, 'd') self.assertEqual(tracks[2].name, 'e') @@ -179,12 +179,12 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): self.assert_(u'OK' in result) def test_move_closed_range(self): - self.b.current_playlist.append([ + self.backend.current_playlist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) - result = self.h.handle_request(u'move "1:3" "0"') - tracks = self.b.current_playlist.tracks.get() + result = self.dispatcher.handle_request(u'move "1:3" "0"') + tracks = self.backend.current_playlist.tracks.get() self.assertEqual(tracks[0].name, 'b') self.assertEqual(tracks[1].name, 'c') self.assertEqual(tracks[2].name, 'a') @@ -194,12 +194,12 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): self.assert_(u'OK' in result) def test_moveid(self): - self.b.current_playlist.append([ + self.backend.current_playlist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) - result = self.h.handle_request(u'moveid "4" "2"') - tracks = self.b.current_playlist.tracks.get() + result = self.dispatcher.handle_request(u'moveid "4" "2"') + tracks = self.backend.current_playlist.tracks.get() self.assertEqual(tracks[0].name, 'a') self.assertEqual(tracks[1].name, 'b') self.assertEqual(tracks[2].name, 'e') @@ -209,30 +209,30 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): self.assert_(u'OK' in result) def test_playlist_returns_same_as_playlistinfo(self): - playlist_result = self.h.handle_request(u'playlist') - playlistinfo_result = self.h.handle_request(u'playlistinfo') + playlist_result = self.dispatcher.handle_request(u'playlist') + playlistinfo_result = self.dispatcher.handle_request(u'playlistinfo') self.assertEqual(playlist_result, playlistinfo_result) def test_playlistfind(self): - result = self.h.handle_request(u'playlistfind "tag" "needle"') + result = self.dispatcher.handle_request(u'playlistfind "tag" "needle"') self.assert_(u'ACK [0@0] {} Not implemented' in result) def test_playlistfind_by_filename_not_in_current_playlist(self): - result = self.h.handle_request( + result = self.dispatcher.handle_request( u'playlistfind "filename" "file:///dev/null"') self.assertEqual(len(result), 1) self.assert_(u'OK' in result) def test_playlistfind_by_filename_without_quotes(self): - result = self.h.handle_request( + result = self.dispatcher.handle_request( u'playlistfind filename "file:///dev/null"') self.assertEqual(len(result), 1) self.assert_(u'OK' in result) def test_playlistfind_by_filename_in_current_playlist(self): - self.b.current_playlist.append([ + self.backend.current_playlist.append([ Track(uri='file:///exists')]) - result = self.h.handle_request( + result = self.dispatcher.handle_request( u'playlistfind filename "file:///exists"') self.assert_(u'file: file:///exists' in result) self.assert_(u'Id: 0' in result) @@ -240,15 +240,15 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): self.assert_(u'OK' in result) def test_playlistid_without_songid(self): - self.b.current_playlist.append([Track(name='a'), Track(name='b')]) - result = self.h.handle_request(u'playlistid') + self.backend.current_playlist.append([Track(name='a'), Track(name='b')]) + result = self.dispatcher.handle_request(u'playlistid') self.assert_(u'Title: a' in result) self.assert_(u'Title: b' in result) self.assert_(u'OK' in result) def test_playlistid_with_songid(self): - self.b.current_playlist.append([Track(name='a'), Track(name='b')]) - result = self.h.handle_request(u'playlistid "1"') + self.backend.current_playlist.append([Track(name='a'), Track(name='b')]) + result = self.dispatcher.handle_request(u'playlistid "1"') self.assert_(u'Title: a' not in result) self.assert_(u'Id: 0' not in result) self.assert_(u'Title: b' in result) @@ -256,16 +256,16 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): self.assert_(u'OK' in result) def test_playlistid_with_not_existing_songid_fails(self): - self.b.current_playlist.append([Track(name='a'), Track(name='b')]) - result = self.h.handle_request(u'playlistid "25"') + self.backend.current_playlist.append([Track(name='a'), Track(name='b')]) + result = self.dispatcher.handle_request(u'playlistid "25"') self.assertEqual(result[0], u'ACK [50@0] {playlistid} No such song') def test_playlistinfo_without_songpos_or_range(self): - self.b.current_playlist.append([ + self.backend.current_playlist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) - result = self.h.handle_request(u'playlistinfo') + result = self.dispatcher.handle_request(u'playlistinfo') self.assert_(u'Title: a' in result) self.assert_(u'Title: b' in result) self.assert_(u'Title: c' in result) @@ -275,11 +275,11 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): self.assert_(u'OK' in result) def test_playlistinfo_with_songpos(self): - self.b.current_playlist.append([ + self.backend.current_playlist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) - result = self.h.handle_request(u'playlistinfo "4"') + result = self.dispatcher.handle_request(u'playlistinfo "4"') self.assert_(u'Title: a' not in result) self.assert_(u'Title: b' not in result) self.assert_(u'Title: c' not in result) @@ -289,16 +289,16 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): self.assert_(u'OK' in result) def test_playlistinfo_with_negative_songpos_same_as_playlistinfo(self): - result1 = self.h.handle_request(u'playlistinfo "-1"') - result2 = self.h.handle_request(u'playlistinfo') + result1 = self.dispatcher.handle_request(u'playlistinfo "-1"') + result2 = self.dispatcher.handle_request(u'playlistinfo') self.assertEqual(result1, result2) def test_playlistinfo_with_open_range(self): - self.b.current_playlist.append([ + self.backend.current_playlist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) - result = self.h.handle_request(u'playlistinfo "2:"') + result = self.dispatcher.handle_request(u'playlistinfo "2:"') self.assert_(u'Title: a' not in result) self.assert_(u'Title: b' not in result) self.assert_(u'Title: c' in result) @@ -308,11 +308,11 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): self.assert_(u'OK' in result) def test_playlistinfo_with_closed_range(self): - self.b.current_playlist.append([ + self.backend.current_playlist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) - result = self.h.handle_request(u'playlistinfo "2:4"') + result = self.dispatcher.handle_request(u'playlistinfo "2:4"') self.assert_(u'Title: a' not in result) self.assert_(u'Title: b' not in result) self.assert_(u'Title: c' in result) @@ -322,52 +322,53 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): self.assert_(u'OK' in result) def test_playlistinfo_with_too_high_start_of_range_returns_arg_error(self): - result = self.h.handle_request(u'playlistinfo "10:20"') + result = self.dispatcher.handle_request(u'playlistinfo "10:20"') self.assert_(u'ACK [2@0] {playlistinfo} Bad song index' in result) def test_playlistinfo_with_too_high_end_of_range_returns_ok(self): - result = self.h.handle_request(u'playlistinfo "0:20"') + result = self.dispatcher.handle_request(u'playlistinfo "0:20"') self.assert_(u'OK' in result) def test_playlistsearch(self): - result = self.h.handle_request(u'playlistsearch "any" "needle"') + result = self.dispatcher.handle_request( + u'playlistsearch "any" "needle"') self.assert_(u'ACK [0@0] {} Not implemented' in result) def test_playlistsearch_without_quotes(self): - result = self.h.handle_request(u'playlistsearch any "needle"') + result = self.dispatcher.handle_request(u'playlistsearch any "needle"') self.assert_(u'ACK [0@0] {} Not implemented' in result) def test_plchanges(self): - self.b.current_playlist.append( + self.backend.current_playlist.append( [Track(name='a'), Track(name='b'), Track(name='c')]) - result = self.h.handle_request(u'plchanges "0"') + result = self.dispatcher.handle_request(u'plchanges "0"') self.assert_(u'Title: a' in result) self.assert_(u'Title: b' in result) self.assert_(u'Title: c' in result) self.assert_(u'OK' in result) def test_plchanges_with_minus_one_returns_entire_playlist(self): - self.b.current_playlist.append( + self.backend.current_playlist.append( [Track(name='a'), Track(name='b'), Track(name='c')]) - result = self.h.handle_request(u'plchanges "-1"') + result = self.dispatcher.handle_request(u'plchanges "-1"') self.assert_(u'Title: a' in result) self.assert_(u'Title: b' in result) self.assert_(u'Title: c' in result) self.assert_(u'OK' in result) def test_plchanges_without_quotes_works(self): - self.b.current_playlist.append( + self.backend.current_playlist.append( [Track(name='a'), Track(name='b'), Track(name='c')]) - result = self.h.handle_request(u'plchanges 0') + result = self.dispatcher.handle_request(u'plchanges 0') self.assert_(u'Title: a' in result) self.assert_(u'Title: b' in result) self.assert_(u'Title: c' in result) self.assert_(u'OK' in result) def test_plchangesposid(self): - self.b.current_playlist.append([Track(), Track(), Track()]) - result = self.h.handle_request(u'plchangesposid "0"') - cp_tracks = self.b.current_playlist.cp_tracks.get() + self.backend.current_playlist.append([Track(), Track(), Track()]) + result = self.dispatcher.handle_request(u'plchangesposid "0"') + cp_tracks = self.backend.current_playlist.cp_tracks.get() self.assert_(u'cpos: 0' in result) self.assert_(u'Id: %d' % cp_tracks[0][0] in result) @@ -380,24 +381,24 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): self.assert_(u'OK' in result) def test_shuffle_without_range(self): - self.b.current_playlist.append([ + self.backend.current_playlist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) - version = self.b.current_playlist.version.get() - result = self.h.handle_request(u'shuffle') - self.assert_(version < self.b.current_playlist.version.get()) + version = self.backend.current_playlist.version.get() + result = self.dispatcher.handle_request(u'shuffle') + self.assert_(version < self.backend.current_playlist.version.get()) self.assert_(u'OK' in result) def test_shuffle_with_open_range(self): - self.b.current_playlist.append([ + self.backend.current_playlist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) - version = self.b.current_playlist.version.get() - result = self.h.handle_request(u'shuffle "4:"') - self.assert_(version < self.b.current_playlist.version.get()) - tracks = self.b.current_playlist.tracks.get() + version = self.backend.current_playlist.version.get() + result = self.dispatcher.handle_request(u'shuffle "4:"') + self.assert_(version < self.backend.current_playlist.version.get()) + tracks = self.backend.current_playlist.tracks.get() self.assertEqual(tracks[0].name, 'a') self.assertEqual(tracks[1].name, 'b') self.assertEqual(tracks[2].name, 'c') @@ -405,14 +406,14 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): self.assert_(u'OK' in result) def test_shuffle_with_closed_range(self): - self.b.current_playlist.append([ + self.backend.current_playlist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) - version = self.b.current_playlist.version.get() - result = self.h.handle_request(u'shuffle "1:3"') - self.assert_(version < self.b.current_playlist.version.get()) - tracks = self.b.current_playlist.tracks.get() + version = self.backend.current_playlist.version.get() + result = self.dispatcher.handle_request(u'shuffle "1:3"') + self.assert_(version < self.backend.current_playlist.version.get()) + tracks = self.backend.current_playlist.tracks.get() self.assertEqual(tracks[0].name, 'a') self.assertEqual(tracks[3].name, 'd') self.assertEqual(tracks[4].name, 'e') @@ -420,12 +421,12 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): self.assert_(u'OK' in result) def test_swap(self): - self.b.current_playlist.append([ + self.backend.current_playlist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) - result = self.h.handle_request(u'swap "1" "4"') - tracks = self.b.current_playlist.tracks.get() + result = self.dispatcher.handle_request(u'swap "1" "4"') + tracks = self.backend.current_playlist.tracks.get() self.assertEqual(tracks[0].name, 'a') self.assertEqual(tracks[1].name, 'e') self.assertEqual(tracks[2].name, 'c') @@ -435,12 +436,12 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): self.assert_(u'OK' in result) def test_swapid(self): - self.b.current_playlist.append([ + self.backend.current_playlist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) - result = self.h.handle_request(u'swapid "1" "4"') - tracks = self.b.current_playlist.tracks.get() + result = self.dispatcher.handle_request(u'swapid "1" "4"') + tracks = self.backend.current_playlist.tracks.get() self.assertEqual(tracks[0].name, 'a') self.assertEqual(tracks[1].name, 'e') self.assertEqual(tracks[2].name, 'c') diff --git a/tests/frontends/mpd/dispatcher_test.py b/tests/frontends/mpd/dispatcher_test.py index 77e0ddf0..1d8efe64 100644 --- a/tests/frontends/mpd/dispatcher_test.py +++ b/tests/frontends/mpd/dispatcher_test.py @@ -1,19 +1,19 @@ import unittest from mopidy.backends.dummy import DummyBackend -from mopidy.frontends.mpd import dispatcher +from mopidy.frontends.mpd.dispatcher import MpdDispatcher from mopidy.frontends.mpd.exceptions import MpdAckError from mopidy.frontends.mpd.protocol import request_handlers, handle_pattern from mopidy.mixers.dummy import DummyMixer class MpdDispatcherTest(unittest.TestCase): def setUp(self): - self.b = DummyBackend.start().proxy() + self.backend = DummyBackend.start().proxy() self.mixer = DummyMixer.start().proxy() - self.h = dispatcher.MpdDispatcher() + self.dispatcher = MpdDispatcher() def tearDown(self): - self.b.stop().get() + self.backend.stop().get() self.mixer.stop().get() def test_register_same_pattern_twice_fails(self): @@ -27,7 +27,7 @@ class MpdDispatcherTest(unittest.TestCase): def test_finding_handler_for_unknown_command_raises_exception(self): try: - self.h.find_handler('an_unknown_command with args') + self.dispatcher.find_handler('an_unknown_command with args') self.fail('Should raise exception') except MpdAckError as e: self.assertEqual(e.get_mpd_ack(), @@ -37,18 +37,18 @@ class MpdDispatcherTest(unittest.TestCase): expected_handler = lambda x: None request_handlers['known_command (?P.+)'] = \ expected_handler - (handler, kwargs) = self.h.find_handler('known_command an_arg') + (handler, kwargs) = self.dispatcher.find_handler('known_command an_arg') self.assertEqual(handler, expected_handler) self.assert_('arg1' in kwargs) self.assertEqual(kwargs['arg1'], 'an_arg') def test_handling_unknown_request_yields_error(self): - result = self.h.handle_request('an unhandled request') + result = self.dispatcher.handle_request('an unhandled request') self.assertEqual(result[0], u'ACK [5@0] {} unknown command "an"') def test_handling_known_request(self): expected = 'magic' request_handlers['known request'] = lambda x: expected - result = self.h.handle_request('known request') + result = self.dispatcher.handle_request('known request') self.assert_(u'OK' in result) self.assert_(expected in result) diff --git a/tests/frontends/mpd/music_db_test.py b/tests/frontends/mpd/music_db_test.py index fa5634be..3793db9e 100644 --- a/tests/frontends/mpd/music_db_test.py +++ b/tests/frontends/mpd/music_db_test.py @@ -1,390 +1,412 @@ import unittest from mopidy.backends.dummy import DummyBackend -from mopidy.frontends.mpd import dispatcher +from mopidy.frontends.mpd.dispatcher import MpdDispatcher from mopidy.mixers.dummy import DummyMixer class MusicDatabaseHandlerTest(unittest.TestCase): def setUp(self): - self.b = DummyBackend.start().proxy() + self.backend = DummyBackend.start().proxy() self.mixer = DummyMixer.start().proxy() - self.h = dispatcher.MpdDispatcher() + self.dispatcher = MpdDispatcher() def tearDown(self): - self.b.stop().get() + self.backend.stop().get() self.mixer.stop().get() def test_count(self): - result = self.h.handle_request(u'count "tag" "needle"') + result = self.dispatcher.handle_request(u'count "tag" "needle"') self.assert_(u'songs: 0' in result) self.assert_(u'playtime: 0' in result) self.assert_(u'OK' in result) def test_findadd(self): - result = self.h.handle_request(u'findadd "album" "what"') + result = self.dispatcher.handle_request(u'findadd "album" "what"') self.assert_(u'OK' in result) def test_listall(self): - result = self.h.handle_request(u'listall "file:///dev/urandom"') + result = self.dispatcher.handle_request( + u'listall "file:///dev/urandom"') self.assert_(u'ACK [0@0] {} Not implemented' in result) def test_listallinfo(self): - result = self.h.handle_request(u'listallinfo "file:///dev/urandom"') + result = self.dispatcher.handle_request( + u'listallinfo "file:///dev/urandom"') self.assert_(u'ACK [0@0] {} Not implemented' in result) def test_lsinfo_without_path_returns_same_as_listplaylists(self): - lsinfo_result = self.h.handle_request(u'lsinfo') - listplaylists_result = self.h.handle_request(u'listplaylists') + lsinfo_result = self.dispatcher.handle_request(u'lsinfo') + listplaylists_result = self.dispatcher.handle_request(u'listplaylists') self.assertEqual(lsinfo_result, listplaylists_result) def test_lsinfo_with_empty_path_returns_same_as_listplaylists(self): - lsinfo_result = self.h.handle_request(u'lsinfo ""') - listplaylists_result = self.h.handle_request(u'listplaylists') + lsinfo_result = self.dispatcher.handle_request(u'lsinfo ""') + listplaylists_result = self.dispatcher.handle_request(u'listplaylists') self.assertEqual(lsinfo_result, listplaylists_result) def test_lsinfo_for_root_returns_same_as_listplaylists(self): - lsinfo_result = self.h.handle_request(u'lsinfo "/"') - listplaylists_result = self.h.handle_request(u'listplaylists') + lsinfo_result = self.dispatcher.handle_request(u'lsinfo "/"') + listplaylists_result = self.dispatcher.handle_request(u'listplaylists') self.assertEqual(lsinfo_result, listplaylists_result) def test_update_without_uri(self): - result = self.h.handle_request(u'update') + result = self.dispatcher.handle_request(u'update') self.assert_(u'OK' in result) self.assert_(u'updating_db: 0' in result) def test_update_with_uri(self): - result = self.h.handle_request(u'update "file:///dev/urandom"') + result = self.dispatcher.handle_request(u'update "file:///dev/urandom"') self.assert_(u'OK' in result) self.assert_(u'updating_db: 0' in result) def test_rescan_without_uri(self): - result = self.h.handle_request(u'rescan') + result = self.dispatcher.handle_request(u'rescan') self.assert_(u'OK' in result) self.assert_(u'updating_db: 0' in result) def test_rescan_with_uri(self): - result = self.h.handle_request(u'rescan "file:///dev/urandom"') + result = self.dispatcher.handle_request(u'rescan "file:///dev/urandom"') self.assert_(u'OK' in result) self.assert_(u'updating_db: 0' in result) class MusicDatabaseFindTest(unittest.TestCase): def setUp(self): - self.b = DummyBackend.start().proxy() + self.backend = DummyBackend.start().proxy() self.mixer = DummyMixer.start().proxy() - self.h = dispatcher.MpdDispatcher() + self.dispatcher = MpdDispatcher() def tearDown(self): - self.b.stop().get() + self.backend.stop().get() self.mixer.stop().get() def test_find_album(self): - result = self.h.handle_request(u'find "album" "what"') + result = self.dispatcher.handle_request(u'find "album" "what"') self.assert_(u'OK' in result) def test_find_album_without_quotes(self): - result = self.h.handle_request(u'find album "what"') + result = self.dispatcher.handle_request(u'find album "what"') self.assert_(u'OK' in result) def test_find_artist(self): - result = self.h.handle_request(u'find "artist" "what"') + result = self.dispatcher.handle_request(u'find "artist" "what"') self.assert_(u'OK' in result) def test_find_artist_without_quotes(self): - result = self.h.handle_request(u'find artist "what"') + result = self.dispatcher.handle_request(u'find artist "what"') self.assert_(u'OK' in result) def test_find_title(self): - result = self.h.handle_request(u'find "title" "what"') + result = self.dispatcher.handle_request(u'find "title" "what"') self.assert_(u'OK' in result) def test_find_title_without_quotes(self): - result = self.h.handle_request(u'find title "what"') + result = self.dispatcher.handle_request(u'find title "what"') self.assert_(u'OK' in result) def test_find_date(self): - result = self.h.handle_request(u'find "date" "2002-01-01"') + result = self.dispatcher.handle_request(u'find "date" "2002-01-01"') self.assert_(u'OK' in result) def test_find_date_without_quotes(self): - result = self.h.handle_request(u'find date "2002-01-01"') + result = self.dispatcher.handle_request(u'find date "2002-01-01"') self.assert_(u'OK' in result) def test_find_date_with_capital_d_and_incomplete_date(self): - result = self.h.handle_request(u'find Date "2005"') + result = self.dispatcher.handle_request(u'find Date "2005"') self.assert_(u'OK' in result) def test_find_else_should_fail(self): - result = self.h.handle_request(u'find "somethingelse" "what"') + result = self.dispatcher.handle_request(u'find "somethingelse" "what"') self.assertEqual(result[0], u'ACK [2@0] {find} incorrect arguments') def test_find_album_and_artist(self): - result = self.h.handle_request( + result = self.dispatcher.handle_request( u'find album "album_what" artist "artist_what"') self.assert_(u'OK' in result) class MusicDatabaseListTest(unittest.TestCase): def setUp(self): - self.b = DummyBackend.start().proxy() + self.backend = DummyBackend.start().proxy() self.mixer = DummyMixer.start().proxy() - self.h = dispatcher.MpdDispatcher() + self.dispatcher = MpdDispatcher() def tearDown(self): - self.b.stop().get() + self.backend.stop().get() self.mixer.stop().get() def test_list_foo_returns_ack(self): - result = self.h.handle_request(u'list "foo"') + result = self.dispatcher.handle_request(u'list "foo"') self.assertEqual(result[0], u'ACK [2@0] {list} incorrect arguments') ### Artist def test_list_artist_with_quotes(self): - result = self.h.handle_request(u'list "artist"') + result = self.dispatcher.handle_request(u'list "artist"') self.assert_(u'OK' in result) def test_list_artist_without_quotes(self): - result = self.h.handle_request(u'list artist') + result = self.dispatcher.handle_request(u'list artist') self.assert_(u'OK' in result) def test_list_artist_without_quotes_and_capitalized(self): - result = self.h.handle_request(u'list Artist') + result = self.dispatcher.handle_request(u'list Artist') self.assert_(u'OK' in result) def test_list_artist_with_query_of_one_token(self): - result = self.h.handle_request(u'list "artist" "anartist"') + result = self.dispatcher.handle_request(u'list "artist" "anartist"') self.assertEqual(result[0], u'ACK [2@0] {list} should be "Album" for 3 arguments') def test_list_artist_with_unknown_field_in_query_returns_ack(self): - result = self.h.handle_request(u'list "artist" "foo" "bar"') + result = self.dispatcher.handle_request(u'list "artist" "foo" "bar"') self.assertEqual(result[0], u'ACK [2@0] {list} not able to parse args') def test_list_artist_by_artist(self): - result = self.h.handle_request(u'list "artist" "artist" "anartist"') + result = self.dispatcher.handle_request( + u'list "artist" "artist" "anartist"') self.assert_(u'OK' in result) def test_list_artist_by_album(self): - result = self.h.handle_request(u'list "artist" "album" "analbum"') + result = self.dispatcher.handle_request( + u'list "artist" "album" "analbum"') self.assert_(u'OK' in result) def test_list_artist_by_full_date(self): - result = self.h.handle_request(u'list "artist" "date" "2001-01-01"') + result = self.dispatcher.handle_request( + u'list "artist" "date" "2001-01-01"') self.assert_(u'OK' in result) def test_list_artist_by_year(self): - result = self.h.handle_request(u'list "artist" "date" "2001"') + result = self.dispatcher.handle_request( + u'list "artist" "date" "2001"') self.assert_(u'OK' in result) def test_list_artist_by_genre(self): - result = self.h.handle_request(u'list "artist" "genre" "agenre"') + result = self.dispatcher.handle_request( + u'list "artist" "genre" "agenre"') self.assert_(u'OK' in result) def test_list_artist_by_artist_and_album(self): - result = self.h.handle_request( + result = self.dispatcher.handle_request( u'list "artist" "artist" "anartist" "album" "analbum"') self.assert_(u'OK' in result) ### Album def test_list_album_with_quotes(self): - result = self.h.handle_request(u'list "album"') + result = self.dispatcher.handle_request(u'list "album"') self.assert_(u'OK' in result) def test_list_album_without_quotes(self): - result = self.h.handle_request(u'list album') + result = self.dispatcher.handle_request(u'list album') self.assert_(u'OK' in result) def test_list_album_without_quotes_and_capitalized(self): - result = self.h.handle_request(u'list Album') + result = self.dispatcher.handle_request(u'list Album') self.assert_(u'OK' in result) def test_list_album_with_artist_name(self): - result = self.h.handle_request(u'list "album" "anartist"') + result = self.dispatcher.handle_request(u'list "album" "anartist"') self.assert_(u'OK' in result) def test_list_album_by_artist(self): - result = self.h.handle_request(u'list "album" "artist" "anartist"') + result = self.dispatcher.handle_request( + u'list "album" "artist" "anartist"') self.assert_(u'OK' in result) def test_list_album_by_album(self): - result = self.h.handle_request(u'list "album" "album" "analbum"') + result = self.dispatcher.handle_request( + u'list "album" "album" "analbum"') self.assert_(u'OK' in result) def test_list_album_by_full_date(self): - result = self.h.handle_request(u'list "album" "date" "2001-01-01"') + result = self.dispatcher.handle_request( + u'list "album" "date" "2001-01-01"') self.assert_(u'OK' in result) def test_list_album_by_year(self): - result = self.h.handle_request(u'list "album" "date" "2001"') + result = self.dispatcher.handle_request( + u'list "album" "date" "2001"') self.assert_(u'OK' in result) def test_list_album_by_genre(self): - result = self.h.handle_request(u'list "album" "genre" "agenre"') + result = self.dispatcher.handle_request( + u'list "album" "genre" "agenre"') self.assert_(u'OK' in result) def test_list_album_by_artist_and_album(self): - result = self.h.handle_request( + result = self.dispatcher.handle_request( u'list "album" "artist" "anartist" "album" "analbum"') self.assert_(u'OK' in result) ### Date def test_list_date_with_quotes(self): - result = self.h.handle_request(u'list "date"') + result = self.dispatcher.handle_request(u'list "date"') self.assert_(u'OK' in result) def test_list_date_without_quotes(self): - result = self.h.handle_request(u'list date') + result = self.dispatcher.handle_request(u'list date') self.assert_(u'OK' in result) def test_list_date_without_quotes_and_capitalized(self): - result = self.h.handle_request(u'list Date') + result = self.dispatcher.handle_request(u'list Date') self.assert_(u'OK' in result) def test_list_date_with_query_of_one_token(self): - result = self.h.handle_request(u'list "date" "anartist"') + result = self.dispatcher.handle_request(u'list "date" "anartist"') self.assertEqual(result[0], u'ACK [2@0] {list} should be "Album" for 3 arguments') def test_list_date_by_artist(self): - result = self.h.handle_request(u'list "date" "artist" "anartist"') + result = self.dispatcher.handle_request( + u'list "date" "artist" "anartist"') self.assert_(u'OK' in result) def test_list_date_by_album(self): - result = self.h.handle_request(u'list "date" "album" "analbum"') + result = self.dispatcher.handle_request( + u'list "date" "album" "analbum"') self.assert_(u'OK' in result) def test_list_date_by_full_date(self): - result = self.h.handle_request(u'list "date" "date" "2001-01-01"') + result = self.dispatcher.handle_request( + u'list "date" "date" "2001-01-01"') self.assert_(u'OK' in result) def test_list_date_by_year(self): - result = self.h.handle_request(u'list "date" "date" "2001"') + result = self.dispatcher.handle_request(u'list "date" "date" "2001"') self.assert_(u'OK' in result) def test_list_date_by_genre(self): - result = self.h.handle_request(u'list "date" "genre" "agenre"') + result = self.dispatcher.handle_request(u'list "date" "genre" "agenre"') self.assert_(u'OK' in result) def test_list_date_by_artist_and_album(self): - result = self.h.handle_request( + result = self.dispatcher.handle_request( u'list "date" "artist" "anartist" "album" "analbum"') self.assert_(u'OK' in result) ### Genre def test_list_genre_with_quotes(self): - result = self.h.handle_request(u'list "genre"') + result = self.dispatcher.handle_request(u'list "genre"') self.assert_(u'OK' in result) def test_list_genre_without_quotes(self): - result = self.h.handle_request(u'list genre') + result = self.dispatcher.handle_request(u'list genre') self.assert_(u'OK' in result) def test_list_genre_without_quotes_and_capitalized(self): - result = self.h.handle_request(u'list Genre') + result = self.dispatcher.handle_request(u'list Genre') self.assert_(u'OK' in result) def test_list_genre_with_query_of_one_token(self): - result = self.h.handle_request(u'list "genre" "anartist"') + result = self.dispatcher.handle_request(u'list "genre" "anartist"') self.assertEqual(result[0], u'ACK [2@0] {list} should be "Album" for 3 arguments') def test_list_genre_by_artist(self): - result = self.h.handle_request(u'list "genre" "artist" "anartist"') + result = self.dispatcher.handle_request( + u'list "genre" "artist" "anartist"') self.assert_(u'OK' in result) def test_list_genre_by_album(self): - result = self.h.handle_request(u'list "genre" "album" "analbum"') + result = self.dispatcher.handle_request( + u'list "genre" "album" "analbum"') self.assert_(u'OK' in result) def test_list_genre_by_full_date(self): - result = self.h.handle_request(u'list "genre" "date" "2001-01-01"') + result = self.dispatcher.handle_request( + u'list "genre" "date" "2001-01-01"') self.assert_(u'OK' in result) def test_list_genre_by_year(self): - result = self.h.handle_request(u'list "genre" "date" "2001"') + result = self.dispatcher.handle_request( + u'list "genre" "date" "2001"') self.assert_(u'OK' in result) def test_list_genre_by_genre(self): - result = self.h.handle_request(u'list "genre" "genre" "agenre"') + result = self.dispatcher.handle_request( + u'list "genre" "genre" "agenre"') self.assert_(u'OK' in result) def test_list_genre_by_artist_and_album(self): - result = self.h.handle_request( + result = self.dispatcher.handle_request( u'list "genre" "artist" "anartist" "album" "analbum"') self.assert_(u'OK' in result) class MusicDatabaseSearchTest(unittest.TestCase): def setUp(self): - self.b = DummyBackend.start().proxy() + self.backend = DummyBackend.start().proxy() self.mixer = DummyMixer.start().proxy() - self.h = dispatcher.MpdDispatcher() + self.dispatcher = MpdDispatcher() def tearDown(self): - self.b.stop().get() + self.backend.stop().get() self.mixer.stop().get() def test_search_album(self): - result = self.h.handle_request(u'search "album" "analbum"') + result = self.dispatcher.handle_request(u'search "album" "analbum"') self.assert_(u'OK' in result) def test_search_album_without_quotes(self): - result = self.h.handle_request(u'search album "analbum"') + result = self.dispatcher.handle_request(u'search album "analbum"') self.assert_(u'OK' in result) def test_search_artist(self): - result = self.h.handle_request(u'search "artist" "anartist"') + result = self.dispatcher.handle_request(u'search "artist" "anartist"') self.assert_(u'OK' in result) def test_search_artist_without_quotes(self): - result = self.h.handle_request(u'search artist "anartist"') + result = self.dispatcher.handle_request(u'search artist "anartist"') self.assert_(u'OK' in result) def test_search_filename(self): - result = self.h.handle_request(u'search "filename" "afilename"') + result = self.dispatcher.handle_request( + u'search "filename" "afilename"') self.assert_(u'OK' in result) def test_search_filename_without_quotes(self): - result = self.h.handle_request(u'search filename "afilename"') + result = self.dispatcher.handle_request(u'search filename "afilename"') self.assert_(u'OK' in result) def test_search_title(self): - result = self.h.handle_request(u'search "title" "atitle"') + result = self.dispatcher.handle_request(u'search "title" "atitle"') self.assert_(u'OK' in result) def test_search_title_without_quotes(self): - result = self.h.handle_request(u'search title "atitle"') + result = self.dispatcher.handle_request(u'search title "atitle"') self.assert_(u'OK' in result) def test_search_any(self): - result = self.h.handle_request(u'search "any" "anything"') + result = self.dispatcher.handle_request(u'search "any" "anything"') self.assert_(u'OK' in result) def test_search_any_without_quotes(self): - result = self.h.handle_request(u'search any "anything"') + result = self.dispatcher.handle_request(u'search any "anything"') self.assert_(u'OK' in result) def test_search_date(self): - result = self.h.handle_request(u'search "date" "2002-01-01"') + result = self.dispatcher.handle_request(u'search "date" "2002-01-01"') self.assert_(u'OK' in result) def test_search_date_without_quotes(self): - result = self.h.handle_request(u'search date "2002-01-01"') + result = self.dispatcher.handle_request(u'search date "2002-01-01"') self.assert_(u'OK' in result) def test_search_date_with_capital_d_and_incomplete_date(self): - result = self.h.handle_request(u'search Date "2005"') + result = self.dispatcher.handle_request(u'search Date "2005"') self.assert_(u'OK' in result) def test_search_else_should_fail(self): - result = self.h.handle_request(u'search "sometype" "something"') + result = self.dispatcher.handle_request( + u'search "sometype" "something"') self.assertEqual(result[0], u'ACK [2@0] {search} incorrect arguments') diff --git a/tests/frontends/mpd/playback_test.py b/tests/frontends/mpd/playback_test.py index 8601aa9c..e80943d6 100644 --- a/tests/frontends/mpd/playback_test.py +++ b/tests/frontends/mpd/playback_test.py @@ -2,7 +2,7 @@ import unittest from mopidy.backends.base import PlaybackController from mopidy.backends.dummy import DummyBackend -from mopidy.frontends.mpd import dispatcher +from mopidy.frontends.mpd.dispatcher import MpdDispatcher from mopidy.mixers.dummy import DummyMixer from mopidy.models import Track @@ -14,393 +14,378 @@ STOPPED = PlaybackController.STOPPED class PlaybackOptionsHandlerTest(unittest.TestCase): def setUp(self): - self.b = DummyBackend.start().proxy() + self.backend = DummyBackend.start().proxy() self.mixer = DummyMixer.start().proxy() - self.h = dispatcher.MpdDispatcher() + self.dispatcher = MpdDispatcher() def tearDown(self): - self.b.stop().get() + self.backend.stop().get() self.mixer.stop().get() def test_consume_off(self): - result = self.h.handle_request(u'consume "0"') - self.assertFalse(self.b.playback.consume.get()) + result = self.dispatcher.handle_request(u'consume "0"') + self.assertFalse(self.backend.playback.consume.get()) self.assert_(u'OK' in result) def test_consume_off_without_quotes(self): - result = self.h.handle_request(u'consume 0') - self.assertFalse(self.b.playback.consume.get()) + result = self.dispatcher.handle_request(u'consume 0') + self.assertFalse(self.backend.playback.consume.get()) self.assert_(u'OK' in result) def test_consume_on(self): - result = self.h.handle_request(u'consume "1"') - self.assertTrue(self.b.playback.consume.get()) + result = self.dispatcher.handle_request(u'consume "1"') + self.assertTrue(self.backend.playback.consume.get()) self.assert_(u'OK' in result) def test_consume_on_without_quotes(self): - result = self.h.handle_request(u'consume 1') - self.assertTrue(self.b.playback.consume.get()) + result = self.dispatcher.handle_request(u'consume 1') + self.assertTrue(self.backend.playback.consume.get()) self.assert_(u'OK' in result) def test_crossfade(self): - result = self.h.handle_request(u'crossfade "10"') + result = self.dispatcher.handle_request(u'crossfade "10"') self.assert_(u'ACK [0@0] {} Not implemented' in result) def test_random_off(self): - result = self.h.handle_request(u'random "0"') - self.assertFalse(self.b.playback.random.get()) + result = self.dispatcher.handle_request(u'random "0"') + self.assertFalse(self.backend.playback.random.get()) self.assert_(u'OK' in result) def test_random_off_without_quotes(self): - result = self.h.handle_request(u'random 0') - self.assertFalse(self.b.playback.random.get()) + result = self.dispatcher.handle_request(u'random 0') + self.assertFalse(self.backend.playback.random.get()) self.assert_(u'OK' in result) def test_random_on(self): - result = self.h.handle_request(u'random "1"') - self.assertTrue(self.b.playback.random.get()) + result = self.dispatcher.handle_request(u'random "1"') + self.assertTrue(self.backend.playback.random.get()) self.assert_(u'OK' in result) def test_random_on_without_quotes(self): - result = self.h.handle_request(u'random 1') - self.assertTrue(self.b.playback.random.get()) + result = self.dispatcher.handle_request(u'random 1') + self.assertTrue(self.backend.playback.random.get()) self.assert_(u'OK' in result) def test_repeat_off(self): - result = self.h.handle_request(u'repeat "0"') - self.assertFalse(self.b.playback.repeat.get()) + result = self.dispatcher.handle_request(u'repeat "0"') + self.assertFalse(self.backend.playback.repeat.get()) self.assert_(u'OK' in result) def test_repeat_off_without_quotes(self): - result = self.h.handle_request(u'repeat 0') - self.assertFalse(self.b.playback.repeat.get()) + result = self.dispatcher.handle_request(u'repeat 0') + self.assertFalse(self.backend.playback.repeat.get()) self.assert_(u'OK' in result) def test_repeat_on(self): - result = self.h.handle_request(u'repeat "1"') - self.assertTrue(self.b.playback.repeat.get()) + result = self.dispatcher.handle_request(u'repeat "1"') + self.assertTrue(self.backend.playback.repeat.get()) self.assert_(u'OK' in result) def test_repeat_on_without_quotes(self): - result = self.h.handle_request(u'repeat 1') - self.assertTrue(self.b.playback.repeat.get()) + result = self.dispatcher.handle_request(u'repeat 1') + self.assertTrue(self.backend.playback.repeat.get()) self.assert_(u'OK' in result) def test_setvol_below_min(self): - result = self.h.handle_request(u'setvol "-10"') + result = self.dispatcher.handle_request(u'setvol "-10"') self.assert_(u'OK' in result) self.assertEqual(0, self.mixer.volume.get()) def test_setvol_min(self): - result = self.h.handle_request(u'setvol "0"') + result = self.dispatcher.handle_request(u'setvol "0"') self.assert_(u'OK' in result) self.assertEqual(0, self.mixer.volume.get()) def test_setvol_middle(self): - result = self.h.handle_request(u'setvol "50"') + result = self.dispatcher.handle_request(u'setvol "50"') self.assert_(u'OK' in result) self.assertEqual(50, self.mixer.volume.get()) def test_setvol_max(self): - result = self.h.handle_request(u'setvol "100"') + result = self.dispatcher.handle_request(u'setvol "100"') self.assert_(u'OK' in result) self.assertEqual(100, self.mixer.volume.get()) def test_setvol_above_max(self): - result = self.h.handle_request(u'setvol "110"') + result = self.dispatcher.handle_request(u'setvol "110"') self.assert_(u'OK' in result) self.assertEqual(100, self.mixer.volume.get()) def test_setvol_plus_is_ignored(self): - result = self.h.handle_request(u'setvol "+10"') + result = self.dispatcher.handle_request(u'setvol "+10"') self.assert_(u'OK' in result) self.assertEqual(10, self.mixer.volume.get()) def test_setvol_without_quotes(self): - result = self.h.handle_request(u'setvol 50') + result = self.dispatcher.handle_request(u'setvol 50') self.assert_(u'OK' in result) self.assertEqual(50, self.mixer.volume.get()) def test_single_off(self): - result = self.h.handle_request(u'single "0"') - self.assertFalse(self.b.playback.single.get()) + result = self.dispatcher.handle_request(u'single "0"') + self.assertFalse(self.backend.playback.single.get()) self.assert_(u'OK' in result) def test_single_off_without_quotes(self): - result = self.h.handle_request(u'single 0') - self.assertFalse(self.b.playback.single.get()) + result = self.dispatcher.handle_request(u'single 0') + self.assertFalse(self.backend.playback.single.get()) self.assert_(u'OK' in result) def test_single_on(self): - result = self.h.handle_request(u'single "1"') - self.assertTrue(self.b.playback.single.get()) + result = self.dispatcher.handle_request(u'single "1"') + self.assertTrue(self.backend.playback.single.get()) self.assert_(u'OK' in result) def test_single_on_without_quotes(self): - result = self.h.handle_request(u'single 1') - self.assertTrue(self.b.playback.single.get()) + result = self.dispatcher.handle_request(u'single 1') + self.assertTrue(self.backend.playback.single.get()) self.assert_(u'OK' in result) def test_replay_gain_mode_off(self): - result = self.h.handle_request(u'replay_gain_mode "off"') + result = self.dispatcher.handle_request(u'replay_gain_mode "off"') self.assert_(u'ACK [0@0] {} Not implemented' in result) def test_replay_gain_mode_track(self): - result = self.h.handle_request(u'replay_gain_mode "track"') + result = self.dispatcher.handle_request(u'replay_gain_mode "track"') self.assert_(u'ACK [0@0] {} Not implemented' in result) def test_replay_gain_mode_album(self): - result = self.h.handle_request(u'replay_gain_mode "album"') + result = self.dispatcher.handle_request(u'replay_gain_mode "album"') self.assert_(u'ACK [0@0] {} Not implemented' in result) def test_replay_gain_status_default(self): expected = u'off' - result = self.h.handle_request(u'replay_gain_status') + result = self.dispatcher.handle_request(u'replay_gain_status') self.assert_(u'OK' in result) self.assert_(expected in result) def test_replay_gain_status_off(self): - raise SkipTest - expected = u'off' - self.h._replay_gain_mode(expected) - result = self.h.handle_request(u'replay_gain_status') - self.assert_(u'OK' in result) - self.assert_(expected in result) + raise SkipTest # TODO def test_replay_gain_status_track(self): - raise SkipTest - expected = u'track' - self.h._replay_gain_mode(expected) - result = self.h.handle_request(u'replay_gain_status') - self.assert_(u'OK' in result) - self.assert_(expected in result) + raise SkipTest # TODO def test_replay_gain_status_album(self): - raise SkipTest - expected = u'album' - self.h._replay_gain_mode(expected) - result = self.h.handle_request(u'replay_gain_status') - self.assert_(u'OK' in result) - self.assert_(expected in result) + raise SkipTest # TODO class PlaybackControlHandlerTest(unittest.TestCase): def setUp(self): - self.b = DummyBackend.start().proxy() + self.backend = DummyBackend.start().proxy() self.mixer = DummyMixer.start().proxy() - self.h = dispatcher.MpdDispatcher() + self.dispatcher = MpdDispatcher() def tearDown(self): - self.b.stop().get() + self.backend.stop().get() self.mixer.stop().get() def test_next(self): - result = self.h.handle_request(u'next') + result = self.dispatcher.handle_request(u'next') self.assert_(u'OK' in result) def test_pause_off(self): - self.b.current_playlist.append([Track()]) - self.h.handle_request(u'play "0"') - self.h.handle_request(u'pause "1"') - result = self.h.handle_request(u'pause "0"') + self.backend.current_playlist.append([Track()]) + self.dispatcher.handle_request(u'play "0"') + self.dispatcher.handle_request(u'pause "1"') + result = self.dispatcher.handle_request(u'pause "0"') self.assert_(u'OK' in result) - self.assertEqual(PLAYING, self.b.playback.state.get()) + self.assertEqual(PLAYING, self.backend.playback.state.get()) def test_pause_on(self): - self.b.current_playlist.append([Track()]) - self.h.handle_request(u'play "0"') - result = self.h.handle_request(u'pause "1"') + self.backend.current_playlist.append([Track()]) + self.dispatcher.handle_request(u'play "0"') + result = self.dispatcher.handle_request(u'pause "1"') self.assert_(u'OK' in result) - self.assertEqual(PAUSED, self.b.playback.state.get()) + self.assertEqual(PAUSED, self.backend.playback.state.get()) def test_pause_toggle(self): - self.b.current_playlist.append([Track()]) - result = self.h.handle_request(u'play "0"') + self.backend.current_playlist.append([Track()]) + result = self.dispatcher.handle_request(u'play "0"') self.assert_(u'OK' in result) - self.assertEqual(PLAYING, self.b.playback.state.get()) - result = self.h.handle_request(u'pause') + self.assertEqual(PLAYING, self.backend.playback.state.get()) + result = self.dispatcher.handle_request(u'pause') self.assert_(u'OK' in result) - self.assertEqual(PAUSED, self.b.playback.state.get()) - result = self.h.handle_request(u'pause') + self.assertEqual(PAUSED, self.backend.playback.state.get()) + result = self.dispatcher.handle_request(u'pause') self.assert_(u'OK' in result) - self.assertEqual(PLAYING, self.b.playback.state.get()) + self.assertEqual(PLAYING, self.backend.playback.state.get()) def test_play_without_pos(self): - self.b.current_playlist.append([Track()]) - self.b.playback.state = PAUSED - result = self.h.handle_request(u'play') + self.backend.current_playlist.append([Track()]) + self.backend.playback.state = PAUSED + result = self.dispatcher.handle_request(u'play') self.assert_(u'OK' in result) - self.assertEqual(PLAYING, self.b.playback.state.get()) + self.assertEqual(PLAYING, self.backend.playback.state.get()) def test_play_with_pos(self): - self.b.current_playlist.append([Track()]) - result = self.h.handle_request(u'play "0"') + self.backend.current_playlist.append([Track()]) + result = self.dispatcher.handle_request(u'play "0"') self.assert_(u'OK' in result) - self.assertEqual(PLAYING, self.b.playback.state.get()) + self.assertEqual(PLAYING, self.backend.playback.state.get()) def test_play_with_pos_without_quotes(self): - self.b.current_playlist.append([Track()]) - result = self.h.handle_request(u'play 0') + self.backend.current_playlist.append([Track()]) + result = self.dispatcher.handle_request(u'play 0') self.assert_(u'OK' in result) - self.assertEqual(PLAYING, self.b.playback.state.get()) + self.assertEqual(PLAYING, self.backend.playback.state.get()) def test_play_with_pos_out_of_bounds(self): - self.b.current_playlist.append([]) - result = self.h.handle_request(u'play "0"') + self.backend.current_playlist.append([]) + result = self.dispatcher.handle_request(u'play "0"') self.assertEqual(result[0], u'ACK [2@0] {play} Bad song index') - self.assertEqual(STOPPED, self.b.playback.state.get()) + self.assertEqual(STOPPED, self.backend.playback.state.get()) def test_play_minus_one_plays_first_in_playlist_if_no_current_track(self): - self.assertEqual(self.b.playback.current_track.get(), None) - self.b.current_playlist.append([Track(uri='a'), Track(uri='b')]) - result = self.h.handle_request(u'play "-1"') + self.assertEqual(self.backend.playback.current_track.get(), None) + self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) + result = self.dispatcher.handle_request(u'play "-1"') self.assert_(u'OK' in result) - self.assertEqual(PLAYING, self.b.playback.state.get()) - self.assertEqual(self.b.playback.current_track.get().uri, 'a') + self.assertEqual(PLAYING, self.backend.playback.state.get()) + self.assertEqual(self.backend.playback.current_track.get().uri, 'a') def test_play_minus_one_plays_current_track_if_current_track_is_set(self): - self.b.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.assertEqual(self.b.playback.current_track.get(), None) - self.b.playback.play() - self.b.playback.next() - self.b.playback.stop() - self.assertNotEqual(self.b.playback.current_track.get(), None) - result = self.h.handle_request(u'play "-1"') + self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.assertEqual(self.backend.playback.current_track.get(), None) + self.backend.playback.play() + self.backend.playback.next() + self.backend.playback.stop() + self.assertNotEqual(self.backend.playback.current_track.get(), None) + result = self.dispatcher.handle_request(u'play "-1"') self.assert_(u'OK' in result) - self.assertEqual(PLAYING, self.b.playback.state.get()) - self.assertEqual(self.b.playback.current_track.get().uri, 'b') + self.assertEqual(PLAYING, self.backend.playback.state.get()) + self.assertEqual(self.backend.playback.current_track.get().uri, 'b') def test_play_minus_one_on_empty_playlist_does_not_ack(self): - self.b.current_playlist.clear() - result = self.h.handle_request(u'play "-1"') + self.backend.current_playlist.clear() + result = self.dispatcher.handle_request(u'play "-1"') self.assert_(u'OK' in result) - self.assertEqual(STOPPED, self.b.playback.state.get()) - self.assertEqual(self.b.playback.current_track.get(), None) + self.assertEqual(STOPPED, self.backend.playback.state.get()) + self.assertEqual(self.backend.playback.current_track.get(), None) def test_play_minus_is_ignored_if_playing(self): - self.b.current_playlist.append([Track(length=40000)]) - self.b.playback.seek(30000) - self.assert_(self.b.playback.time_position.get() >= 30000) - self.assertEquals(PLAYING, self.b.playback.state.get()) - result = self.h.handle_request(u'play "-1"') + self.backend.current_playlist.append([Track(length=40000)]) + self.backend.playback.seek(30000) + self.assert_(self.backend.playback.time_position.get() >= 30000) + self.assertEquals(PLAYING, self.backend.playback.state.get()) + result = self.dispatcher.handle_request(u'play "-1"') self.assert_(u'OK' in result) - self.assertEqual(PLAYING, self.b.playback.state.get()) - self.assert_(self.b.playback.time_position.get() >= 30000) + self.assertEqual(PLAYING, self.backend.playback.state.get()) + self.assert_(self.backend.playback.time_position.get() >= 30000) def test_play_minus_one_resumes_if_paused(self): - self.b.current_playlist.append([Track(length=40000)]) - self.b.playback.seek(30000) - self.assert_(self.b.playback.time_position.get() >= 30000) - self.assertEquals(PLAYING, self.b.playback.state.get()) - self.b.playback.pause() - self.assertEquals(PAUSED, self.b.playback.state.get()) - result = self.h.handle_request(u'play "-1"') + self.backend.current_playlist.append([Track(length=40000)]) + self.backend.playback.seek(30000) + self.assert_(self.backend.playback.time_position.get() >= 30000) + self.assertEquals(PLAYING, self.backend.playback.state.get()) + self.backend.playback.pause() + self.assertEquals(PAUSED, self.backend.playback.state.get()) + result = self.dispatcher.handle_request(u'play "-1"') self.assert_(u'OK' in result) - self.assertEqual(PLAYING, self.b.playback.state.get()) - self.assert_(self.b.playback.time_position.get() >= 30000) + self.assertEqual(PLAYING, self.backend.playback.state.get()) + self.assert_(self.backend.playback.time_position.get() >= 30000) def test_playid(self): - self.b.current_playlist.append([Track()]) - result = self.h.handle_request(u'playid "0"') + self.backend.current_playlist.append([Track()]) + result = self.dispatcher.handle_request(u'playid "0"') self.assert_(u'OK' in result) - self.assertEqual(PLAYING, self.b.playback.state.get()) + self.assertEqual(PLAYING, self.backend.playback.state.get()) def test_playid_minus_one_plays_first_in_playlist_if_no_current_track(self): - self.assertEqual(self.b.playback.current_track.get(), None) - self.b.current_playlist.append([Track(uri='a'), Track(uri='b')]) - result = self.h.handle_request(u'playid "-1"') + self.assertEqual(self.backend.playback.current_track.get(), None) + self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) + result = self.dispatcher.handle_request(u'playid "-1"') self.assert_(u'OK' in result) - self.assertEqual(PLAYING, self.b.playback.state.get()) - self.assertEqual(self.b.playback.current_track.get().uri, 'a') + self.assertEqual(PLAYING, self.backend.playback.state.get()) + self.assertEqual(self.backend.playback.current_track.get().uri, 'a') def test_playid_minus_one_plays_current_track_if_current_track_is_set(self): - self.b.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.assertEqual(self.b.playback.current_track.get(), None) - self.b.playback.play() - self.b.playback.next() - self.b.playback.stop() - self.assertNotEqual(self.b.playback.current_track.get(), None) - result = self.h.handle_request(u'playid "-1"') + self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.assertEqual(self.backend.playback.current_track.get(), None) + self.backend.playback.play() + self.backend.playback.next() + self.backend.playback.stop() + self.assertNotEqual(self.backend.playback.current_track.get(), None) + result = self.dispatcher.handle_request(u'playid "-1"') self.assert_(u'OK' in result) - self.assertEqual(PLAYING, self.b.playback.state.get()) - self.assertEqual(self.b.playback.current_track.get().uri, 'b') + self.assertEqual(PLAYING, self.backend.playback.state.get()) + self.assertEqual(self.backend.playback.current_track.get().uri, 'b') def test_playid_minus_one_on_empty_playlist_does_not_ack(self): - self.b.current_playlist.clear() - result = self.h.handle_request(u'playid "-1"') + self.backend.current_playlist.clear() + result = self.dispatcher.handle_request(u'playid "-1"') self.assert_(u'OK' in result) - self.assertEqual(STOPPED, self.b.playback.state.get()) - self.assertEqual(self.b.playback.current_track.get(), None) + self.assertEqual(STOPPED, self.backend.playback.state.get()) + self.assertEqual(self.backend.playback.current_track.get(), None) def test_playid_minus_is_ignored_if_playing(self): - self.b.current_playlist.append([Track(length=40000)]) - self.b.playback.seek(30000) - self.assert_(self.b.playback.time_position.get() >= 30000) - self.assertEquals(PLAYING, self.b.playback.state.get()) - result = self.h.handle_request(u'playid "-1"') + self.backend.current_playlist.append([Track(length=40000)]) + self.backend.playback.seek(30000) + self.assert_(self.backend.playback.time_position.get() >= 30000) + self.assertEquals(PLAYING, self.backend.playback.state.get()) + result = self.dispatcher.handle_request(u'playid "-1"') self.assert_(u'OK' in result) - self.assertEqual(PLAYING, self.b.playback.state.get()) - self.assert_(self.b.playback.time_position.get() >= 30000) + self.assertEqual(PLAYING, self.backend.playback.state.get()) + self.assert_(self.backend.playback.time_position.get() >= 30000) def test_playid_minus_one_resumes_if_paused(self): - self.b.current_playlist.append([Track(length=40000)]) - self.b.playback.seek(30000) - self.assert_(self.b.playback.time_position.get() >= 30000) - self.assertEquals(PLAYING, self.b.playback.state.get()) - self.b.playback.pause() - self.assertEquals(PAUSED, self.b.playback.state.get()) - result = self.h.handle_request(u'playid "-1"') + self.backend.current_playlist.append([Track(length=40000)]) + self.backend.playback.seek(30000) + self.assert_(self.backend.playback.time_position.get() >= 30000) + self.assertEquals(PLAYING, self.backend.playback.state.get()) + self.backend.playback.pause() + self.assertEquals(PAUSED, self.backend.playback.state.get()) + result = self.dispatcher.handle_request(u'playid "-1"') self.assert_(u'OK' in result) - self.assertEqual(PLAYING, self.b.playback.state.get()) - self.assert_(self.b.playback.time_position.get() >= 30000) + self.assertEqual(PLAYING, self.backend.playback.state.get()) + self.assert_(self.backend.playback.time_position.get() >= 30000) def test_playid_which_does_not_exist(self): - self.b.current_playlist.append([Track()]) - result = self.h.handle_request(u'playid "12345"') + self.backend.current_playlist.append([Track()]) + result = self.dispatcher.handle_request(u'playid "12345"') self.assertEqual(result[0], u'ACK [50@0] {playid} No such song') def test_previous(self): - result = self.h.handle_request(u'previous') + result = self.dispatcher.handle_request(u'previous') self.assert_(u'OK' in result) def test_seek(self): - self.b.current_playlist.append([Track(length=40000)]) - self.h.handle_request(u'seek "0"') - result = self.h.handle_request(u'seek "0" "30"') + self.backend.current_playlist.append([Track(length=40000)]) + self.dispatcher.handle_request(u'seek "0"') + result = self.dispatcher.handle_request(u'seek "0" "30"') self.assert_(u'OK' in result) - self.assert_(self.b.playback.time_position >= 30000) + self.assert_(self.backend.playback.time_position >= 30000) def test_seek_with_songpos(self): seek_track = Track(uri='2', length=40000) - self.b.current_playlist.append( + self.backend.current_playlist.append( [Track(uri='1', length=40000), seek_track]) - result = self.h.handle_request(u'seek "1" "30"') + result = self.dispatcher.handle_request(u'seek "1" "30"') self.assert_(u'OK' in result) - self.assertEqual(self.b.playback.current_track.get(), seek_track) + self.assertEqual(self.backend.playback.current_track.get(), seek_track) def test_seek_without_quotes(self): - self.b.current_playlist.append([Track(length=40000)]) - self.h.handle_request(u'seek 0') - result = self.h.handle_request(u'seek 0 30') + self.backend.current_playlist.append([Track(length=40000)]) + self.dispatcher.handle_request(u'seek 0') + result = self.dispatcher.handle_request(u'seek 0 30') self.assert_(u'OK' in result) - self.assert_(self.b.playback.time_position.get() >= 30000) + self.assert_(self.backend.playback.time_position.get() >= 30000) def test_seekid(self): - self.b.current_playlist.append([Track(length=40000)]) - result = self.h.handle_request(u'seekid "0" "30"') + self.backend.current_playlist.append([Track(length=40000)]) + result = self.dispatcher.handle_request(u'seekid "0" "30"') self.assert_(u'OK' in result) - self.assert_(self.b.playback.time_position.get() >= 30000) + self.assert_(self.backend.playback.time_position.get() >= 30000) def test_seekid_with_cpid(self): seek_track = Track(uri='2', length=40000) - self.b.current_playlist.append( + self.backend.current_playlist.append( [Track(length=40000), seek_track]) - result = self.h.handle_request(u'seekid "1" "30"') + result = self.dispatcher.handle_request(u'seekid "1" "30"') self.assert_(u'OK' in result) - self.assertEqual(self.b.playback.current_cpid.get(), 1) - self.assertEqual(self.b.playback.current_track.get(), seek_track) + self.assertEqual(self.backend.playback.current_cpid.get(), 1) + self.assertEqual(self.backend.playback.current_track.get(), seek_track) def test_stop(self): - result = self.h.handle_request(u'stop') + result = self.dispatcher.handle_request(u'stop') self.assert_(u'OK' in result) - self.assertEqual(STOPPED, self.b.playback.state.get()) + self.assertEqual(STOPPED, self.backend.playback.state.get()) diff --git a/tests/frontends/mpd/reflection_test.py b/tests/frontends/mpd/reflection_test.py index be95c49b..c5cde1bb 100644 --- a/tests/frontends/mpd/reflection_test.py +++ b/tests/frontends/mpd/reflection_test.py @@ -1,21 +1,21 @@ import unittest from mopidy.backends.dummy import DummyBackend -from mopidy.frontends.mpd import dispatcher +from mopidy.frontends.mpd.dispatcher import MpdDispatcher from mopidy.mixers.dummy import DummyMixer class ReflectionHandlerTest(unittest.TestCase): def setUp(self): - self.b = DummyBackend.start().proxy() + self.backend = DummyBackend.start().proxy() self.mixer = DummyMixer.start().proxy() - self.h = dispatcher.MpdDispatcher() + self.dispatcher = MpdDispatcher() def tearDown(self): - self.b.stop().get() + self.backend.stop().get() self.mixer.stop().get() def test_commands_returns_list_of_all_commands(self): - result = self.h.handle_request(u'commands') + result = self.dispatcher.handle_request(u'commands') # Check if some random commands are included self.assert_(u'command: commands' in result) self.assert_(u'command: play' in result) @@ -30,19 +30,19 @@ class ReflectionHandlerTest(unittest.TestCase): self.assert_(u'OK' in result) def test_decoders(self): - result = self.h.handle_request(u'decoders') + result = self.dispatcher.handle_request(u'decoders') self.assert_(u'ACK [0@0] {} Not implemented' in result) def test_notcommands_returns_only_ok(self): - result = self.h.handle_request(u'notcommands') + result = self.dispatcher.handle_request(u'notcommands') self.assertEqual(1, len(result)) self.assert_(u'OK' in result) def test_tagtypes(self): - result = self.h.handle_request(u'tagtypes') + result = self.dispatcher.handle_request(u'tagtypes') self.assert_(u'OK' in result) def test_urlhandlers(self): - result = self.h.handle_request(u'urlhandlers') + result = self.dispatcher.handle_request(u'urlhandlers') self.assert_(u'OK' in result) self.assert_(u'handler: dummy:' in result) diff --git a/tests/frontends/mpd/stickers_test.py b/tests/frontends/mpd/stickers_test.py index 83d43792..86ac8aec 100644 --- a/tests/frontends/mpd/stickers_test.py +++ b/tests/frontends/mpd/stickers_test.py @@ -1,45 +1,45 @@ import unittest from mopidy.backends.dummy import DummyBackend -from mopidy.frontends.mpd import dispatcher +from mopidy.frontends.mpd.dispatcher import MpdDispatcher from mopidy.mixers.dummy import DummyMixer class StickersHandlerTest(unittest.TestCase): def setUp(self): - self.b = DummyBackend.start().proxy() + self.backend = DummyBackend.start().proxy() self.mixer = DummyMixer.start().proxy() - self.h = dispatcher.MpdDispatcher() + self.dispatcher = MpdDispatcher() def tearDown(self): - self.b.stop().get() + self.backend.stop().get() self.mixer.stop().get() def test_sticker_get(self): - result = self.h.handle_request( + result = self.dispatcher.handle_request( u'sticker get "song" "file:///dev/urandom" "a_name"') self.assert_(u'ACK [0@0] {} Not implemented' in result) def test_sticker_set(self): - result = self.h.handle_request( + result = self.dispatcher.handle_request( u'sticker set "song" "file:///dev/urandom" "a_name" "a_value"') self.assert_(u'ACK [0@0] {} Not implemented' in result) def test_sticker_delete_with_name(self): - result = self.h.handle_request( + result = self.dispatcher.handle_request( u'sticker delete "song" "file:///dev/urandom" "a_name"') self.assert_(u'ACK [0@0] {} Not implemented' in result) def test_sticker_delete_without_name(self): - result = self.h.handle_request( + result = self.dispatcher.handle_request( u'sticker delete "song" "file:///dev/urandom"') self.assert_(u'ACK [0@0] {} Not implemented' in result) def test_sticker_list(self): - result = self.h.handle_request( + result = self.dispatcher.handle_request( u'sticker list "song" "file:///dev/urandom"') self.assert_(u'ACK [0@0] {} Not implemented' in result) def test_sticker_find(self): - result = self.h.handle_request( + result = self.dispatcher.handle_request( u'sticker find "song" "file:///dev/urandom" "a_name"') self.assert_(u'ACK [0@0] {} Not implemented' in result) diff --git a/tests/frontends/mpd/stored_playlists_test.py b/tests/frontends/mpd/stored_playlists_test.py index e981c9ed..04bab6f1 100644 --- a/tests/frontends/mpd/stored_playlists_test.py +++ b/tests/frontends/mpd/stored_playlists_test.py @@ -2,64 +2,64 @@ import datetime as dt import unittest from mopidy.backends.dummy import DummyBackend -from mopidy.frontends.mpd import dispatcher +from mopidy.frontends.mpd.dispatcher import MpdDispatcher from mopidy.mixers.dummy import DummyMixer from mopidy.models import Track, Playlist class StoredPlaylistsHandlerTest(unittest.TestCase): def setUp(self): - self.b = DummyBackend.start().proxy() + self.backend = DummyBackend.start().proxy() self.mixer = DummyMixer.start().proxy() - self.h = dispatcher.MpdDispatcher() + self.dispatcher = MpdDispatcher() def tearDown(self): - self.b.stop().get() + self.backend.stop().get() self.mixer.stop().get() def test_listplaylist(self): - self.b.stored_playlists.playlists = [ + self.backend.stored_playlists.playlists = [ Playlist(name='name', tracks=[Track(uri='file:///dev/urandom')])] - result = self.h.handle_request(u'listplaylist "name"') + result = self.dispatcher.handle_request(u'listplaylist "name"') self.assert_(u'file: file:///dev/urandom' in result) self.assert_(u'OK' in result) def test_listplaylist_fails_if_no_playlist_is_found(self): - result = self.h.handle_request(u'listplaylist "name"') + result = self.dispatcher.handle_request(u'listplaylist "name"') self.assertEqual(result[0], u'ACK [50@0] {listplaylist} No such playlist') def test_listplaylistinfo(self): - self.b.stored_playlists.playlists = [ + self.backend.stored_playlists.playlists = [ Playlist(name='name', tracks=[Track(uri='file:///dev/urandom')])] - result = self.h.handle_request(u'listplaylistinfo "name"') + result = self.dispatcher.handle_request(u'listplaylistinfo "name"') self.assert_(u'file: file:///dev/urandom' in result) self.assert_(u'Track: 0' in result) self.assert_(u'Pos: 0' not in result) self.assert_(u'OK' in result) def test_listplaylistinfo_fails_if_no_playlist_is_found(self): - result = self.h.handle_request(u'listplaylistinfo "name"') + result = self.dispatcher.handle_request(u'listplaylistinfo "name"') self.assertEqual(result[0], u'ACK [50@0] {listplaylistinfo} No such playlist') def test_listplaylists(self): last_modified = dt.datetime(2001, 3, 17, 13, 41, 17, 12345) - self.b.stored_playlists.playlists = [Playlist(name='a', + self.backend.stored_playlists.playlists = [Playlist(name='a', last_modified=last_modified)] - result = self.h.handle_request(u'listplaylists') + result = self.dispatcher.handle_request(u'listplaylists') self.assert_(u'playlist: a' in result) # Date without microseconds and with time zone information self.assert_(u'Last-Modified: 2001-03-17T13:41:17Z' in result) self.assert_(u'OK' in result) def test_load_known_playlist_appends_to_current_playlist(self): - self.b.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.assertEqual(len(self.b.current_playlist.tracks.get()), 2) - self.b.stored_playlists.playlists = [Playlist(name='A-list', + self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.assertEqual(len(self.backend.current_playlist.tracks.get()), 2) + self.backend.stored_playlists.playlists = [Playlist(name='A-list', tracks=[Track(uri='c'), Track(uri='d'), Track(uri='e')])] - result = self.h.handle_request(u'load "A-list"') + result = self.dispatcher.handle_request(u'load "A-list"') self.assert_(u'OK' in result) - tracks = self.b.current_playlist.tracks.get() + tracks = self.backend.current_playlist.tracks.get() self.assertEqual(len(tracks), 5) self.assertEqual(tracks[0].uri, 'a') self.assertEqual(tracks[1].uri, 'b') @@ -68,35 +68,35 @@ class StoredPlaylistsHandlerTest(unittest.TestCase): self.assertEqual(tracks[4].uri, 'e') def test_load_unknown_playlist_acks(self): - result = self.h.handle_request(u'load "unknown playlist"') + result = self.dispatcher.handle_request(u'load "unknown playlist"') self.assert_(u'ACK [50@0] {load} No such playlist' in result) - self.assertEqual(len(self.b.current_playlist.tracks.get()), 0) + self.assertEqual(len(self.backend.current_playlist.tracks.get()), 0) def test_playlistadd(self): - result = self.h.handle_request( + result = self.dispatcher.handle_request( u'playlistadd "name" "file:///dev/urandom"') self.assert_(u'ACK [0@0] {} Not implemented' in result) def test_playlistclear(self): - result = self.h.handle_request(u'playlistclear "name"') + result = self.dispatcher.handle_request(u'playlistclear "name"') self.assert_(u'ACK [0@0] {} Not implemented' in result) def test_playlistdelete(self): - result = self.h.handle_request(u'playlistdelete "name" "5"') + result = self.dispatcher.handle_request(u'playlistdelete "name" "5"') self.assert_(u'ACK [0@0] {} Not implemented' in result) def test_playlistmove(self): - result = self.h.handle_request(u'playlistmove "name" "5" "10"') + result = self.dispatcher.handle_request(u'playlistmove "name" "5" "10"') self.assert_(u'ACK [0@0] {} Not implemented' in result) def test_rename(self): - result = self.h.handle_request(u'rename "old_name" "new_name"') + result = self.dispatcher.handle_request(u'rename "old_name" "new_name"') self.assert_(u'ACK [0@0] {} Not implemented' in result) def test_rm(self): - result = self.h.handle_request(u'rm "name"') + result = self.dispatcher.handle_request(u'rm "name"') self.assert_(u'ACK [0@0] {} Not implemented' in result) def test_save(self): - result = self.h.handle_request(u'save "name"') + result = self.dispatcher.handle_request(u'save "name"') self.assert_(u'ACK [0@0] {} Not implemented' in result) From d4ab666b213d203f38fc06aa8192d4063faee0f5 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 3 Jun 2011 16:57:48 +0200 Subject: [PATCH 111/218] Update outdated docstrings in MpdSession --- mopidy/frontends/mpd/session.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/frontends/mpd/session.py b/mopidy/frontends/mpd/session.py index 5a473eca..6308e66e 100644 --- a/mopidy/frontends/mpd/session.py +++ b/mopidy/frontends/mpd/session.py @@ -45,7 +45,7 @@ class MpdSession(asynchat.async_chat): logger.warning(u'Received invalid data: %s', e) def handle_request(self, request): - """Handle request by sending it to the MPD frontend.""" + """Handle request using the MPD command handlers.""" if not self.authenticated: (self.authenticated, response) = self.check_password(request) if response is not None: @@ -56,7 +56,7 @@ class MpdSession(asynchat.async_chat): self.handle_response(response) def handle_response(self, response): - """Handle response from the MPD frontend.""" + """Handle response from the MPD command handlers.""" self.send_response(LINE_TERMINATOR.join(response)) def send_response(self, output): From acde68159ad16f5912807f5ac26db2ba542a6e23 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 3 Jun 2011 17:06:32 +0200 Subject: [PATCH 112/218] Use @property and memoization to delay backend/mixer proxy creation until it is needed --- mopidy/frontends/mpd/dispatcher.py | 32 +++++++++++++++++++----------- 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/mopidy/frontends/mpd/dispatcher.py b/mopidy/frontends/mpd/dispatcher.py index 87a7112c..2eb0805c 100644 --- a/mopidy/frontends/mpd/dispatcher.py +++ b/mopidy/frontends/mpd/dispatcher.py @@ -91,23 +91,31 @@ class MpdContext(object): #: The current :class:`MpdDispatcher`. dispatcher = None - #: The backend. An instance of :class:`mopidy.backends.base.Backend`. - backend = None - - #: The mixer. An instance of :class:`mopidy.mixers.base.BaseMixer`. - mixer = None - def __init__(self, dispatcher): self.dispatcher = dispatcher - self.backend = self._get_backend() - self.mixer = self._get_mixer() + self._backend = None + self._mixer = None - def _get_backend(self): + @property + def backend(self): + """ + The backend. An instance of :class:`mopidy.backends.base.Backend`. + """ + if self._backend is not None: + return self._backend backend_refs = ActorRegistry.get_by_class(Backend) assert len(backend_refs) == 1, 'Expected exactly one running backend.' - return backend_refs[0].proxy() + self._backend = backend_refs[0].proxy() + return self._backend - def _get_mixer(self): + @property + def mixer(self): + """ + The mixer. An instance of :class:`mopidy.mixers.base.BaseMixer`. + """ + if self._mixer is not None: + return self._mixer mixer_refs = ActorRegistry.get_by_class(BaseMixer) assert len(mixer_refs) == 1, 'Expected exactly one running mixer.' - return mixer_refs[0].proxy() + self._mixer = mixer_refs[0].proxy() + return self._mixer From 82381720e57354236e1f65f69847ccc0adf6084f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 3 Jun 2011 17:12:41 +0200 Subject: [PATCH 113/218] Add the current MpdSession to the MpdContext --- mopidy/frontends/mpd/dispatcher.py | 10 +++++++--- mopidy/frontends/mpd/session.py | 2 +- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/mopidy/frontends/mpd/dispatcher.py b/mopidy/frontends/mpd/dispatcher.py index 2eb0805c..e513a4d1 100644 --- a/mopidy/frontends/mpd/dispatcher.py +++ b/mopidy/frontends/mpd/dispatcher.py @@ -25,10 +25,10 @@ class MpdDispatcher(object): # XXX Consider merging MpdDispatcher into MpdSession - def __init__(self): + def __init__(self, session=None): self.command_list = False self.command_list_ok = False - self.context = MpdContext(self) + self.context = MpdContext(self, session=session) def handle_request(self, request, command_list_index=None): """Dispatch incoming requests to the correct handler.""" @@ -91,8 +91,12 @@ class MpdContext(object): #: The current :class:`MpdDispatcher`. dispatcher = None - def __init__(self, dispatcher): + #: The current :class:`mopidy.frontends.mpd.session.MpdSession`. + session = None + + def __init__(self, dispatcher, session=None): self.dispatcher = dispatcher + self.session = session self._backend = None self._mixer = None diff --git a/mopidy/frontends/mpd/session.py b/mopidy/frontends/mpd/session.py index 6308e66e..4ac09a68 100644 --- a/mopidy/frontends/mpd/session.py +++ b/mopidy/frontends/mpd/session.py @@ -22,7 +22,7 @@ class MpdSession(asynchat.async_chat): self.input_buffer = [] self.authenticated = False self.set_terminator(LINE_TERMINATOR.encode(ENCODING)) - self.dispatcher = MpdDispatcher() + self.dispatcher = MpdDispatcher(session=self) def start(self): """Start a new client session.""" From 30d6d6f29e2e45806d0ff7c8edcc2a06565a3c77 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 3 Jun 2011 17:18:06 +0200 Subject: [PATCH 114/218] Close client connection on MPD command 'close' --- mopidy/frontends/mpd/protocol/connection.py | 2 +- tests/frontends/mpd/connection_test.py | 9 +++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/mopidy/frontends/mpd/protocol/connection.py b/mopidy/frontends/mpd/protocol/connection.py index 48e5ebd4..d6a1dc7c 100644 --- a/mopidy/frontends/mpd/protocol/connection.py +++ b/mopidy/frontends/mpd/protocol/connection.py @@ -11,7 +11,7 @@ def close(context): Closes the connection to MPD. """ - pass # TODO + context.session.close() @handle_pattern(r'^kill$') def kill(context): diff --git a/tests/frontends/mpd/connection_test.py b/tests/frontends/mpd/connection_test.py index e34265d4..84794257 100644 --- a/tests/frontends/mpd/connection_test.py +++ b/tests/frontends/mpd/connection_test.py @@ -1,23 +1,28 @@ +import mock import unittest from mopidy import settings from mopidy.backends.dummy import DummyBackend from mopidy.frontends.mpd.dispatcher import MpdDispatcher +from mopidy.frontends.mpd.session import MpdSession from mopidy.mixers.dummy import DummyMixer class ConnectionHandlerTest(unittest.TestCase): def setUp(self): self.backend = DummyBackend.start().proxy() self.mixer = DummyMixer.start().proxy() - self.dispatcher = MpdDispatcher() + self.session = mock.Mock(spec=MpdSession) + self.dispatcher = MpdDispatcher(session=self.session) def tearDown(self): self.backend.stop().get() self.mixer.stop().get() settings.runtime.clear() - def test_close(self): + def test_close_closes_the_client_connection(self): result = self.dispatcher.handle_request(u'close') + self.assert_(self.session.close.called, + u'Should call close() on MpdSession') self.assert_(u'OK' in result) def test_empty_request(self): From 4db9c0139b42cdaf7c22ac2032b5edfec9394ce4 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 3 Jun 2011 17:34:19 +0200 Subject: [PATCH 115/218] Update TODO for MPD command 'kill' --- mopidy/frontends/mpd/protocol/connection.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/mopidy/frontends/mpd/protocol/connection.py b/mopidy/frontends/mpd/protocol/connection.py index d6a1dc7c..2b030eb0 100644 --- a/mopidy/frontends/mpd/protocol/connection.py +++ b/mopidy/frontends/mpd/protocol/connection.py @@ -22,7 +22,9 @@ def kill(context): Kills MPD. """ - pass # TODO + # TODO We do not want to allow remote killing of Mopidy. We should throw an + # MPD exception here. Maybe using ACK_ERROR_PERMISSION. + pass @handle_pattern(r'^password "(?P[^"]+)"$') def password_(context, password): From 9545da4b4e7cc2360063f3162064e31466ef7a63 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 3 Jun 2011 22:37:05 +0200 Subject: [PATCH 116/218] Refactor MPD dispatcher --- mopidy/frontends/mpd/dispatcher.py | 62 ++++++++++++++------------ tests/frontends/mpd/dispatcher_test.py | 4 +- 2 files changed, 36 insertions(+), 30 deletions(-) diff --git a/mopidy/frontends/mpd/dispatcher.py b/mopidy/frontends/mpd/dispatcher.py index 423e8701..7b322419 100644 --- a/mopidy/frontends/mpd/dispatcher.py +++ b/mopidy/frontends/mpd/dispatcher.py @@ -27,8 +27,6 @@ class MpdDispatcher(object): back to the MPD session. """ - # XXX Consider merging MpdDispatcher into MpdSession - def __init__(self, session=None): self.command_list = False self.command_list_ok = False @@ -40,24 +38,26 @@ class MpdDispatcher(object): self.command_list.append(request) return None try: - (handler, kwargs) = self.find_handler(request) - result = handler(self.context, **kwargs) + result = self._call_handler(request) except MpdAckError as e: if command_list_index is not None: e.index = command_list_index - return self.handle_response(e.get_mpd_ack(), add_ok=False) + return self._format_response(e.get_mpd_ack(), add_ok=False) except ActorDeadError as e: logger.warning(u'Tried to communicate with dead actor.') mpd_error = MpdSystemError(e.message) - return self.handle_response(mpd_error.get_mpd_ack(), add_ok=False) + return self._format_response(mpd_error.get_mpd_ack(), add_ok=False) if request in (u'command_list_begin', u'command_list_ok_begin'): return None if command_list_index is not None: - return self.handle_response(result, add_ok=False) - return self.handle_response(result) + return self._format_response(result, add_ok=False) + return self._format_response(result) - def find_handler(self, request): - """Find the correct handler for a request.""" + def _call_handler(self, request): + (handler, kwargs) = self._find_handler(request) + return handler(self.context, **kwargs) + + def _find_handler(self, request): for pattern in request_handlers: matches = re.match(pattern, request) if matches is not None: @@ -67,28 +67,34 @@ class MpdDispatcher(object): raise MpdArgError(u'incorrect arguments', command=command) raise MpdUnknownCommand(command=command) - def handle_response(self, result, add_ok=True): - """Format the response from a request handler.""" + def _format_response(self, result, add_ok=True): response = [] - if result is None: - result = [] - elif isinstance(result, set): - result = list(result) - elif not isinstance(result, list): - result = [result] - for line in flatten(result): - if isinstance(line, dict): - for (key, value) in line.items(): - response.append(u'%s: %s' % (key, value)) - elif isinstance(line, tuple): - (key, value) = line - response.append(u'%s: %s' % (key, value)) - else: - response.append(line) - if add_ok and (not response or not response[-1].startswith(u'ACK')): + for element in self._listify_result(result): + response.extend(self._format_lines(element)) + if add_ok and (not response or not self._has_error(response)): response.append(u'OK') return response + def _listify_result(self, result): + if result is None: + return [] + if isinstance(result, set): + return flatten(list(result)) + if not isinstance(result, list): + return [result] + return flatten(result) + + def _format_lines(self, line): + if isinstance(line, dict): + return [u'%s: %s' % (key, value) for (key, value) in line.items()] + if isinstance(line, tuple): + (key, value) = line + return [u'%s: %s' % (key, value)] + return [line] + + def _has_error(self, response): + return bool(response) and response[-1].startswith(u'ACK') + class MpdContext(object): """ diff --git a/tests/frontends/mpd/dispatcher_test.py b/tests/frontends/mpd/dispatcher_test.py index 1d8efe64..7dd70834 100644 --- a/tests/frontends/mpd/dispatcher_test.py +++ b/tests/frontends/mpd/dispatcher_test.py @@ -27,7 +27,7 @@ class MpdDispatcherTest(unittest.TestCase): def test_finding_handler_for_unknown_command_raises_exception(self): try: - self.dispatcher.find_handler('an_unknown_command with args') + self.dispatcher._find_handler('an_unknown_command with args') self.fail('Should raise exception') except MpdAckError as e: self.assertEqual(e.get_mpd_ack(), @@ -37,7 +37,7 @@ class MpdDispatcherTest(unittest.TestCase): expected_handler = lambda x: None request_handlers['known_command (?P.+)'] = \ expected_handler - (handler, kwargs) = self.dispatcher.find_handler('known_command an_arg') + (handler, kwargs) = self.dispatcher._find_handler('known_command an_arg') self.assertEqual(handler, expected_handler) self.assert_('arg1' in kwargs) self.assertEqual(kwargs['arg1'], 'an_arg') From 4036b7dd8ebec61b8f3c9b950a359df1cba17e02 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 3 Jun 2011 22:54:19 +0200 Subject: [PATCH 117/218] Add missing inheritance diagrams for the MPD docs --- docs/modules/frontends/mpd.rst | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/modules/frontends/mpd.rst b/docs/modules/frontends/mpd.rst index 35128e70..6120c2a6 100644 --- a/docs/modules/frontends/mpd.rst +++ b/docs/modules/frontends/mpd.rst @@ -2,6 +2,8 @@ :mod:`mopidy.frontends.mpd` -- MPD server ***************************************** +.. inheritance-diagram:: mopidy.frontends.mpd + .. automodule:: mopidy.frontends.mpd :synopsis: MPD frontend :members: @@ -11,28 +13,30 @@ MPD server ========== +.. inheritance-diagram:: mopidy.frontends.mpd.server + .. automodule:: mopidy.frontends.mpd.server :synopsis: MPD server :members: :undoc-members: -.. inheritance-diagram:: mopidy.frontends.mpd.server - MPD session =========== +.. inheritance-diagram:: mopidy.frontends.mpd.session + .. automodule:: mopidy.frontends.mpd.session :synopsis: MPD client session :members: :undoc-members: -.. inheritance-diagram:: mopidy.frontends.mpd.session - MPD dispatcher ============== +.. inheritance-diagram:: mopidy.frontends.mpd.dispatcher + .. automodule:: mopidy.frontends.mpd.dispatcher :synopsis: MPD request dispatcher :members: From c84639b1977f47aaee6bf6970102c4c50d4bbc32 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 3 Jun 2011 23:25:00 +0200 Subject: [PATCH 118/218] Add MpdPermissionError exception --- mopidy/frontends/mpd/exceptions.py | 6 ++++++ tests/frontends/mpd/exception_test.py | 11 +++++++++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/mopidy/frontends/mpd/exceptions.py b/mopidy/frontends/mpd/exceptions.py index df90aed7..c6020658 100644 --- a/mopidy/frontends/mpd/exceptions.py +++ b/mopidy/frontends/mpd/exceptions.py @@ -42,6 +42,12 @@ class MpdPasswordError(MpdAckError): super(MpdPasswordError, self).__init__(*args, **kwargs) self.error_code = MpdAckError.ACK_ERROR_PASSWORD +class MpdPermissionError(MpdAckError): + def __init__(self, *args, **kwargs): + super(MpdPermissionError, self).__init__(*args, **kwargs) + self.message = u'you don\'t have permission for "%s"' % self.command + self.error_code = MpdAckError.ACK_ERROR_PERMISSION + class MpdUnknownCommand(MpdAckError): def __init__(self, *args, **kwargs): super(MpdUnknownCommand, self).__init__(*args, **kwargs) diff --git a/tests/frontends/mpd/exception_test.py b/tests/frontends/mpd/exception_test.py index 9b1b47a2..6750189b 100644 --- a/tests/frontends/mpd/exception_test.py +++ b/tests/frontends/mpd/exception_test.py @@ -1,7 +1,7 @@ import unittest -from mopidy.frontends.mpd.exceptions import (MpdAckError, MpdUnknownCommand, - MpdSystemError, MpdNotImplemented) +from mopidy.frontends.mpd.exceptions import (MpdAckError, MpdPermissionError, + MpdUnknownCommand, MpdSystemError, MpdNotImplemented) class MpdExceptionsTest(unittest.TestCase): def test_key_error_wrapped_in_mpd_ack_error(self): @@ -43,3 +43,10 @@ class MpdExceptionsTest(unittest.TestCase): except MpdSystemError as e: self.assertEqual(e.get_mpd_ack(), u'ACK [52@0] {} foo') + + def test_mpd_permission_error(self): + try: + raise MpdPermissionError(command='foo') + except MpdPermissionError as e: + self.assertEqual(e.get_mpd_ack(), + u'ACK [4@0] {foo} you don\'t have permission for "foo"') From 7f7d79b21fa0721b7b645c957c35c0f891bfe36d Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 3 Jun 2011 23:31:52 +0200 Subject: [PATCH 119/218] Return permission error on use of MPD command 'kill' --- mopidy/frontends/mpd/protocol/connection.py | 7 +++---- tests/frontends/mpd/connection_test.py | 2 +- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/mopidy/frontends/mpd/protocol/connection.py b/mopidy/frontends/mpd/protocol/connection.py index 2b030eb0..5722a83c 100644 --- a/mopidy/frontends/mpd/protocol/connection.py +++ b/mopidy/frontends/mpd/protocol/connection.py @@ -1,6 +1,7 @@ from mopidy import settings from mopidy.frontends.mpd.protocol import handle_pattern -from mopidy.frontends.mpd.exceptions import MpdPasswordError +from mopidy.frontends.mpd.exceptions import (MpdPasswordError, + MpdPermissionError) @handle_pattern(r'^close$') def close(context): @@ -22,9 +23,7 @@ def kill(context): Kills MPD. """ - # TODO We do not want to allow remote killing of Mopidy. We should throw an - # MPD exception here. Maybe using ACK_ERROR_PERMISSION. - pass + raise MpdPermissionError(command=u'kill') @handle_pattern(r'^password "(?P[^"]+)"$') def password_(context, password): diff --git a/tests/frontends/mpd/connection_test.py b/tests/frontends/mpd/connection_test.py index 84794257..bc995a5e 100644 --- a/tests/frontends/mpd/connection_test.py +++ b/tests/frontends/mpd/connection_test.py @@ -31,7 +31,7 @@ class ConnectionHandlerTest(unittest.TestCase): def test_kill(self): result = self.dispatcher.handle_request(u'kill') - self.assert_(u'OK' in result) + self.assert_(u'ACK [4@0] {kill} you don\'t have permission for "kill"' in result) def test_valid_password_is_accepted(self): settings.MPD_SERVER_PASSWORD = u'topsecret' From c9506ca7e1d37fcc9f6f0743ab2b7941cd7afdd1 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 3 Jun 2011 23:32:19 +0200 Subject: [PATCH 120/218] List 'kill' MPD command in 'notcommands' instead of 'commands' --- mopidy/frontends/mpd/protocol/reflection.py | 11 ++++++++++- tests/frontends/mpd/reflection_test.py | 7 +++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/mopidy/frontends/mpd/protocol/reflection.py b/mopidy/frontends/mpd/protocol/reflection.py index 181dce54..2b319c5e 100644 --- a/mopidy/frontends/mpd/protocol/reflection.py +++ b/mopidy/frontends/mpd/protocol/reflection.py @@ -17,6 +17,9 @@ def commands(context): sorted_commands = sorted(list(mpd_commands)) + # No permission to use + sorted_commands.remove('kill') + # Not shown by MPD in its command list sorted_commands.remove('command_list_begin') sorted_commands.remove('command_list_ok_begin') @@ -59,7 +62,13 @@ def notcommands(context): # authenticated, 'notcommands' should list all the commands the client does # not have access to. To implement this we need access to the session # object to check if the client is authenticated or not. - pass + + commands = [] + + # No permission to use + commands.append('kill') + + return [('command', c) for c in sorted(commands)] @handle_pattern(r'^tagtypes$') def tagtypes(context): diff --git a/tests/frontends/mpd/reflection_test.py b/tests/frontends/mpd/reflection_test.py index c5cde1bb..adc34338 100644 --- a/tests/frontends/mpd/reflection_test.py +++ b/tests/frontends/mpd/reflection_test.py @@ -20,6 +20,8 @@ class ReflectionHandlerTest(unittest.TestCase): self.assert_(u'command: commands' in result) self.assert_(u'command: play' in result) self.assert_(u'command: status' in result) + # Check if commands you do not have access to are not present + self.assert_(u'command: kill' not in result) # Check if the blacklisted commands are not present self.assert_(u'command: command_list_begin' not in result) self.assert_(u'command: command_list_ok_begin' not in result) @@ -33,9 +35,10 @@ class ReflectionHandlerTest(unittest.TestCase): result = self.dispatcher.handle_request(u'decoders') self.assert_(u'ACK [0@0] {} Not implemented' in result) - def test_notcommands_returns_only_ok(self): + def test_notcommands_returns_only_kill_and_ok(self): result = self.dispatcher.handle_request(u'notcommands') - self.assertEqual(1, len(result)) + self.assertEqual(2, len(result)) + self.assert_(u'command: kill' in result) self.assert_(u'OK' in result) def test_tagtypes(self): From 6b71a7acb5680f7641034104e6197becfcd26197 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 3 Jun 2011 23:39:34 +0200 Subject: [PATCH 121/218] Update changelog with MPD improvements --- docs/changes.rst | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/changes.rst b/docs/changes.rst index b9df87df..37ce22c1 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -29,6 +29,17 @@ 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. +- MPD frontend: + + - Refactoring and cleanup. Most notably, all request handlers now get an + instance of :class:`mopidy.frontends.mpd.dispatcher.MpdContext` as the + first argument. The new class contains reference to any object in Mopidy + the MPD protocol implementation should need access to. + + - Close the client connection when the command ``close`` is received. + + - Do not allow access to the command ``kill``. + v0.4.1 (2011-05-06) =================== From bf175a3dce0a194677190e194667ad17acacf5f7 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 3 Jun 2011 23:58:24 +0200 Subject: [PATCH 122/218] Rename one letter variable in tests --- tests/frontends/mpd/status_test.py | 46 +++++++++++++++--------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/tests/frontends/mpd/status_test.py b/tests/frontends/mpd/status_test.py index 7fa9d8de..a7ed921f 100644 --- a/tests/frontends/mpd/status_test.py +++ b/tests/frontends/mpd/status_test.py @@ -13,13 +13,13 @@ STOPPED = PlaybackController.STOPPED class StatusHandlerTest(unittest.TestCase): def setUp(self): - self.b = DummyBackend.start().proxy() + self.backend = DummyBackend.start().proxy() self.mixer = DummyMixer.start().proxy() self.dispatcher = MpdDispatcher() self.context = self.dispatcher.context def tearDown(self): - self.b.stop().get() + self.backend.stop().get() self.mixer.stop().get() def test_clearerror(self): @@ -28,8 +28,8 @@ class StatusHandlerTest(unittest.TestCase): def test_currentsong(self): track = Track() - self.b.current_playlist.append([track]) - self.b.playback.play() + self.backend.current_playlist.append([track]) + self.backend.playback.play() result = self.dispatcher.handle_request(u'currentsong') self.assert_(u'file: ' in result) self.assert_(u'Time: 0' in result) @@ -100,7 +100,7 @@ class StatusHandlerTest(unittest.TestCase): self.assertEqual(int(result['repeat']), 0) def test_status_method_contains_repeat_is_1(self): - self.b.playback.repeat = 1 + self.backend.playback.repeat = 1 result = dict(status.status(self.context)) self.assert_('repeat' in result) self.assertEqual(int(result['repeat']), 1) @@ -111,7 +111,7 @@ class StatusHandlerTest(unittest.TestCase): self.assertEqual(int(result['random']), 0) def test_status_method_contains_random_is_1(self): - self.b.playback.random = 1 + self.backend.playback.random = 1 result = dict(status.status(self.context)) self.assert_('random' in result) self.assertEqual(int(result['random']), 1) @@ -127,7 +127,7 @@ class StatusHandlerTest(unittest.TestCase): self.assertEqual(int(result['consume']), 0) def test_status_method_contains_consume_is_1(self): - self.b.playback.consume = 1 + self.backend.playback.consume = 1 result = dict(status.status(self.context)) self.assert_('consume' in result) self.assertEqual(int(result['consume']), 1) @@ -148,41 +148,41 @@ class StatusHandlerTest(unittest.TestCase): self.assert_(int(result['xfade']) >= 0) def test_status_method_contains_state_is_play(self): - self.b.playback.state = PLAYING + self.backend.playback.state = PLAYING result = dict(status.status(self.context)) self.assert_('state' in result) self.assertEqual(result['state'], 'play') def test_status_method_contains_state_is_stop(self): - self.b.playback.state = STOPPED + self.backend.playback.state = STOPPED result = dict(status.status(self.context)) self.assert_('state' in result) self.assertEqual(result['state'], 'stop') def test_status_method_contains_state_is_pause(self): - self.b.playback.state = PLAYING - self.b.playback.state = PAUSED + self.backend.playback.state = PLAYING + self.backend.playback.state = PAUSED result = dict(status.status(self.context)) self.assert_('state' in result) self.assertEqual(result['state'], 'pause') def test_status_method_when_playlist_loaded_contains_song(self): - self.b.current_playlist.append([Track()]) - self.b.playback.play() + self.backend.current_playlist.append([Track()]) + self.backend.playback.play() result = dict(status.status(self.context)) self.assert_('song' in result) self.assert_(int(result['song']) >= 0) def test_status_method_when_playlist_loaded_contains_cpid_as_songid(self): - self.b.current_playlist.append([Track()]) - self.b.playback.play() + self.backend.current_playlist.append([Track()]) + self.backend.playback.play() result = dict(status.status(self.context)) self.assert_('songid' in result) self.assertEqual(int(result['songid']), 0) def test_status_method_when_playing_contains_time_with_no_length(self): - self.b.current_playlist.append([Track(length=None)]) - self.b.playback.play() + self.backend.current_playlist.append([Track(length=None)]) + self.backend.playback.play() result = dict(status.status(self.context)) self.assert_('time' in result) (position, total) = result['time'].split(':') @@ -191,8 +191,8 @@ class StatusHandlerTest(unittest.TestCase): self.assert_(position <= total) def test_status_method_when_playing_contains_time_with_length(self): - self.b.current_playlist.append([Track(length=10000)]) - self.b.playback.play() + self.backend.current_playlist.append([Track(length=10000)]) + self.backend.playback.play() result = dict(status.status(self.context)) self.assert_('time' in result) (position, total) = result['time'].split(':') @@ -201,15 +201,15 @@ class StatusHandlerTest(unittest.TestCase): self.assert_(position <= total) def test_status_method_when_playing_contains_elapsed(self): - self.b.playback.state = PAUSED - self.b.playback.play_time_accumulated = 59123 + self.backend.playback.state = PAUSED + self.backend.playback.play_time_accumulated = 59123 result = dict(status.status(self.context)) self.assert_('elapsed' in result) self.assertEqual(int(result['elapsed']), 59123) def test_status_method_when_playing_contains_bitrate(self): - self.b.current_playlist.append([Track(bitrate=320)]) - self.b.playback.play() + self.backend.current_playlist.append([Track(bitrate=320)]) + self.backend.playback.play() result = dict(status.status(self.context)) self.assert_('bitrate' in result) self.assertEqual(int(result['bitrate']), 320) From b6c196b8abf7d25ed3bd1a0b55afbff715ca48a0 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 4 Jun 2011 00:13:40 +0200 Subject: [PATCH 123/218] 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 124/218] 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 125/218] 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 126/218] 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 127/218] 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 1db84dcccada3e696e8974ed1762c0ab6b5c9a6c Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 4 Jun 2011 00:33:57 +0200 Subject: [PATCH 128/218] Refactor MpdDispatcher.handle_request --- mopidy/frontends/mpd/dispatcher.py | 30 +++++++++++-------- mopidy/frontends/mpd/protocol/command_list.py | 4 +-- tests/frontends/mpd/command_list_test.py | 4 +-- 3 files changed, 22 insertions(+), 16 deletions(-) diff --git a/mopidy/frontends/mpd/dispatcher.py b/mopidy/frontends/mpd/dispatcher.py index 7b322419..8a28714a 100644 --- a/mopidy/frontends/mpd/dispatcher.py +++ b/mopidy/frontends/mpd/dispatcher.py @@ -32,27 +32,33 @@ class MpdDispatcher(object): self.command_list_ok = False self.context = MpdContext(self, session=session) - def handle_request(self, request, command_list_index=None): + def handle_request(self, request, current_command_list_index=None): """Dispatch incoming requests to the correct handler.""" - if self.command_list is not False and request != u'command_list_end': + if self._is_receiving_command_list(request): self.command_list.append(request) return None + try: - result = self._call_handler(request) + try: + result = self._call_handler(request) + except ActorDeadError as e: + logger.warning(u'Tried to communicate with dead actor.') + raise MpdSystemError(e.message) except MpdAckError as e: - if command_list_index is not None: - e.index = command_list_index + if current_command_list_index is not None: + e.index = current_command_list_index return self._format_response(e.get_mpd_ack(), add_ok=False) - except ActorDeadError as e: - logger.warning(u'Tried to communicate with dead actor.') - mpd_error = MpdSystemError(e.message) - return self._format_response(mpd_error.get_mpd_ack(), add_ok=False) - if request in (u'command_list_begin', u'command_list_ok_begin'): - return None - if command_list_index is not None: + + if (request in (u'command_list_begin', u'command_list_ok_begin') + or current_command_list_index is not None): return self._format_response(result, add_ok=False) + return self._format_response(result) + def _is_receiving_command_list(self, request): + return (self.command_list is not False + and request != u'command_list_end') + def _call_handler(self, request): (handler, kwargs) = self._find_handler(request) return handler(self.context, **kwargs) diff --git a/mopidy/frontends/mpd/protocol/command_list.py b/mopidy/frontends/mpd/protocol/command_list.py index 78fccec6..ffdf58d7 100644 --- a/mopidy/frontends/mpd/protocol/command_list.py +++ b/mopidy/frontends/mpd/protocol/command_list.py @@ -32,9 +32,9 @@ def command_list_end(context): (command_list_ok, context.dispatcher.command_list_ok) = ( context.dispatcher.command_list_ok, False) result = [] - for i, command in enumerate(command_list): + for index, command in enumerate(command_list): response = context.dispatcher.handle_request( - command, command_list_index=i) + command, current_command_list_index=index) if response is not None: result.append(response) if response and response[-1].startswith(u'ACK'): diff --git a/tests/frontends/mpd/command_list_test.py b/tests/frontends/mpd/command_list_test.py index 3537ee77..542d1265 100644 --- a/tests/frontends/mpd/command_list_test.py +++ b/tests/frontends/mpd/command_list_test.py @@ -16,7 +16,7 @@ class CommandListsTest(unittest.TestCase): def test_command_list_begin(self): result = self.dispatcher.handle_request(u'command_list_begin') - self.assert_(result is None) + self.assertEquals(result, []) def test_command_list_end(self): self.dispatcher.handle_request(u'command_list_begin') @@ -47,7 +47,7 @@ class CommandListsTest(unittest.TestCase): def test_command_list_ok_begin(self): result = self.dispatcher.handle_request(u'command_list_ok_begin') - self.assert_(result is None) + self.assertEquals(result, []) def test_command_list_ok_with_ping(self): self.dispatcher.handle_request(u'command_list_ok_begin') From a68bdae7511ef319db0f6103d426d769837a31a9 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 4 Jun 2011 00:42:51 +0200 Subject: [PATCH 129/218] Move MPD authentication check from MpdSession to MpdDispatcher --- mopidy/frontends/mpd/dispatcher.py | 30 +++++++++++ mopidy/frontends/mpd/session.py | 28 ---------- tests/frontends/mpd/authentication_test.py | 63 ++++++++++++++++++++++ tests/frontends/mpd/server_test.py | 49 ----------------- 4 files changed, 93 insertions(+), 77 deletions(-) create mode 100644 tests/frontends/mpd/authentication_test.py diff --git a/mopidy/frontends/mpd/dispatcher.py b/mopidy/frontends/mpd/dispatcher.py index 8a28714a..5365c8bd 100644 --- a/mopidy/frontends/mpd/dispatcher.py +++ b/mopidy/frontends/mpd/dispatcher.py @@ -4,6 +4,7 @@ import re from pykka import ActorDeadError from pykka.registry import ActorRegistry +from mopidy import settings from mopidy.backends.base import Backend from mopidy.frontends.mpd.exceptions import (MpdAckError, MpdArgError, MpdUnknownCommand, MpdSystemError) @@ -28,12 +29,18 @@ class MpdDispatcher(object): """ def __init__(self, session=None): + self.authenticated = False self.command_list = False self.command_list_ok = False self.context = MpdContext(self, session=session) def handle_request(self, request, current_command_list_index=None): """Dispatch incoming requests to the correct handler.""" + if not self.authenticated: + (self.authenticated, result) = self._check_password(request) + if result: + return result + if self._is_receiving_command_list(request): self.command_list.append(request) return None @@ -55,6 +62,29 @@ class MpdDispatcher(object): return self._format_response(result) + def _check_password(self, request): + """ + Takes any request and tries to authenticate the client using it. + + :rtype: a two-tuple containing (is_authenticated, response_message). If + the response_message is :class:`None`, normal processing should + continue, even though the client may not be authenticated. + """ + if settings.MPD_SERVER_PASSWORD is None: + return (True, None) + command = request.split(' ')[0] + if command == 'password': + if request == 'password "%s"' % settings.MPD_SERVER_PASSWORD: + return (True, [u'OK']) + else: + return (False, [u'ACK [3@0] {password} incorrect password']) + if command in ('close', 'commands', 'notcommands', 'ping'): + return (False, None) + else: + return (False, + [u'ACK [4@0] {%(c)s} you don\'t have permission for "%(c)s"' % + {'c': command}]) + def _is_receiving_command_list(self, request): return (self.command_list is not False and request != u'command_list_end') diff --git a/mopidy/frontends/mpd/session.py b/mopidy/frontends/mpd/session.py index 4ac09a68..42058eb7 100644 --- a/mopidy/frontends/mpd/session.py +++ b/mopidy/frontends/mpd/session.py @@ -46,11 +46,6 @@ class MpdSession(asynchat.async_chat): def handle_request(self, request): """Handle request using the MPD command handlers.""" - if not self.authenticated: - (self.authenticated, response) = self.check_password(request) - if response is not None: - self.send_response(response) - return response = self.dispatcher.handle_request(request) if response is not None: self.handle_response(response) @@ -66,26 +61,3 @@ class MpdSession(asynchat.async_chat): output = u'%s%s' % (output, LINE_TERMINATOR) data = output.encode(ENCODING) self.push(data) - - def check_password(self, request): - """ - Takes any request and tries to authenticate the client using it. - - :rtype: a two-tuple containing (is_authenticated, response_message). If - the response_message is :class:`None`, normal processing should - continue, even though the client may not be authenticated. - """ - if settings.MPD_SERVER_PASSWORD is None: - return (True, None) - command = request.split(' ')[0] - if command == 'password': - if request == 'password "%s"' % settings.MPD_SERVER_PASSWORD: - return (True, u'OK') - else: - return (False, u'ACK [3@0] {password} incorrect password') - if command in ('close', 'commands', 'notcommands', 'ping'): - return (False, None) - else: - return (False, - u'ACK [4@0] {%(c)s} you don\'t have permission for "%(c)s"' % - {'c': command}) diff --git a/tests/frontends/mpd/authentication_test.py b/tests/frontends/mpd/authentication_test.py new file mode 100644 index 00000000..d795d726 --- /dev/null +++ b/tests/frontends/mpd/authentication_test.py @@ -0,0 +1,63 @@ +import mock +import unittest + +from mopidy import settings +from mopidy.frontends.mpd.dispatcher import MpdDispatcher +from mopidy.frontends.mpd.session import MpdSession + +class AuthenticationTest(unittest.TestCase): + def setUp(self): + self.session = mock.Mock(spec=MpdSession) + self.dispatcher = MpdDispatcher(session=self.session) + + def tearDown(self): + settings.runtime.clear() + + def test_authentication_with_valid_password_is_accepted(self): + settings.MPD_SERVER_PASSWORD = u'topsecret' + response = self.dispatcher.handle_request(u'password "topsecret"') + self.assertTrue(self.dispatcher.authenticated) + self.assert_(u'OK' in response) + + def test_authentication_with_invalid_password_is_not_accepted(self): + settings.MPD_SERVER_PASSWORD = u'topsecret' + response = self.dispatcher.handle_request(u'password "secret"') + self.assertFalse(self.dispatcher.authenticated) + self.assert_(u'ACK [3@0] {password} incorrect password' in response) + + def test_authentication_with_anything_when_password_check_turned_off(self): + settings.MPD_SERVER_PASSWORD = None + response = self.dispatcher.handle_request(u'any request at all') + self.assertTrue(self.dispatcher.authenticated) + self.assert_('ACK [5@0] {} unknown command "any"' in response) + + def test_anything_when_not_authenticated_should_fail(self): + settings.MPD_SERVER_PASSWORD = u'topsecret' + response = self.dispatcher.handle_request(u'any request at all') + self.assertFalse(self.dispatcher.authenticated) + self.assert_( + u'ACK [4@0] {any} you don\'t have permission for "any"' in response) + + def test_close_is_allowed_without_authentication(self): + settings.MPD_SERVER_PASSWORD = u'topsecret' + response = self.dispatcher.handle_request(u'close') + self.assertFalse(self.dispatcher.authenticated) + self.assert_(u'OK' in response) + + def test_commands_is_allowed_without_authentication(self): + settings.MPD_SERVER_PASSWORD = u'topsecret' + response = self.dispatcher.handle_request(u'commands') + self.assertFalse(self.dispatcher.authenticated) + self.assert_(u'OK' in response) + + def test_notcommands_is_allowed_without_authentication(self): + settings.MPD_SERVER_PASSWORD = u'topsecret' + response = self.dispatcher.handle_request(u'notcommands') + self.assertFalse(self.dispatcher.authenticated) + self.assert_(u'OK' in response) + + def test_ping_is_allowed_without_authentication(self): + settings.MPD_SERVER_PASSWORD = u'topsecret' + response = self.dispatcher.handle_request(u'ping') + self.assertFalse(self.dispatcher.authenticated) + self.assert_(u'OK' in response) diff --git a/tests/frontends/mpd/server_test.py b/tests/frontends/mpd/server_test.py index 32e90450..ee363aea 100644 --- a/tests/frontends/mpd/server_test.py +++ b/tests/frontends/mpd/server_test.py @@ -44,52 +44,3 @@ class MpdSessionTest(unittest.TestCase): self.session.input_buffer = ['\xff'] self.session.found_terminator() self.assertEqual(len(self.session.input_buffer), 0) - - def test_authentication_with_valid_password_is_accepted(self): - settings.MPD_SERVER_PASSWORD = u'topsecret' - authed, response = self.session.check_password(u'password "topsecret"') - self.assertTrue(authed) - self.assertEqual(u'OK', response) - - def test_authentication_with_invalid_password_is_not_accepted(self): - settings.MPD_SERVER_PASSWORD = u'topsecret' - authed, response = self.session.check_password(u'password "secret"') - self.assertFalse(authed) - self.assertEqual(u'ACK [3@0] {password} incorrect password', response) - - def test_authentication_with_anything_when_password_check_turned_off(self): - settings.MPD_SERVER_PASSWORD = None - authed, response = self.session.check_password(u'any request at all') - self.assertTrue(authed) - self.assertEqual(None, response) - - def test_anything_when_not_authenticated_should_fail(self): - settings.MPD_SERVER_PASSWORD = u'topsecret' - authed, response = self.session.check_password(u'any request at all') - self.assertFalse(authed) - self.assertEqual( - u'ACK [4@0] {any} you don\'t have permission for "any"', response) - - def test_close_is_allowed_without_authentication(self): - settings.MPD_SERVER_PASSWORD = u'topsecret' - authed, response = self.session.check_password(u'close') - self.assertFalse(authed) - self.assertEqual(None, response) - - def test_commands_is_allowed_without_authentication(self): - settings.MPD_SERVER_PASSWORD = u'topsecret' - authed, response = self.session.check_password(u'commands') - self.assertFalse(authed) - self.assertEqual(None, response) - - def test_notcommands_is_allowed_without_authentication(self): - settings.MPD_SERVER_PASSWORD = u'topsecret' - authed, response = self.session.check_password(u'notcommands') - self.assertFalse(authed) - self.assertEqual(None, response) - - def test_ping_is_allowed_without_authentication(self): - settings.MPD_SERVER_PASSWORD = u'topsecret' - authed, response = self.session.check_password(u'ping') - self.assertFalse(authed) - self.assertEqual(None, response) From 3fe276f32a2d9e42d1e3974d9ec0e5af5b4969c7 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 4 Jun 2011 00:54:02 +0200 Subject: [PATCH 130/218] Refactor MpdSession --- mopidy/frontends/mpd/session.py | 42 +++++++++++++++------------------ 1 file changed, 19 insertions(+), 23 deletions(-) diff --git a/mopidy/frontends/mpd/session.py b/mopidy/frontends/mpd/session.py index 42058eb7..7226e0eb 100644 --- a/mopidy/frontends/mpd/session.py +++ b/mopidy/frontends/mpd/session.py @@ -25,39 +25,35 @@ class MpdSession(asynchat.async_chat): self.dispatcher = MpdDispatcher(session=self) def start(self): - """Start a new client session.""" - self.send_response(u'OK MPD %s' % VERSION) + """Called by asynchat when a new client connects.""" + self.send_response([u'OK MPD %s' % VERSION]) def collect_incoming_data(self, data): - """Collect incoming data into buffer until a terminator is found.""" + """Called by asynchat when new data arrives.""" self.input_buffer.append(data) def found_terminator(self): - """Handle request when a terminator is found.""" + """Called by asynchat when a terminator is found in incoming data.""" data = ''.join(self.input_buffer).strip() self.input_buffer = [] try: - request = data.decode(ENCODING) - logger.debug(u'Input from [%s]:%s: %s', self.client_address, - self.client_port, indent(request)) - self.handle_request(request) + self.send_response(self.handle_request(data)) except UnicodeDecodeError as e: logger.warning(u'Received invalid data: %s', e) def handle_request(self, request): - """Handle request using the MPD command handlers.""" - response = self.dispatcher.handle_request(request) + """Handle the request using the MPD command handlers.""" + request = request.decode(ENCODING) + logger.debug(u'Request from [%s]:%s: %s', self.client_address, + self.client_port, indent(request)) + return self.dispatcher.handle_request(request) + + def send_response(self, response): + """Format a response from the MPD command handlers and send it to the client.""" if response is not None: - self.handle_response(response) - - def handle_response(self, response): - """Handle response from the MPD command handlers.""" - self.send_response(LINE_TERMINATOR.join(response)) - - def send_response(self, output): - """Send a response to the client.""" - logger.debug(u'Output to [%s]:%s: %s', self.client_address, - self.client_port, indent(output)) - output = u'%s%s' % (output, LINE_TERMINATOR) - data = output.encode(ENCODING) - self.push(data) + response = LINE_TERMINATOR.join(response) + logger.debug(u'Response to [%s]:%s: %s', self.client_address, + self.client_port, indent(response)) + response = u'%s%s' % (response, LINE_TERMINATOR) + data = response.encode(ENCODING) + self.push(data) From 503c98b98ee99196088d7e0f88dab7e7d5d98165 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 4 Jun 2011 01:29:28 +0200 Subject: [PATCH 131/218] 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 132/218] 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 133/218] 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 08f085fd8d36082870bdde1cef7b593f9ebf4137 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 4 Jun 2011 02:21:14 +0200 Subject: [PATCH 134/218] Refactor MpdDispatcher to use a filter model, like Java Servlets. Password authentication handling becomes much cleaner. --- mopidy/frontends/mpd/dispatcher.py | 132 ++++++++++-------- mopidy/frontends/mpd/protocol/command_list.py | 13 +- mopidy/frontends/mpd/protocol/connection.py | 7 +- tests/frontends/mpd/authentication_test.py | 2 +- tests/frontends/mpd/command_list_test.py | 1 + 5 files changed, 88 insertions(+), 67 deletions(-) diff --git a/mopidy/frontends/mpd/dispatcher.py b/mopidy/frontends/mpd/dispatcher.py index 5365c8bd..05ae976d 100644 --- a/mopidy/frontends/mpd/dispatcher.py +++ b/mopidy/frontends/mpd/dispatcher.py @@ -7,7 +7,7 @@ from pykka.registry import ActorRegistry from mopidy import settings from mopidy.backends.base import Backend from mopidy.frontends.mpd.exceptions import (MpdAckError, MpdArgError, - MpdUnknownCommand, MpdSystemError) + MpdPermissionError, MpdPasswordError, MpdSystemError, MpdUnknownCommand) from mopidy.frontends.mpd.protocol import mpd_commands, request_handlers # Do not remove the following import. The protocol modules must be imported to # get them registered as request handlers. @@ -32,63 +32,82 @@ class MpdDispatcher(object): self.authenticated = False self.command_list = False self.command_list_ok = False + self.command_list_index = None self.context = MpdContext(self, session=session) def handle_request(self, request, current_command_list_index=None): """Dispatch incoming requests to the correct handler.""" - if not self.authenticated: - (self.authenticated, result) = self._check_password(request) - if result: - return result + self.command_list_index = current_command_list_index + response = [] + filter_chain = [ + self._catch_mpd_ack_errors_filter, + self._authenticate_filter, + self._command_list_filter, + self._add_ok_filter, + self._call_handler_filter, + ] + return self._call_next_filter(request, response, filter_chain) + + def _catch_mpd_ack_errors_filter(self, request, response, filter_chain): + try: + return self._call_next_filter(request, response, filter_chain) + except MpdAckError as mpd_ack_error: + if self.command_list_index is not None: + mpd_ack_error.index = self.command_list_index + return [mpd_ack_error.get_mpd_ack()] + + + def _authenticate_filter(self, request, response, filter_chain): + if self.authenticated or settings.MPD_SERVER_PASSWORD is None: + return self._call_next_filter(request, response, filter_chain) + else: + command = request.split(' ')[0] + if command in ('close', 'commands', 'notcommands', 'password', 'ping'): + return self._call_next_filter(request, response, filter_chain) + else: + raise MpdPermissionError(command=command) + + + def _command_list_filter(self, request, response, filter_chain): if self._is_receiving_command_list(request): self.command_list.append(request) - return None - - try: - try: - result = self._call_handler(request) - except ActorDeadError as e: - logger.warning(u'Tried to communicate with dead actor.') - raise MpdSystemError(e.message) - except MpdAckError as e: - if current_command_list_index is not None: - e.index = current_command_list_index - return self._format_response(e.get_mpd_ack(), add_ok=False) - - if (request in (u'command_list_begin', u'command_list_ok_begin') - or current_command_list_index is not None): - return self._format_response(result, add_ok=False) - - return self._format_response(result) - - def _check_password(self, request): - """ - Takes any request and tries to authenticate the client using it. - - :rtype: a two-tuple containing (is_authenticated, response_message). If - the response_message is :class:`None`, normal processing should - continue, even though the client may not be authenticated. - """ - if settings.MPD_SERVER_PASSWORD is None: - return (True, None) - command = request.split(' ')[0] - if command == 'password': - if request == 'password "%s"' % settings.MPD_SERVER_PASSWORD: - return (True, [u'OK']) - else: - return (False, [u'ACK [3@0] {password} incorrect password']) - if command in ('close', 'commands', 'notcommands', 'ping'): - return (False, None) + return [] else: - return (False, - [u'ACK [4@0] {%(c)s} you don\'t have permission for "%(c)s"' % - {'c': command}]) + response = self._call_next_filter(request, response, filter_chain) + if (self._is_receiving_command_list(request) or + self._is_processing_command_list(request)): + if response and response[-1] == u'OK': + response = response[:-1] + return response def _is_receiving_command_list(self, request): return (self.command_list is not False and request != u'command_list_end') + def _is_processing_command_list(self, request): + return (self.command_list_index is not None + and request != u'command_list_end') + + + def _add_ok_filter(self, request, response, filter_chain): + response = self._call_next_filter(request, response, filter_chain) + if not self._has_error(response): + response.append(u'OK') + return response + + def _has_error(self, response): + return response and response[-1].startswith(u'ACK') + + + def _call_handler_filter(self, request, response, filter_chain): + try: + response = self._format_response(self._call_handler(request)) + return self._call_next_filter(request, response, filter_chain) + except ActorDeadError as e: + logger.warning(u'Tried to communicate with dead actor.') + raise MpdSystemError(e.message) + def _call_handler(self, request): (handler, kwargs) = self._find_handler(request) return handler(self.context, **kwargs) @@ -103,13 +122,19 @@ class MpdDispatcher(object): raise MpdArgError(u'incorrect arguments', command=command) raise MpdUnknownCommand(command=command) - def _format_response(self, result, add_ok=True): - response = [] - for element in self._listify_result(result): - response.extend(self._format_lines(element)) - if add_ok and (not response or not self._has_error(response)): - response.append(u'OK') - return response + + def _call_next_filter(self, request, response, filter_chain): + if filter_chain: + next_filter = filter_chain.pop(0) + return next_filter(request, response, filter_chain) + else: + return response + + def _format_response(self, response): + formatted_response = [] + for element in self._listify_result(response): + formatted_response.extend(self._format_lines(element)) + return formatted_response def _listify_result(self, result): if result is None: @@ -128,9 +153,6 @@ class MpdDispatcher(object): return [u'%s: %s' % (key, value)] return [line] - def _has_error(self, response): - return bool(response) and response[-1].startswith(u'ACK') - class MpdContext(object): """ diff --git a/mopidy/frontends/mpd/protocol/command_list.py b/mopidy/frontends/mpd/protocol/command_list.py index ffdf58d7..a8a0c317 100644 --- a/mopidy/frontends/mpd/protocol/command_list.py +++ b/mopidy/frontends/mpd/protocol/command_list.py @@ -31,17 +31,16 @@ def command_list_end(context): context.dispatcher.command_list, False) (command_list_ok, context.dispatcher.command_list_ok) = ( context.dispatcher.command_list_ok, False) - result = [] + command_list_response = [] for index, command in enumerate(command_list): response = context.dispatcher.handle_request( command, current_command_list_index=index) - if response is not None: - result.append(response) - if response and response[-1].startswith(u'ACK'): - return result + command_list_response.extend(response) + if command_list_response and command_list_response[-1].startswith(u'ACK'): + return command_list_response if command_list_ok: - response.append(u'list_OK') - return result + command_list_response.append(u'list_OK') + return command_list_response @handle_pattern(r'^command_list_ok_begin$') def command_list_ok_begin(context): diff --git a/mopidy/frontends/mpd/protocol/connection.py b/mopidy/frontends/mpd/protocol/connection.py index 5722a83c..99944ac2 100644 --- a/mopidy/frontends/mpd/protocol/connection.py +++ b/mopidy/frontends/mpd/protocol/connection.py @@ -35,10 +35,9 @@ def password_(context, password): This is used for authentication with the server. ``PASSWORD`` is simply the plaintext password. """ - # You will not get to this code without being authenticated. This is for - # when you are already authenticated, and are sending additional 'password' - # requests. - if settings.MPD_SERVER_PASSWORD != password: + if password == settings.MPD_SERVER_PASSWORD: + context.dispatcher.authenticated = True + else: raise MpdPasswordError(u'incorrect password', command=u'password') @handle_pattern(r'^ping$') diff --git a/tests/frontends/mpd/authentication_test.py b/tests/frontends/mpd/authentication_test.py index d795d726..03fc5718 100644 --- a/tests/frontends/mpd/authentication_test.py +++ b/tests/frontends/mpd/authentication_test.py @@ -28,7 +28,7 @@ class AuthenticationTest(unittest.TestCase): def test_authentication_with_anything_when_password_check_turned_off(self): settings.MPD_SERVER_PASSWORD = None response = self.dispatcher.handle_request(u'any request at all') - self.assertTrue(self.dispatcher.authenticated) + self.assertFalse(self.dispatcher.authenticated) self.assert_('ACK [5@0] {} unknown command "any"' in response) def test_anything_when_not_authenticated_should_fail(self): diff --git a/tests/frontends/mpd/command_list_test.py b/tests/frontends/mpd/command_list_test.py index 542d1265..8fd4c828 100644 --- a/tests/frontends/mpd/command_list_test.py +++ b/tests/frontends/mpd/command_list_test.py @@ -43,6 +43,7 @@ class CommandListsTest(unittest.TestCase): self.dispatcher.handle_request(u'play') # Known command self.dispatcher.handle_request(u'paly') # Unknown command result = self.dispatcher.handle_request(u'command_list_end') + self.assertEqual(len(result), 1, result) self.assertEqual(result[0], u'ACK [5@1] {} unknown command "paly"') def test_command_list_ok_begin(self): From eba5cff9d10170e0dca63368e5b3ae2236fb6360 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 4 Jun 2011 02:21:42 +0200 Subject: [PATCH 135/218] 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 6d1bac0d72e0d2100d1d167bf50c7ff8febbc196 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 4 Jun 2011 17:06:17 +0200 Subject: [PATCH 136/218] Reorder methods and add comments to divide sections --- mopidy/frontends/mpd/dispatcher.py | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/mopidy/frontends/mpd/dispatcher.py b/mopidy/frontends/mpd/dispatcher.py index 05ae976d..65346275 100644 --- a/mopidy/frontends/mpd/dispatcher.py +++ b/mopidy/frontends/mpd/dispatcher.py @@ -48,6 +48,15 @@ class MpdDispatcher(object): ] return self._call_next_filter(request, response, filter_chain) + def _call_next_filter(self, request, response, filter_chain): + if filter_chain: + next_filter = filter_chain.pop(0) + return next_filter(request, response, filter_chain) + else: + return response + + + ### Filter: catch MPD ACK errors def _catch_mpd_ack_errors_filter(self, request, response, filter_chain): try: @@ -58,6 +67,8 @@ class MpdDispatcher(object): return [mpd_ack_error.get_mpd_ack()] + ### Filter: authenticate + def _authenticate_filter(self, request, response, filter_chain): if self.authenticated or settings.MPD_SERVER_PASSWORD is None: return self._call_next_filter(request, response, filter_chain) @@ -69,6 +80,8 @@ class MpdDispatcher(object): raise MpdPermissionError(command=command) + ### Filter: command list + def _command_list_filter(self, request, response, filter_chain): if self._is_receiving_command_list(request): self.command_list.append(request) @@ -90,6 +103,8 @@ class MpdDispatcher(object): and request != u'command_list_end') + ### Filter: add OK + def _add_ok_filter(self, request, response, filter_chain): response = self._call_next_filter(request, response, filter_chain) if not self._has_error(response): @@ -100,6 +115,8 @@ class MpdDispatcher(object): return response and response[-1].startswith(u'ACK') + ### Filter: call handler + def _call_handler_filter(self, request, response, filter_chain): try: response = self._format_response(self._call_handler(request)) @@ -122,14 +139,6 @@ class MpdDispatcher(object): raise MpdArgError(u'incorrect arguments', command=command) raise MpdUnknownCommand(command=command) - - def _call_next_filter(self, request, response, filter_chain): - if filter_chain: - next_filter = filter_chain.pop(0) - return next_filter(request, response, filter_chain) - else: - return response - def _format_response(self, response): formatted_response = [] for element in self._listify_result(response): From 939a8f40d6e680c67528d531c3fd7ae1905b6187 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 4 Jun 2011 17:18:46 +0200 Subject: [PATCH 137/218] No need for splitting init and start of MpdSession --- mopidy/frontends/mpd/server.py | 2 +- mopidy/frontends/mpd/session.py | 3 --- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/mopidy/frontends/mpd/server.py b/mopidy/frontends/mpd/server.py index 1be46ef4..20e0073f 100644 --- a/mopidy/frontends/mpd/server.py +++ b/mopidy/frontends/mpd/server.py @@ -60,7 +60,7 @@ class MpdServer(asyncore.dispatcher): (client_socket, client_socket_address) = self.accept() logger.info(u'MPD client connection from [%s]:%s', client_socket_address[0], client_socket_address[1]) - MpdSession(self, client_socket, client_socket_address).start() + MpdSession(self, client_socket, client_socket_address) def handle_close(self): """Handle end of client connection.""" diff --git a/mopidy/frontends/mpd/session.py b/mopidy/frontends/mpd/session.py index 7226e0eb..5a86dfdb 100644 --- a/mopidy/frontends/mpd/session.py +++ b/mopidy/frontends/mpd/session.py @@ -23,9 +23,6 @@ class MpdSession(asynchat.async_chat): self.authenticated = False self.set_terminator(LINE_TERMINATOR.encode(ENCODING)) self.dispatcher = MpdDispatcher(session=self) - - def start(self): - """Called by asynchat when a new client connects.""" self.send_response([u'OK MPD %s' % VERSION]) def collect_incoming_data(self, data): From 68a671414cd2777047df5567c66b8e0c0c1c847e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 4 Jun 2011 17:38:03 +0200 Subject: [PATCH 138/218] Update MpdServer's docstrings --- mopidy/frontends/mpd/server.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/frontends/mpd/server.py b/mopidy/frontends/mpd/server.py index 20e0073f..a2d9cefe 100644 --- a/mopidy/frontends/mpd/server.py +++ b/mopidy/frontends/mpd/server.py @@ -56,14 +56,14 @@ class MpdServer(asyncore.dispatcher): sys.exit(1) def handle_accept(self): - """Handle new client connection.""" + """Called by asyncore when a new client connects.""" (client_socket, client_socket_address) = self.accept() 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): - """Handle end of client connection.""" + """Called by asyncore when the socket is closed.""" self.close() def _format_hostname(self, hostname): From e6294ec8694a739b73ccc6fb5cc892bb3125021f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 4 Jun 2011 18:24:06 +0200 Subject: [PATCH 139/218] Fix pylint warnings in MPD frontend --- mopidy/frontends/mpd/dispatcher.py | 7 ++++--- mopidy/frontends/mpd/protocol/command_list.py | 3 ++- mopidy/frontends/mpd/server.py | 3 ++- mopidy/frontends/mpd/session.py | 6 ++++-- 4 files changed, 12 insertions(+), 7 deletions(-) diff --git a/mopidy/frontends/mpd/dispatcher.py b/mopidy/frontends/mpd/dispatcher.py index 65346275..996c3714 100644 --- a/mopidy/frontends/mpd/dispatcher.py +++ b/mopidy/frontends/mpd/dispatcher.py @@ -7,7 +7,7 @@ from pykka.registry import ActorRegistry from mopidy import settings from mopidy.backends.base import Backend from mopidy.frontends.mpd.exceptions import (MpdAckError, MpdArgError, - MpdPermissionError, MpdPasswordError, MpdSystemError, MpdUnknownCommand) + MpdPermissionError, MpdSystemError, MpdUnknownCommand) from mopidy.frontends.mpd.protocol import mpd_commands, request_handlers # Do not remove the following import. The protocol modules must be imported to # get them registered as request handlers. @@ -74,7 +74,8 @@ class MpdDispatcher(object): return self._call_next_filter(request, response, filter_chain) else: command = request.split(' ')[0] - if command in ('close', 'commands', 'notcommands', 'password', 'ping'): + if command in ( + 'close', 'commands', 'notcommands', 'password', 'ping'): return self._call_next_filter(request, response, filter_chain) else: raise MpdPermissionError(command=command) @@ -142,7 +143,7 @@ class MpdDispatcher(object): def _format_response(self, response): formatted_response = [] for element in self._listify_result(response): - formatted_response.extend(self._format_lines(element)) + formatted_response.extend(self._format_lines(element)) return formatted_response def _listify_result(self, result): diff --git a/mopidy/frontends/mpd/protocol/command_list.py b/mopidy/frontends/mpd/protocol/command_list.py index a8a0c317..f2c51578 100644 --- a/mopidy/frontends/mpd/protocol/command_list.py +++ b/mopidy/frontends/mpd/protocol/command_list.py @@ -36,7 +36,8 @@ def command_list_end(context): response = context.dispatcher.handle_request( command, current_command_list_index=index) command_list_response.extend(response) - if command_list_response and command_list_response[-1].startswith(u'ACK'): + if (command_list_response and + command_list_response[-1].startswith(u'ACK')): return command_list_response if command_list_ok: command_list_response.append(u'list_OK') diff --git a/mopidy/frontends/mpd/server.py b/mopidy/frontends/mpd/server.py index a2d9cefe..4e651ddb 100644 --- a/mopidy/frontends/mpd/server.py +++ b/mopidy/frontends/mpd/server.py @@ -52,7 +52,8 @@ class MpdServer(asyncore.dispatcher): self._format_hostname(settings.MPD_SERVER_HOSTNAME), settings.MPD_SERVER_PORT) except IOError, e: - logger.error(u'MPD server startup failed: %s' % str(e).decode('utf-8')) + logger.error(u'MPD server startup failed: %s' % + str(e).decode('utf-8')) sys.exit(1) def handle_accept(self): diff --git a/mopidy/frontends/mpd/session.py b/mopidy/frontends/mpd/session.py index 5a86dfdb..53f4cab7 100644 --- a/mopidy/frontends/mpd/session.py +++ b/mopidy/frontends/mpd/session.py @@ -1,7 +1,6 @@ import asynchat import logging -from mopidy import settings from mopidy.frontends.mpd.dispatcher import MpdDispatcher from mopidy.frontends.mpd.protocol import ENCODING, LINE_TERMINATOR, VERSION from mopidy.utils.log import indent @@ -46,7 +45,10 @@ class MpdSession(asynchat.async_chat): return self.dispatcher.handle_request(request) def send_response(self, response): - """Format a response from the MPD command handlers and send it to the client.""" + """ + Format a response from the MPD command handlers and send it to the + client. + """ if response is not None: response = LINE_TERMINATOR.join(response) logger.debug(u'Response to [%s]:%s: %s', self.client_address, From a08885bb9598956dfa2393d187d8a12fef562043 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 4 Jun 2011 18:43:30 +0200 Subject: [PATCH 140/218] Use a MpdCommand namedtuple in the mopidy.frontends.mpd.protocol.mpd_commands list --- mopidy/frontends/mpd/dispatcher.py | 8 +++---- mopidy/frontends/mpd/protocol/__init__.py | 4 +++- mopidy/frontends/mpd/protocol/reflection.py | 24 ++++++++++----------- 3 files changed, 19 insertions(+), 17 deletions(-) diff --git a/mopidy/frontends/mpd/dispatcher.py b/mopidy/frontends/mpd/dispatcher.py index 996c3714..405ca03d 100644 --- a/mopidy/frontends/mpd/dispatcher.py +++ b/mopidy/frontends/mpd/dispatcher.py @@ -135,10 +135,10 @@ class MpdDispatcher(object): matches = re.match(pattern, request) if matches is not None: return (request_handlers[pattern], matches.groupdict()) - command = request.split(' ')[0] - if command in mpd_commands: - raise MpdArgError(u'incorrect arguments', command=command) - raise MpdUnknownCommand(command=command) + command_name = request.split(' ')[0] + if command_name in [command.name for command in mpd_commands]: + raise MpdArgError(u'incorrect arguments', command=command_name) + raise MpdUnknownCommand(command=command_name) def _format_response(self, response): formatted_response = [] diff --git a/mopidy/frontends/mpd/protocol/__init__.py b/mopidy/frontends/mpd/protocol/__init__.py index 6689f627..76aad687 100644 --- a/mopidy/frontends/mpd/protocol/__init__.py +++ b/mopidy/frontends/mpd/protocol/__init__.py @@ -10,6 +10,7 @@ implement our own MPD server which is compatible with the numerous existing `MPD clients `_. """ +from collections import namedtuple import re #: The MPD protocol uses UTF-8 for encoding all data. @@ -21,6 +22,7 @@ LINE_TERMINATOR = u'\n' #: The MPD protocol version is 0.16.0. VERSION = u'0.16.0' +MpdCommand = namedtuple('MpdCommand', ['name']) mpd_commands = set() request_handlers = {} @@ -45,7 +47,7 @@ def handle_pattern(pattern): def decorator(func): match = re.search('([a-z_]+)', pattern) if match is not None: - mpd_commands.add(match.group()) + mpd_commands.add(MpdCommand(name=match.group())) if pattern in request_handlers: raise ValueError(u'Tried to redefine handler for %s with %s' % ( pattern, func)) diff --git a/mopidy/frontends/mpd/protocol/reflection.py b/mopidy/frontends/mpd/protocol/reflection.py index 2b319c5e..6e0a2f6c 100644 --- a/mopidy/frontends/mpd/protocol/reflection.py +++ b/mopidy/frontends/mpd/protocol/reflection.py @@ -15,20 +15,20 @@ def commands(context): # have access to. To implement this we need access to the session object to # check if the client is authenticated or not. - sorted_commands = sorted(list(mpd_commands)) + command_names = [command.name for command in mpd_commands] # No permission to use - sorted_commands.remove('kill') + command_names.remove('kill') # Not shown by MPD in its command list - sorted_commands.remove('command_list_begin') - sorted_commands.remove('command_list_ok_begin') - sorted_commands.remove('command_list_end') - sorted_commands.remove('idle') - sorted_commands.remove('noidle') - sorted_commands.remove('sticker') + command_names.remove('command_list_begin') + command_names.remove('command_list_ok_begin') + command_names.remove('command_list_end') + command_names.remove('idle') + command_names.remove('noidle') + command_names.remove('sticker') - return [('command', c) for c in sorted_commands] + return [('command', command_name) for command_name in sorted(command_names)] @handle_pattern(r'^decoders$') def decoders(context): @@ -63,12 +63,12 @@ def notcommands(context): # not have access to. To implement this we need access to the session # object to check if the client is authenticated or not. - commands = [] + command_names = [] # No permission to use - commands.append('kill') + command_names.append('kill') - return [('command', c) for c in sorted(commands)] + return [('command', command_name) for command_name in sorted(command_names)] @handle_pattern(r'^tagtypes$') def tagtypes(context): From e68d715d3e1d07418237c9f3d4c93b7ab4da9c18 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 4 Jun 2011 18:51:18 +0200 Subject: [PATCH 141/218] Rename handle_pattern decorator to handle_request --- mopidy/frontends/mpd/protocol/__init__.py | 6 +-- mopidy/frontends/mpd/protocol/audio_output.py | 8 +-- mopidy/frontends/mpd/protocol/command_list.py | 8 +-- mopidy/frontends/mpd/protocol/connection.py | 10 ++-- .../mpd/protocol/current_playlist.py | 52 +++++++++--------- mopidy/frontends/mpd/protocol/empty.py | 4 +- mopidy/frontends/mpd/protocol/music_db.py | 24 ++++----- mopidy/frontends/mpd/protocol/playback.py | 54 +++++++++---------- mopidy/frontends/mpd/protocol/reflection.py | 12 ++--- mopidy/frontends/mpd/protocol/status.py | 16 +++--- mopidy/frontends/mpd/protocol/stickers.py | 12 ++--- .../mpd/protocol/stored_playlists.py | 24 ++++----- tests/frontends/mpd/dispatcher_test.py | 6 +-- 13 files changed, 118 insertions(+), 118 deletions(-) diff --git a/mopidy/frontends/mpd/protocol/__init__.py b/mopidy/frontends/mpd/protocol/__init__.py index 76aad687..24ce1cac 100644 --- a/mopidy/frontends/mpd/protocol/__init__.py +++ b/mopidy/frontends/mpd/protocol/__init__.py @@ -26,9 +26,9 @@ MpdCommand = namedtuple('MpdCommand', ['name']) mpd_commands = set() request_handlers = {} -def handle_pattern(pattern): +def handle_request(pattern): """ - Decorator for connecting command handlers to command patterns. + Decorator for connecting command handlers to command requests. If you use named groups in the pattern, the decorated method will get the groups as keyword arguments. If the group is optional, remember to give the @@ -37,7 +37,7 @@ def handle_pattern(pattern): For example, if the command is ``do that thing`` the ``what`` argument will be ``this thing``:: - @handle_pattern('^do (?P.+)$') + @handle_request('^do (?P.+)$') def do(what): ... diff --git a/mopidy/frontends/mpd/protocol/audio_output.py b/mopidy/frontends/mpd/protocol/audio_output.py index 6111332a..7147963a 100644 --- a/mopidy/frontends/mpd/protocol/audio_output.py +++ b/mopidy/frontends/mpd/protocol/audio_output.py @@ -1,7 +1,7 @@ -from mopidy.frontends.mpd.protocol import handle_pattern +from mopidy.frontends.mpd.protocol import handle_request from mopidy.frontends.mpd.exceptions import MpdNotImplemented -@handle_pattern(r'^disableoutput "(?P\d+)"$') +@handle_request(r'^disableoutput "(?P\d+)"$') def disableoutput(context, outputid): """ *musicpd.org, audio output section:* @@ -12,7 +12,7 @@ def disableoutput(context, outputid): """ raise MpdNotImplemented # TODO -@handle_pattern(r'^enableoutput "(?P\d+)"$') +@handle_request(r'^enableoutput "(?P\d+)"$') def enableoutput(context, outputid): """ *musicpd.org, audio output section:* @@ -23,7 +23,7 @@ def enableoutput(context, outputid): """ raise MpdNotImplemented # TODO -@handle_pattern(r'^outputs$') +@handle_request(r'^outputs$') def outputs(context): """ *musicpd.org, audio output section:* diff --git a/mopidy/frontends/mpd/protocol/command_list.py b/mopidy/frontends/mpd/protocol/command_list.py index f2c51578..37e5c93d 100644 --- a/mopidy/frontends/mpd/protocol/command_list.py +++ b/mopidy/frontends/mpd/protocol/command_list.py @@ -1,7 +1,7 @@ -from mopidy.frontends.mpd.protocol import handle_pattern +from mopidy.frontends.mpd.protocol import handle_request from mopidy.frontends.mpd.exceptions import MpdUnknownCommand -@handle_pattern(r'^command_list_begin$') +@handle_request(r'^command_list_begin$') def command_list_begin(context): """ *musicpd.org, command list section:* @@ -21,7 +21,7 @@ def command_list_begin(context): context.dispatcher.command_list = [] context.dispatcher.command_list_ok = False -@handle_pattern(r'^command_list_end$') +@handle_request(r'^command_list_end$') def command_list_end(context): """See :meth:`command_list_begin()`.""" if context.dispatcher.command_list is False: @@ -43,7 +43,7 @@ def command_list_end(context): command_list_response.append(u'list_OK') return command_list_response -@handle_pattern(r'^command_list_ok_begin$') +@handle_request(r'^command_list_ok_begin$') def command_list_ok_begin(context): """See :meth:`command_list_begin()`.""" context.dispatcher.command_list = [] diff --git a/mopidy/frontends/mpd/protocol/connection.py b/mopidy/frontends/mpd/protocol/connection.py index 99944ac2..d5c2d80c 100644 --- a/mopidy/frontends/mpd/protocol/connection.py +++ b/mopidy/frontends/mpd/protocol/connection.py @@ -1,9 +1,9 @@ from mopidy import settings -from mopidy.frontends.mpd.protocol import handle_pattern +from mopidy.frontends.mpd.protocol import handle_request from mopidy.frontends.mpd.exceptions import (MpdPasswordError, MpdPermissionError) -@handle_pattern(r'^close$') +@handle_request(r'^close$') def close(context): """ *musicpd.org, connection section:* @@ -14,7 +14,7 @@ def close(context): """ context.session.close() -@handle_pattern(r'^kill$') +@handle_request(r'^kill$') def kill(context): """ *musicpd.org, connection section:* @@ -25,7 +25,7 @@ def kill(context): """ raise MpdPermissionError(command=u'kill') -@handle_pattern(r'^password "(?P[^"]+)"$') +@handle_request(r'^password "(?P[^"]+)"$') def password_(context, password): """ *musicpd.org, connection section:* @@ -40,7 +40,7 @@ def password_(context, password): else: raise MpdPasswordError(u'incorrect password', command=u'password') -@handle_pattern(r'^ping$') +@handle_request(r'^ping$') def ping(context): """ *musicpd.org, connection section:* diff --git a/mopidy/frontends/mpd/protocol/current_playlist.py b/mopidy/frontends/mpd/protocol/current_playlist.py index e73e0a9c..82e096a0 100644 --- a/mopidy/frontends/mpd/protocol/current_playlist.py +++ b/mopidy/frontends/mpd/protocol/current_playlist.py @@ -1,9 +1,9 @@ from mopidy.frontends.mpd.exceptions import (MpdArgError, MpdNoExistError, MpdNotImplemented) -from mopidy.frontends.mpd.protocol import handle_pattern +from mopidy.frontends.mpd.protocol import handle_request from mopidy.frontends.mpd.translator import tracks_to_mpd_format -@handle_pattern(r'^add "(?P[^"]*)"$') +@handle_request(r'^add "(?P[^"]*)"$') def add(context, uri): """ *musicpd.org, current playlist section:* @@ -28,7 +28,7 @@ def add(context, uri): raise MpdNoExistError( u'directory or file not found', command=u'add') -@handle_pattern(r'^addid "(?P[^"]*)"( "(?P\d+)")*$') +@handle_request(r'^addid "(?P[^"]*)"( "(?P\d+)")*$') def addid(context, uri, songpos=None): """ *musicpd.org, current playlist section:* @@ -61,7 +61,7 @@ def addid(context, uri, songpos=None): at_position=songpos).get() return ('Id', cp_track[0]) -@handle_pattern(r'^delete "(?P\d+):(?P\d+)*"$') +@handle_request(r'^delete "(?P\d+):(?P\d+)*"$') def delete_range(context, start, end=None): """ *musicpd.org, current playlist section:* @@ -81,7 +81,7 @@ def delete_range(context, start, end=None): for (cpid, _) in cp_tracks: context.backend.current_playlist.remove(cpid=cpid) -@handle_pattern(r'^delete "(?P\d+)"$') +@handle_request(r'^delete "(?P\d+)"$') def delete_songpos(context, songpos): """See :meth:`delete_range`""" try: @@ -91,7 +91,7 @@ def delete_songpos(context, songpos): except IndexError: raise MpdArgError(u'Bad song index', command=u'delete') -@handle_pattern(r'^deleteid "(?P\d+)"$') +@handle_request(r'^deleteid "(?P\d+)"$') def deleteid(context, cpid): """ *musicpd.org, current playlist section:* @@ -108,7 +108,7 @@ def deleteid(context, cpid): except LookupError: raise MpdNoExistError(u'No such song', command=u'deleteid') -@handle_pattern(r'^clear$') +@handle_request(r'^clear$') def clear(context): """ *musicpd.org, current playlist section:* @@ -119,7 +119,7 @@ def clear(context): """ context.backend.current_playlist.clear() -@handle_pattern(r'^move "(?P\d+):(?P\d+)*" "(?P\d+)"$') +@handle_request(r'^move "(?P\d+):(?P\d+)*" "(?P\d+)"$') def move_range(context, start, to, end=None): """ *musicpd.org, current playlist section:* @@ -136,14 +136,14 @@ def move_range(context, start, to, end=None): to = int(to) context.backend.current_playlist.move(start, end, to) -@handle_pattern(r'^move "(?P\d+)" "(?P\d+)"$') +@handle_request(r'^move "(?P\d+)" "(?P\d+)"$') def move_songpos(context, songpos, to): """See :meth:`move_range`.""" songpos = int(songpos) to = int(to) context.backend.current_playlist.move(songpos, songpos + 1, to) -@handle_pattern(r'^moveid "(?P\d+)" "(?P\d+)"$') +@handle_request(r'^moveid "(?P\d+)" "(?P\d+)"$') def moveid(context, cpid, to): """ *musicpd.org, current playlist section:* @@ -161,7 +161,7 @@ def moveid(context, cpid, to): cp_track) context.backend.current_playlist.move(position, position + 1, to) -@handle_pattern(r'^playlist$') +@handle_request(r'^playlist$') def playlist(context): """ *musicpd.org, current playlist section:* @@ -176,8 +176,8 @@ def playlist(context): """ return playlistinfo(context) -@handle_pattern(r'^playlistfind (?P[^"]+) "(?P[^"]+)"$') -@handle_pattern(r'^playlistfind "(?P[^"]+)" "(?P[^"]+)"$') +@handle_request(r'^playlistfind (?P[^"]+) "(?P[^"]+)"$') +@handle_request(r'^playlistfind "(?P[^"]+)" "(?P[^"]+)"$') def playlistfind(context, tag, needle): """ *musicpd.org, current playlist section:* @@ -201,7 +201,7 @@ def playlistfind(context, tag, needle): return None raise MpdNotImplemented # TODO -@handle_pattern(r'^playlistid( "(?P\d+)")*$') +@handle_request(r'^playlistid( "(?P\d+)")*$') def playlistid(context, cpid=None): """ *musicpd.org, current playlist section:* @@ -226,9 +226,9 @@ def playlistid(context, cpid=None): return tracks_to_mpd_format( context.backend.current_playlist.tracks.get(), cpids=cpids) -@handle_pattern(r'^playlistinfo$') -@handle_pattern(r'^playlistinfo "(?P-?\d+)"$') -@handle_pattern(r'^playlistinfo "(?P\d+):(?P\d+)*"$') +@handle_request(r'^playlistinfo$') +@handle_request(r'^playlistinfo "(?P-?\d+)"$') +@handle_request(r'^playlistinfo "(?P\d+):(?P\d+)*"$') def playlistinfo(context, songpos=None, start=None, end=None): """ @@ -276,8 +276,8 @@ def playlistinfo(context, songpos=None, context.backend.current_playlist.tracks.get(), start, end, cpids=cpids) -@handle_pattern(r'^playlistsearch "(?P[^"]+)" "(?P[^"]+)"$') -@handle_pattern(r'^playlistsearch (?P\S+) "(?P[^"]+)"$') +@handle_request(r'^playlistsearch "(?P[^"]+)" "(?P[^"]+)"$') +@handle_request(r'^playlistsearch (?P\S+) "(?P[^"]+)"$') def playlistsearch(context, tag, needle): """ *musicpd.org, current playlist section:* @@ -294,8 +294,8 @@ def playlistsearch(context, tag, needle): """ raise MpdNotImplemented # TODO -@handle_pattern(r'^plchanges (?P-?\d+)$') -@handle_pattern(r'^plchanges "(?P-?\d+)"$') +@handle_request(r'^plchanges (?P-?\d+)$') +@handle_request(r'^plchanges "(?P-?\d+)"$') def plchanges(context, version): """ *musicpd.org, current playlist section:* @@ -318,7 +318,7 @@ def plchanges(context, version): return tracks_to_mpd_format( context.backend.current_playlist.tracks.get(), cpids=cpids) -@handle_pattern(r'^plchangesposid "(?P\d+)"$') +@handle_request(r'^plchangesposid "(?P\d+)"$') def plchangesposid(context, version): """ *musicpd.org, current playlist section:* @@ -341,8 +341,8 @@ def plchangesposid(context, version): result.append((u'Id', cpid)) return result -@handle_pattern(r'^shuffle$') -@handle_pattern(r'^shuffle "(?P\d+):(?P\d+)*"$') +@handle_request(r'^shuffle$') +@handle_request(r'^shuffle "(?P\d+):(?P\d+)*"$') def shuffle(context, start=None, end=None): """ *musicpd.org, current playlist section:* @@ -358,7 +358,7 @@ def shuffle(context, start=None, end=None): end = int(end) context.backend.current_playlist.shuffle(start, end) -@handle_pattern(r'^swap "(?P\d+)" "(?P\d+)"$') +@handle_request(r'^swap "(?P\d+)" "(?P\d+)"$') def swap(context, songpos1, songpos2): """ *musicpd.org, current playlist section:* @@ -379,7 +379,7 @@ def swap(context, songpos1, songpos2): context.backend.current_playlist.clear() context.backend.current_playlist.append(tracks) -@handle_pattern(r'^swapid "(?P\d+)" "(?P\d+)"$') +@handle_request(r'^swapid "(?P\d+)" "(?P\d+)"$') def swapid(context, cpid1, cpid2): """ *musicpd.org, current playlist section:* diff --git a/mopidy/frontends/mpd/protocol/empty.py b/mopidy/frontends/mpd/protocol/empty.py index b84f08f4..0e418551 100644 --- a/mopidy/frontends/mpd/protocol/empty.py +++ b/mopidy/frontends/mpd/protocol/empty.py @@ -1,6 +1,6 @@ -from mopidy.frontends.mpd.protocol import handle_pattern +from mopidy.frontends.mpd.protocol import handle_request -@handle_pattern(r'^$') +@handle_request(r'^$') def empty(context): """The original MPD server returns ``OK`` on an empty request.""" pass diff --git a/mopidy/frontends/mpd/protocol/music_db.py b/mopidy/frontends/mpd/protocol/music_db.py index 0183d471..0343b3ab 100644 --- a/mopidy/frontends/mpd/protocol/music_db.py +++ b/mopidy/frontends/mpd/protocol/music_db.py @@ -1,7 +1,7 @@ import re import shlex -from mopidy.frontends.mpd.protocol import handle_pattern, stored_playlists +from mopidy.frontends.mpd.protocol import handle_request, stored_playlists from mopidy.frontends.mpd.exceptions import MpdArgError, MpdNotImplemented def _build_query(mpd_query): @@ -28,7 +28,7 @@ def _build_query(mpd_query): query[field] = [what] return query -@handle_pattern(r'^count "(?P[^"]+)" "(?P[^"]*)"$') +@handle_request(r'^count "(?P[^"]+)" "(?P[^"]*)"$') def count(context, tag, needle): """ *musicpd.org, music database section:* @@ -40,7 +40,7 @@ def count(context, tag, needle): """ return [('songs', 0), ('playtime', 0)] # TODO -@handle_pattern(r'^find ' +@handle_request(r'^find ' r'(?P("?([Aa]lbum|[Aa]rtist|[Dd]ate|[Ff]ilename|' r'[Tt]itle|[Aa]ny)"? "[^"]+"\s?)+)$') def find(context, mpd_query): @@ -70,7 +70,7 @@ def find(context, mpd_query): query = _build_query(mpd_query) return context.backend.library.find_exact(**query).get().mpd_format() -@handle_pattern(r'^findadd ' +@handle_request(r'^findadd ' r'(?P("?([Aa]lbum|[Aa]rtist|[Ff]ilename|[Tt]itle|[Aa]ny)"? ' '"[^"]+"\s?)+)$') def findadd(context, query): @@ -86,7 +86,7 @@ def findadd(context, query): # TODO Add result to current playlist #result = context.find(query) -@handle_pattern(r'^list "?(?P([Aa]rtist|[Aa]lbum|[Dd]ate|[Gg]enre))"?' +@handle_request(r'^list "?(?P([Aa]rtist|[Aa]lbum|[Dd]ate|[Gg]enre))"?' '( (?P.*))?$') def list_(context, field, mpd_query=None): """ @@ -237,7 +237,7 @@ def _list_date(context, query): dates.add((u'Date', track.date.strftime('%Y-%m-%d'))) return dates -@handle_pattern(r'^listall "(?P[^"]+)"') +@handle_request(r'^listall "(?P[^"]+)"') def listall(context, uri): """ *musicpd.org, music database section:* @@ -248,7 +248,7 @@ def listall(context, uri): """ raise MpdNotImplemented # TODO -@handle_pattern(r'^listallinfo "(?P[^"]+)"') +@handle_request(r'^listallinfo "(?P[^"]+)"') def listallinfo(context, uri): """ *musicpd.org, music database section:* @@ -260,8 +260,8 @@ def listallinfo(context, uri): """ raise MpdNotImplemented # TODO -@handle_pattern(r'^lsinfo$') -@handle_pattern(r'^lsinfo "(?P[^"]*)"$') +@handle_request(r'^lsinfo$') +@handle_request(r'^lsinfo "(?P[^"]*)"$') def lsinfo(context, uri=None): """ *musicpd.org, music database section:* @@ -282,7 +282,7 @@ def lsinfo(context, uri=None): return stored_playlists.listplaylists(context) raise MpdNotImplemented # TODO -@handle_pattern(r'^rescan( "(?P[^"]+)")*$') +@handle_request(r'^rescan( "(?P[^"]+)")*$') def rescan(context, uri=None): """ *musicpd.org, music database section:* @@ -293,7 +293,7 @@ def rescan(context, uri=None): """ return update(context, uri, rescan_unmodified_files=True) -@handle_pattern(r'^search ' +@handle_request(r'^search ' r'(?P("?([Aa]lbum|[Aa]rtist|[Dd]ate|[Ff]ilename|' r'[Tt]itle|[Aa]ny)"? "[^"]+"\s?)+)$') def search(context, mpd_query): @@ -326,7 +326,7 @@ def search(context, mpd_query): query = _build_query(mpd_query) return context.backend.library.search(**query).get().mpd_format() -@handle_pattern(r'^update( "(?P[^"]+)")*$') +@handle_request(r'^update( "(?P[^"]+)")*$') def update(context, uri=None, rescan_unmodified_files=False): """ *musicpd.org, music database section:* diff --git a/mopidy/frontends/mpd/protocol/playback.py b/mopidy/frontends/mpd/protocol/playback.py index b8646a8b..63cfe649 100644 --- a/mopidy/frontends/mpd/protocol/playback.py +++ b/mopidy/frontends/mpd/protocol/playback.py @@ -1,10 +1,10 @@ from mopidy.backends.base import PlaybackController -from mopidy.frontends.mpd.protocol import handle_pattern +from mopidy.frontends.mpd.protocol import handle_request from mopidy.frontends.mpd.exceptions import (MpdArgError, MpdNoExistError, MpdNotImplemented) -@handle_pattern(r'^consume (?P[01])$') -@handle_pattern(r'^consume "(?P[01])"$') +@handle_request(r'^consume (?P[01])$') +@handle_request(r'^consume "(?P[01])"$') def consume(context, state): """ *musicpd.org, playback section:* @@ -20,7 +20,7 @@ def consume(context, state): else: context.backend.playback.consume = False -@handle_pattern(r'^crossfade "(?P\d+)"$') +@handle_request(r'^crossfade "(?P\d+)"$') def crossfade(context, seconds): """ *musicpd.org, playback section:* @@ -32,7 +32,7 @@ def crossfade(context, seconds): seconds = int(seconds) raise MpdNotImplemented # TODO -@handle_pattern(r'^next$') +@handle_request(r'^next$') def next_(context): """ *musicpd.org, playback section:* @@ -89,8 +89,8 @@ def next_(context): """ return context.backend.playback.next().get() -@handle_pattern(r'^pause$') -@handle_pattern(r'^pause "(?P[01])"$') +@handle_request(r'^pause$') +@handle_request(r'^pause "(?P[01])"$') def pause(context, state=None): """ *musicpd.org, playback section:* @@ -115,7 +115,7 @@ def pause(context, state=None): else: context.backend.playback.resume() -@handle_pattern(r'^play$') +@handle_request(r'^play$') def play(context): """ The original MPD server resumes from the paused state on ``play`` @@ -123,8 +123,8 @@ def play(context): """ return context.backend.playback.play().get() -@handle_pattern(r'^playid "(?P\d+)"$') -@handle_pattern(r'^playid "(?P-1)"$') +@handle_request(r'^playid "(?P\d+)"$') +@handle_request(r'^playid "(?P-1)"$') def playid(context, cpid): """ *musicpd.org, playback section:* @@ -151,8 +151,8 @@ def playid(context, cpid): except LookupError: raise MpdNoExistError(u'No such song', command=u'playid') -@handle_pattern(r'^play (?P-?\d+)$') -@handle_pattern(r'^play "(?P-?\d+)"$') +@handle_request(r'^play (?P-?\d+)$') +@handle_request(r'^play "(?P-?\d+)"$') def playpos(context, songpos): """ *musicpd.org, playback section:* @@ -197,7 +197,7 @@ def _play_minus_one(context): else: return # Fail silently -@handle_pattern(r'^previous$') +@handle_request(r'^previous$') def previous(context): """ *musicpd.org, playback section:* @@ -243,8 +243,8 @@ def previous(context): """ return context.backend.playback.previous().get() -@handle_pattern(r'^random (?P[01])$') -@handle_pattern(r'^random "(?P[01])"$') +@handle_request(r'^random (?P[01])$') +@handle_request(r'^random "(?P[01])"$') def random(context, state): """ *musicpd.org, playback section:* @@ -258,8 +258,8 @@ def random(context, state): else: context.backend.playback.random = False -@handle_pattern(r'^repeat (?P[01])$') -@handle_pattern(r'^repeat "(?P[01])"$') +@handle_request(r'^repeat (?P[01])$') +@handle_request(r'^repeat "(?P[01])"$') def repeat(context, state): """ *musicpd.org, playback section:* @@ -273,7 +273,7 @@ def repeat(context, state): else: context.backend.playback.repeat = False -@handle_pattern(r'^replay_gain_mode "(?P(off|track|album))"$') +@handle_request(r'^replay_gain_mode "(?P(off|track|album))"$') def replay_gain_mode(context, mode): """ *musicpd.org, playback section:* @@ -289,7 +289,7 @@ def replay_gain_mode(context, mode): """ raise MpdNotImplemented # TODO -@handle_pattern(r'^replay_gain_status$') +@handle_request(r'^replay_gain_status$') def replay_gain_status(context): """ *musicpd.org, playback section:* @@ -301,8 +301,8 @@ def replay_gain_status(context): """ return u'off' # TODO -@handle_pattern(r'^seek (?P\d+) (?P\d+)$') -@handle_pattern(r'^seek "(?P\d+)" "(?P\d+)"$') +@handle_request(r'^seek (?P\d+) (?P\d+)$') +@handle_request(r'^seek "(?P\d+)" "(?P\d+)"$') def seek(context, songpos, seconds): """ *musicpd.org, playback section:* @@ -320,7 +320,7 @@ def seek(context, songpos, seconds): playpos(context, songpos) context.backend.playback.seek(int(seconds) * 1000) -@handle_pattern(r'^seekid "(?P\d+)" "(?P\d+)"$') +@handle_request(r'^seekid "(?P\d+)" "(?P\d+)"$') def seekid(context, cpid, seconds): """ *musicpd.org, playback section:* @@ -333,8 +333,8 @@ def seekid(context, cpid, seconds): playid(context, cpid) context.backend.playback.seek(int(seconds) * 1000) -@handle_pattern(r'^setvol (?P[-+]*\d+)$') -@handle_pattern(r'^setvol "(?P[-+]*\d+)"$') +@handle_request(r'^setvol (?P[-+]*\d+)$') +@handle_request(r'^setvol "(?P[-+]*\d+)"$') def setvol(context, volume): """ *musicpd.org, playback section:* @@ -354,8 +354,8 @@ def setvol(context, volume): volume = 100 context.mixer.volume = volume -@handle_pattern(r'^single (?P[01])$') -@handle_pattern(r'^single "(?P[01])"$') +@handle_request(r'^single (?P[01])$') +@handle_request(r'^single "(?P[01])"$') def single(context, state): """ *musicpd.org, playback section:* @@ -371,7 +371,7 @@ def single(context, state): else: context.backend.playback.single = False -@handle_pattern(r'^stop$') +@handle_request(r'^stop$') def stop(context): """ *musicpd.org, playback section:* diff --git a/mopidy/frontends/mpd/protocol/reflection.py b/mopidy/frontends/mpd/protocol/reflection.py index 6e0a2f6c..94d8ce83 100644 --- a/mopidy/frontends/mpd/protocol/reflection.py +++ b/mopidy/frontends/mpd/protocol/reflection.py @@ -1,7 +1,7 @@ -from mopidy.frontends.mpd.protocol import handle_pattern, mpd_commands +from mopidy.frontends.mpd.protocol import handle_request, mpd_commands from mopidy.frontends.mpd.exceptions import MpdNotImplemented -@handle_pattern(r'^commands$') +@handle_request(r'^commands$') def commands(context): """ *musicpd.org, reflection section:* @@ -30,7 +30,7 @@ def commands(context): return [('command', command_name) for command_name in sorted(command_names)] -@handle_pattern(r'^decoders$') +@handle_request(r'^decoders$') def decoders(context): """ *musicpd.org, reflection section:* @@ -49,7 +49,7 @@ def decoders(context): """ raise MpdNotImplemented # TODO -@handle_pattern(r'^notcommands$') +@handle_request(r'^notcommands$') def notcommands(context): """ *musicpd.org, reflection section:* @@ -70,7 +70,7 @@ def notcommands(context): return [('command', command_name) for command_name in sorted(command_names)] -@handle_pattern(r'^tagtypes$') +@handle_request(r'^tagtypes$') def tagtypes(context): """ *musicpd.org, reflection section:* @@ -81,7 +81,7 @@ def tagtypes(context): """ pass # TODO -@handle_pattern(r'^urlhandlers$') +@handle_request(r'^urlhandlers$') def urlhandlers(context): """ *musicpd.org, reflection section:* diff --git a/mopidy/frontends/mpd/protocol/status.py b/mopidy/frontends/mpd/protocol/status.py index 4f9e00cd..58fefa11 100644 --- a/mopidy/frontends/mpd/protocol/status.py +++ b/mopidy/frontends/mpd/protocol/status.py @@ -1,8 +1,8 @@ from mopidy.backends.base import PlaybackController -from mopidy.frontends.mpd.protocol import handle_pattern +from mopidy.frontends.mpd.protocol import handle_request from mopidy.frontends.mpd.exceptions import MpdNotImplemented -@handle_pattern(r'^clearerror$') +@handle_request(r'^clearerror$') def clearerror(context): """ *musicpd.org, status section:* @@ -14,7 +14,7 @@ def clearerror(context): """ raise MpdNotImplemented # TODO -@handle_pattern(r'^currentsong$') +@handle_request(r'^currentsong$') def currentsong(context): """ *musicpd.org, status section:* @@ -30,8 +30,8 @@ def currentsong(context): position=context.backend.playback.current_playlist_position.get(), cpid=current_cp_track[0]) -@handle_pattern(r'^idle$') -@handle_pattern(r'^idle (?P.+)$') +@handle_request(r'^idle$') +@handle_request(r'^idle (?P.+)$') def idle(context, subsystems=None): """ *musicpd.org, status section:* @@ -67,12 +67,12 @@ def idle(context, subsystems=None): """ pass # TODO -@handle_pattern(r'^noidle$') +@handle_request(r'^noidle$') def noidle(context): """See :meth:`_status_idle`.""" pass # TODO -@handle_pattern(r'^stats$') +@handle_request(r'^stats$') def stats(context): """ *musicpd.org, status section:* @@ -98,7 +98,7 @@ def stats(context): 'playtime': 0, # TODO } -@handle_pattern(r'^status$') +@handle_request(r'^status$') def status(context): """ *musicpd.org, status section:* diff --git a/mopidy/frontends/mpd/protocol/stickers.py b/mopidy/frontends/mpd/protocol/stickers.py index c1b7be16..c3663ff1 100644 --- a/mopidy/frontends/mpd/protocol/stickers.py +++ b/mopidy/frontends/mpd/protocol/stickers.py @@ -1,7 +1,7 @@ -from mopidy.frontends.mpd.protocol import handle_pattern +from mopidy.frontends.mpd.protocol import handle_request from mopidy.frontends.mpd.exceptions import MpdNotImplemented -@handle_pattern(r'^sticker delete "(?P[^"]+)" ' +@handle_request(r'^sticker delete "(?P[^"]+)" ' r'"(?P[^"]+)"( "(?P[^"]+)")*$') def sticker_delete(context, field, uri, name=None): """ @@ -14,7 +14,7 @@ def sticker_delete(context, field, uri, name=None): """ raise MpdNotImplemented # TODO -@handle_pattern(r'^sticker find "(?P[^"]+)" "(?P[^"]+)" ' +@handle_request(r'^sticker find "(?P[^"]+)" "(?P[^"]+)" ' r'"(?P[^"]+)"$') def sticker_find(context, field, uri, name): """ @@ -28,7 +28,7 @@ def sticker_find(context, field, uri, name): """ raise MpdNotImplemented # TODO -@handle_pattern(r'^sticker get "(?P[^"]+)" "(?P[^"]+)" ' +@handle_request(r'^sticker get "(?P[^"]+)" "(?P[^"]+)" ' r'"(?P[^"]+)"$') def sticker_get(context, field, uri, name): """ @@ -40,7 +40,7 @@ def sticker_get(context, field, uri, name): """ raise MpdNotImplemented # TODO -@handle_pattern(r'^sticker list "(?P[^"]+)" "(?P[^"]+)"$') +@handle_request(r'^sticker list "(?P[^"]+)" "(?P[^"]+)"$') def sticker_list(context, field, uri): """ *musicpd.org, sticker section:* @@ -51,7 +51,7 @@ def sticker_list(context, field, uri): """ raise MpdNotImplemented # TODO -@handle_pattern(r'^sticker set "(?P[^"]+)" "(?P[^"]+)" ' +@handle_request(r'^sticker set "(?P[^"]+)" "(?P[^"]+)" ' r'"(?P[^"]+)" "(?P[^"]+)"$') def sticker_set(context, field, uri, name, value): """ diff --git a/mopidy/frontends/mpd/protocol/stored_playlists.py b/mopidy/frontends/mpd/protocol/stored_playlists.py index 764a8e12..0a157f66 100644 --- a/mopidy/frontends/mpd/protocol/stored_playlists.py +++ b/mopidy/frontends/mpd/protocol/stored_playlists.py @@ -1,9 +1,9 @@ import datetime as dt -from mopidy.frontends.mpd.protocol import handle_pattern +from mopidy.frontends.mpd.protocol import handle_request from mopidy.frontends.mpd.exceptions import MpdNoExistError, MpdNotImplemented -@handle_pattern(r'^listplaylist "(?P[^"]+)"$') +@handle_request(r'^listplaylist "(?P[^"]+)"$') def listplaylist(context, name): """ *musicpd.org, stored playlists section:* @@ -24,7 +24,7 @@ def listplaylist(context, name): except LookupError: raise MpdNoExistError(u'No such playlist', command=u'listplaylist') -@handle_pattern(r'^listplaylistinfo "(?P[^"]+)"$') +@handle_request(r'^listplaylistinfo "(?P[^"]+)"$') def listplaylistinfo(context, name): """ *musicpd.org, stored playlists section:* @@ -45,7 +45,7 @@ def listplaylistinfo(context, name): raise MpdNoExistError( u'No such playlist', command=u'listplaylistinfo') -@handle_pattern(r'^listplaylists$') +@handle_request(r'^listplaylists$') def listplaylists(context): """ *musicpd.org, stored playlists section:* @@ -79,7 +79,7 @@ def listplaylists(context): result.append((u'Last-Modified', last_modified)) return result -@handle_pattern(r'^load "(?P[^"]+)"$') +@handle_request(r'^load "(?P[^"]+)"$') def load(context, name): """ *musicpd.org, stored playlists section:* @@ -98,7 +98,7 @@ def load(context, name): except LookupError: raise MpdNoExistError(u'No such playlist', command=u'load') -@handle_pattern(r'^playlistadd "(?P[^"]+)" "(?P[^"]+)"$') +@handle_request(r'^playlistadd "(?P[^"]+)" "(?P[^"]+)"$') def playlistadd(context, name, uri): """ *musicpd.org, stored playlists section:* @@ -111,7 +111,7 @@ def playlistadd(context, name, uri): """ raise MpdNotImplemented # TODO -@handle_pattern(r'^playlistclear "(?P[^"]+)"$') +@handle_request(r'^playlistclear "(?P[^"]+)"$') def playlistclear(context, name): """ *musicpd.org, stored playlists section:* @@ -122,7 +122,7 @@ def playlistclear(context, name): """ raise MpdNotImplemented # TODO -@handle_pattern(r'^playlistdelete "(?P[^"]+)" "(?P\d+)"$') +@handle_request(r'^playlistdelete "(?P[^"]+)" "(?P\d+)"$') def playlistdelete(context, name, songpos): """ *musicpd.org, stored playlists section:* @@ -133,7 +133,7 @@ def playlistdelete(context, name, songpos): """ raise MpdNotImplemented # TODO -@handle_pattern(r'^playlistmove "(?P[^"]+)" ' +@handle_request(r'^playlistmove "(?P[^"]+)" ' r'"(?P\d+)" "(?P\d+)"$') def playlistmove(context, name, from_pos, to_pos): """ @@ -152,7 +152,7 @@ def playlistmove(context, name, from_pos, to_pos): """ raise MpdNotImplemented # TODO -@handle_pattern(r'^rename "(?P[^"]+)" "(?P[^"]+)"$') +@handle_request(r'^rename "(?P[^"]+)" "(?P[^"]+)"$') def rename(context, old_name, new_name): """ *musicpd.org, stored playlists section:* @@ -163,7 +163,7 @@ def rename(context, old_name, new_name): """ raise MpdNotImplemented # TODO -@handle_pattern(r'^rm "(?P[^"]+)"$') +@handle_request(r'^rm "(?P[^"]+)"$') def rm(context, name): """ *musicpd.org, stored playlists section:* @@ -174,7 +174,7 @@ def rm(context, name): """ raise MpdNotImplemented # TODO -@handle_pattern(r'^save "(?P[^"]+)"$') +@handle_request(r'^save "(?P[^"]+)"$') def save(context, name): """ *musicpd.org, stored playlists section:* diff --git a/tests/frontends/mpd/dispatcher_test.py b/tests/frontends/mpd/dispatcher_test.py index 7dd70834..7708ce31 100644 --- a/tests/frontends/mpd/dispatcher_test.py +++ b/tests/frontends/mpd/dispatcher_test.py @@ -3,7 +3,7 @@ import unittest from mopidy.backends.dummy import DummyBackend from mopidy.frontends.mpd.dispatcher import MpdDispatcher from mopidy.frontends.mpd.exceptions import MpdAckError -from mopidy.frontends.mpd.protocol import request_handlers, handle_pattern +from mopidy.frontends.mpd.protocol import request_handlers, handle_request from mopidy.mixers.dummy import DummyMixer class MpdDispatcherTest(unittest.TestCase): @@ -19,8 +19,8 @@ class MpdDispatcherTest(unittest.TestCase): def test_register_same_pattern_twice_fails(self): func = lambda: None try: - handle_pattern('a pattern')(func) - handle_pattern('a pattern')(func) + handle_request('a pattern')(func) + handle_request('a pattern')(func) self.fail('Registering a pattern twice shoulde raise ValueError') except ValueError: pass From d5a13ae1ca51c4955f39be50ed02e987d1c8c3cb Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 4 Jun 2011 18:55:54 +0200 Subject: [PATCH 142/218] Add auth_required=True to handle_request, and add it to the MpdCommand object stashed in mpd_commands --- mopidy/frontends/mpd/protocol/__init__.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/mopidy/frontends/mpd/protocol/__init__.py b/mopidy/frontends/mpd/protocol/__init__.py index 24ce1cac..dc6cfb89 100644 --- a/mopidy/frontends/mpd/protocol/__init__.py +++ b/mopidy/frontends/mpd/protocol/__init__.py @@ -22,11 +22,11 @@ LINE_TERMINATOR = u'\n' #: The MPD protocol version is 0.16.0. VERSION = u'0.16.0' -MpdCommand = namedtuple('MpdCommand', ['name']) +MpdCommand = namedtuple('MpdCommand', ['name', 'auth_required']) mpd_commands = set() request_handlers = {} -def handle_request(pattern): +def handle_request(pattern, auth_required=True): """ Decorator for connecting command handlers to command requests. @@ -47,7 +47,8 @@ def handle_request(pattern): def decorator(func): match = re.search('([a-z_]+)', pattern) if match is not None: - mpd_commands.add(MpdCommand(name=match.group())) + mpd_commands.add( + MpdCommand(name=match.group(), auth_required=auth_required)) if pattern in request_handlers: raise ValueError(u'Tried to redefine handler for %s with %s' % ( pattern, func)) From 601a0f0a455441628c313ac1f8f3b456611b0cb2 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 4 Jun 2011 19:02:20 +0200 Subject: [PATCH 143/218] You are always authenticated when MPD_SERVER_PASSWORD==None --- mopidy/frontends/mpd/dispatcher.py | 5 ++++- tests/frontends/mpd/authentication_test.py | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/mopidy/frontends/mpd/dispatcher.py b/mopidy/frontends/mpd/dispatcher.py index 405ca03d..4ae1e184 100644 --- a/mopidy/frontends/mpd/dispatcher.py +++ b/mopidy/frontends/mpd/dispatcher.py @@ -70,7 +70,10 @@ class MpdDispatcher(object): ### Filter: authenticate def _authenticate_filter(self, request, response, filter_chain): - if self.authenticated or settings.MPD_SERVER_PASSWORD is None: + if self.authenticated: + return self._call_next_filter(request, response, filter_chain) + elif settings.MPD_SERVER_PASSWORD is None: + self.authenticated = True return self._call_next_filter(request, response, filter_chain) else: command = request.split(' ')[0] diff --git a/tests/frontends/mpd/authentication_test.py b/tests/frontends/mpd/authentication_test.py index 03fc5718..d795d726 100644 --- a/tests/frontends/mpd/authentication_test.py +++ b/tests/frontends/mpd/authentication_test.py @@ -28,7 +28,7 @@ class AuthenticationTest(unittest.TestCase): def test_authentication_with_anything_when_password_check_turned_off(self): settings.MPD_SERVER_PASSWORD = None response = self.dispatcher.handle_request(u'any request at all') - self.assertFalse(self.dispatcher.authenticated) + self.assertTrue(self.dispatcher.authenticated) self.assert_('ACK [5@0] {} unknown command "any"' in response) def test_anything_when_not_authenticated_should_fail(self): From 3ac987ee475a41b17b8265dc7375fb140b33550e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 4 Jun 2011 19:16:07 +0200 Subject: [PATCH 144/218] Move definition of what commands are allowed without authentication from the auth filter to the request handlers --- mopidy/frontends/mpd/dispatcher.py | 10 ++++++---- mopidy/frontends/mpd/protocol/connection.py | 6 +++--- mopidy/frontends/mpd/protocol/reflection.py | 4 ++-- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/mopidy/frontends/mpd/dispatcher.py b/mopidy/frontends/mpd/dispatcher.py index 4ae1e184..29ceacd1 100644 --- a/mopidy/frontends/mpd/dispatcher.py +++ b/mopidy/frontends/mpd/dispatcher.py @@ -76,12 +76,14 @@ class MpdDispatcher(object): self.authenticated = True return self._call_next_filter(request, response, filter_chain) else: - command = request.split(' ')[0] - if command in ( - 'close', 'commands', 'notcommands', 'password', 'ping'): + command_name = request.split(' ')[0] + command_names_not_requiring_auth = [ + command.name for command in mpd_commands + if not command.auth_required] + if command_name in command_names_not_requiring_auth: return self._call_next_filter(request, response, filter_chain) else: - raise MpdPermissionError(command=command) + raise MpdPermissionError(command=command_name) ### Filter: command list diff --git a/mopidy/frontends/mpd/protocol/connection.py b/mopidy/frontends/mpd/protocol/connection.py index d5c2d80c..ff230173 100644 --- a/mopidy/frontends/mpd/protocol/connection.py +++ b/mopidy/frontends/mpd/protocol/connection.py @@ -3,7 +3,7 @@ from mopidy.frontends.mpd.protocol import handle_request from mopidy.frontends.mpd.exceptions import (MpdPasswordError, MpdPermissionError) -@handle_request(r'^close$') +@handle_request(r'^close$', auth_required=False) def close(context): """ *musicpd.org, connection section:* @@ -25,7 +25,7 @@ def kill(context): """ raise MpdPermissionError(command=u'kill') -@handle_request(r'^password "(?P[^"]+)"$') +@handle_request(r'^password "(?P[^"]+)"$', auth_required=False) def password_(context, password): """ *musicpd.org, connection section:* @@ -40,7 +40,7 @@ def password_(context, password): else: raise MpdPasswordError(u'incorrect password', command=u'password') -@handle_request(r'^ping$') +@handle_request(r'^ping$', auth_required=False) def ping(context): """ *musicpd.org, connection section:* diff --git a/mopidy/frontends/mpd/protocol/reflection.py b/mopidy/frontends/mpd/protocol/reflection.py index 94d8ce83..fd47e57e 100644 --- a/mopidy/frontends/mpd/protocol/reflection.py +++ b/mopidy/frontends/mpd/protocol/reflection.py @@ -1,7 +1,7 @@ from mopidy.frontends.mpd.protocol import handle_request, mpd_commands from mopidy.frontends.mpd.exceptions import MpdNotImplemented -@handle_request(r'^commands$') +@handle_request(r'^commands$', auth_required=False) def commands(context): """ *musicpd.org, reflection section:* @@ -49,7 +49,7 @@ def decoders(context): """ raise MpdNotImplemented # TODO -@handle_request(r'^notcommands$') +@handle_request(r'^notcommands$', auth_required=False) def notcommands(context): """ *musicpd.org, reflection section:* From c52d5c9388fc7d3d86ad78bfc793d780f3ec26a2 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 4 Jun 2011 19:20:53 +0200 Subject: [PATCH 145/218] Document mopidy.frontends.mpd.protoocol.mpd_commands as a part of the MPD frontend API --- mopidy/frontends/mpd/protocol/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/mopidy/frontends/mpd/protocol/__init__.py b/mopidy/frontends/mpd/protocol/__init__.py index dc6cfb89..f0b56a57 100644 --- a/mopidy/frontends/mpd/protocol/__init__.py +++ b/mopidy/frontends/mpd/protocol/__init__.py @@ -23,7 +23,10 @@ LINE_TERMINATOR = u'\n' VERSION = u'0.16.0' MpdCommand = namedtuple('MpdCommand', ['name', 'auth_required']) + +#: List of all available commands, represented as :class:`MpdCommand` objects. mpd_commands = set() + request_handlers = {} def handle_request(pattern, auth_required=True): From 528f5996601e89c03b28deeb96fd66e5afb6363d Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 4 Jun 2011 19:25:38 +0200 Subject: [PATCH 146/218] Remove _-prefix from BaseMixer.{get_volume,set_volume} as they are public members of the API, intended to be overridden --- mopidy/mixers/alsa.py | 4 ++-- mopidy/mixers/base.py | 11 ++++++----- mopidy/mixers/denon.py | 4 ++-- mopidy/mixers/dummy.py | 4 ++-- mopidy/mixers/gstreamer_software.py | 4 ++-- mopidy/mixers/nad.py | 4 ++-- mopidy/mixers/osa.py | 4 ++-- 7 files changed, 18 insertions(+), 17 deletions(-) diff --git a/mopidy/mixers/alsa.py b/mopidy/mixers/alsa.py index 6329bbbb..ae4bd031 100644 --- a/mopidy/mixers/alsa.py +++ b/mopidy/mixers/alsa.py @@ -51,9 +51,9 @@ class AlsaMixer(ThreadingActor, BaseMixer): return [settings.MIXER_ALSA_CONTROL] return [u'Master', u'PCM'] - def _get_volume(self): + def get_volume(self): # FIXME does not seem to see external volume changes. return self._mixer.getvolume()[0] - def _set_volume(self, volume): + def set_volume(self, volume): self._mixer.setvolume(volume) diff --git a/mopidy/mixers/base.py b/mopidy/mixers/base.py index 74996cb6..ec3d8ae5 100644 --- a/mopidy/mixers/base.py +++ b/mopidy/mixers/base.py @@ -17,9 +17,10 @@ class BaseMixer(object): Integer in range [0, 100]. :class:`None` if unknown. Values below 0 is equal to 0. Values above 100 is equal to 100. """ - if self._get_volume() is None: + volume = self.get_volume() + if volume is None: return None - return int(self._get_volume() / self.amplification_factor) + return int(volume / self.amplification_factor) @volume.setter def volume(self, volume): @@ -28,9 +29,9 @@ class BaseMixer(object): volume = 0 elif volume > 100: volume = 100 - self._set_volume(volume) + self.set_volume(volume) - def _get_volume(self): + def get_volume(self): """ Return volume as integer in range [0, 100]. :class:`None` if unknown. @@ -38,7 +39,7 @@ class BaseMixer(object): """ raise NotImplementedError - def _set_volume(self, volume): + def set_volume(self, volume): """ Set volume as integer in range [0, 100]. diff --git a/mopidy/mixers/denon.py b/mopidy/mixers/denon.py index 3922d20a..d0dc5f54 100644 --- a/mopidy/mixers/denon.py +++ b/mopidy/mixers/denon.py @@ -35,14 +35,14 @@ class DenonMixer(ThreadingActor, BaseMixer): from serial import Serial self._device = Serial(port=settings.MIXER_EXT_PORT, timeout=0.2) - def _get_volume(self): + def get_volume(self): self._ensure_open_device() self._device.write('MV?\r') vol = str(self._device.readline()[2:4]) logger.debug(u'_get_volume() = %s' % vol) return self._levels.index(vol) - def _set_volume(self, volume): + def set_volume(self, volume): # Clamp according to Denon-spec if volume > 99: volume = 99 diff --git a/mopidy/mixers/dummy.py b/mopidy/mixers/dummy.py index 186bc7aa..23f96c4c 100644 --- a/mopidy/mixers/dummy.py +++ b/mopidy/mixers/dummy.py @@ -8,8 +8,8 @@ class DummyMixer(ThreadingActor, BaseMixer): def __init__(self): self._volume = None - def _get_volume(self): + def get_volume(self): return self._volume - def _set_volume(self, volume): + def set_volume(self, volume): self._volume = volume diff --git a/mopidy/mixers/gstreamer_software.py b/mopidy/mixers/gstreamer_software.py index 87602772..523c3387 100644 --- a/mopidy/mixers/gstreamer_software.py +++ b/mopidy/mixers/gstreamer_software.py @@ -15,8 +15,8 @@ class GStreamerSoftwareMixer(ThreadingActor, BaseMixer): assert len(output_refs) == 1, 'Expected exactly one running output.' self.output = output_refs[0].proxy() - def _get_volume(self): + def get_volume(self): return self.output.get_volume().get() - def _set_volume(self, volume): + def set_volume(self, volume): self.output.set_volume(volume).get() diff --git a/mopidy/mixers/nad.py b/mopidy/mixers/nad.py index 62f38bb7..4dbf27be 100644 --- a/mopidy/mixers/nad.py +++ b/mopidy/mixers/nad.py @@ -40,10 +40,10 @@ class NadMixer(ThreadingActor, BaseMixer): self._volume_cache = None self._nad_talker = NadTalker.start().proxy() - def _get_volume(self): + def get_volume(self): return self._volume_cache - def _set_volume(self, volume): + def set_volume(self, volume): self._volume_cache = volume self._nad_talker.set_volume(volume) diff --git a/mopidy/mixers/osa.py b/mopidy/mixers/osa.py index 53983095..bd97d790 100644 --- a/mopidy/mixers/osa.py +++ b/mopidy/mixers/osa.py @@ -28,7 +28,7 @@ class OsaMixer(ThreadingActor, BaseMixer): and self._last_update is not None and (int(time.time() - self._last_update) < self.CACHE_TTL)) - def _get_volume(self): + def get_volume(self): if not self._valid_cache(): try: self._cache = int(Popen( @@ -40,7 +40,7 @@ class OsaMixer(ThreadingActor, BaseMixer): self._last_update = int(time.time()) return self._cache - def _set_volume(self, volume): + def set_volume(self, volume): Popen(['osascript', '-e', 'set volume output volume %d' % volume]) self._cache = volume self._last_update = int(time.time()) From d0573aa7e851198857d75bfa322c872001bf4c77 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 4 Jun 2011 19:28:19 +0200 Subject: [PATCH 147/218] docs: Do not autodoc private members any longer. The need really went away with the separation of BackendControllers and BackendProviders --- docs/autodoc_private_members.py | 10 ---------- docs/conf.py | 2 +- 2 files changed, 1 insertion(+), 11 deletions(-) delete mode 100644 docs/autodoc_private_members.py diff --git a/docs/autodoc_private_members.py b/docs/autodoc_private_members.py deleted file mode 100644 index 9cb2e49b..00000000 --- a/docs/autodoc_private_members.py +++ /dev/null @@ -1,10 +0,0 @@ -def setup(app): - app.connect('autodoc-skip-member', autodoc_private_members_with_doc) - -def autodoc_private_members_with_doc(app, what, name, obj, skip, options): - if not skip: - return skip - if (name.startswith('_') and obj.__doc__ is not None - and not (name.startswith('__') and name.endswith('__'))): - return False - return skip diff --git a/docs/conf.py b/docs/conf.py index 7ae3c126..aeada340 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -25,7 +25,7 @@ import mopidy # Add any Sphinx extension module names here, as strings. They can be extensions # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. -extensions = ['sphinx.ext.autodoc', 'autodoc_private_members', +extensions = ['sphinx.ext.autodoc', 'sphinx.ext.graphviz', 'sphinx.ext.inheritance_diagram', 'sphinx.ext.extlinks', 'sphinx.ext.viewcode'] From 6c68b17b45c1e65411d13f49e4238857feb0a141 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 4 Jun 2011 19:44:08 +0200 Subject: [PATCH 148/218] Fix 'commands' and 'notcommands' for unauthenticated users Use newly gained access to the current user's authentication state and the command handler's auth_required flag to give correct 'commands' and 'notcommands' output to unauthenticated users when password authentication is activated. --- docs/changes.rst | 4 ++ mopidy/frontends/mpd/protocol/reflection.py | 43 ++++++++++++--------- tests/frontends/mpd/reflection_test.py | 28 ++++++++++++++ 3 files changed, 56 insertions(+), 19 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 37ce22c1..b4d56711 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -40,6 +40,10 @@ No description yet. - Do not allow access to the command ``kill``. + - ``commands`` and ``notcommands`` now have correct output if password + authentication is turned on, but the connected user has not been + authenticated yet. + v0.4.1 (2011-05-06) =================== diff --git a/mopidy/frontends/mpd/protocol/reflection.py b/mopidy/frontends/mpd/protocol/reflection.py index fd47e57e..920f48a5 100644 --- a/mopidy/frontends/mpd/protocol/reflection.py +++ b/mopidy/frontends/mpd/protocol/reflection.py @@ -10,23 +10,29 @@ def commands(context): Shows which commands the current user has access to. """ - # FIXME When password auth is turned on and the client is not - # authenticated, 'commands' should list only the commands the client does - # have access to. To implement this we need access to the session object to - # check if the client is authenticated or not. - - command_names = [command.name for command in mpd_commands] + if context.dispatcher.authenticated: + command_names = [command.name for command in mpd_commands] + else: + command_names = [command.name for command in mpd_commands + if not command.auth_required] # No permission to use - command_names.remove('kill') + if 'kill' in command_names: + command_names.remove('kill') # Not shown by MPD in its command list - command_names.remove('command_list_begin') - command_names.remove('command_list_ok_begin') - command_names.remove('command_list_end') - command_names.remove('idle') - command_names.remove('noidle') - command_names.remove('sticker') + if 'command_list_begin' in command_names: + command_names.remove('command_list_begin') + if 'command_list_ok_begin' in command_names: + command_names.remove('command_list_ok_begin') + if 'command_list_end' in command_names: + command_names.remove('command_list_end') + if 'idle' in command_names: + command_names.remove('idle') + if 'noidle' in command_names: + command_names.remove('noidle') + if 'sticker' in command_names: + command_names.remove('sticker') return [('command', command_name) for command_name in sorted(command_names)] @@ -58,12 +64,11 @@ def notcommands(context): Shows which commands the current user does not have access to. """ - # FIXME When password auth is turned on and the client is not - # authenticated, 'notcommands' should list all the commands the client does - # not have access to. To implement this we need access to the session - # object to check if the client is authenticated or not. - - command_names = [] + if context.dispatcher.authenticated: + command_names = [] + else: + command_names = [command.name for command in mpd_commands + if command.auth_required] # No permission to use command_names.append('kill') diff --git a/tests/frontends/mpd/reflection_test.py b/tests/frontends/mpd/reflection_test.py index adc34338..2abf5acc 100644 --- a/tests/frontends/mpd/reflection_test.py +++ b/tests/frontends/mpd/reflection_test.py @@ -1,5 +1,6 @@ import unittest +from mopidy import settings from mopidy.backends.dummy import DummyBackend from mopidy.frontends.mpd.dispatcher import MpdDispatcher from mopidy.mixers.dummy import DummyMixer @@ -11,6 +12,7 @@ class ReflectionHandlerTest(unittest.TestCase): self.dispatcher = MpdDispatcher() def tearDown(self): + settings.runtime.clear() self.backend.stop().get() self.mixer.stop().get() @@ -31,6 +33,19 @@ class ReflectionHandlerTest(unittest.TestCase): self.assert_(u'command: sticker' not in result) self.assert_(u'OK' in result) + def test_commands_show_less_if_auth_required_and_not_authed(self): + settings.MPD_SERVER_PASSWORD = u'secret' + result = self.dispatcher.handle_request(u'commands') + # Not requiring auth + self.assert_(u'command: close' in result, result) + self.assert_(u'command: commands' in result, result) + self.assert_(u'command: notcommands' in result, result) + self.assert_(u'command: password' in result, result) + self.assert_(u'command: ping' in result, result) + # Requiring auth + self.assert_(u'command: play' not in result, result) + self.assert_(u'command: status' not in result, result) + def test_decoders(self): result = self.dispatcher.handle_request(u'decoders') self.assert_(u'ACK [0@0] {} Not implemented' in result) @@ -41,6 +56,19 @@ class ReflectionHandlerTest(unittest.TestCase): self.assert_(u'command: kill' in result) self.assert_(u'OK' in result) + def test_notcommands_returns_more_if_auth_required_and_not_authed(self): + settings.MPD_SERVER_PASSWORD = u'secret' + result = self.dispatcher.handle_request(u'notcommands') + # Not requiring auth + self.assert_(u'command: close' not in result, result) + self.assert_(u'command: commands' not in result, result) + self.assert_(u'command: notcommands' not in result, result) + self.assert_(u'command: password' not in result, result) + self.assert_(u'command: ping' not in result, result) + # Requiring auth + self.assert_(u'command: play' in result, result) + self.assert_(u'command: status' in result, result) + def test_tagtypes(self): result = self.dispatcher.handle_request(u'tagtypes') self.assert_(u'OK' in result) From 958983113dac0dfeaeaa92dd2ef6d519af3ee747 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 4 Jun 2011 20:25:02 +0200 Subject: [PATCH 149/218] Improve 'status' performance by about 30% by sending requests for all needed data at once, block for all of them to arrive, and then return the result. This reduces the number of thread switches needed. --- mopidy/frontends/mpd/protocol/status.py | 127 ++++++++++++++---------- 1 file changed, 72 insertions(+), 55 deletions(-) diff --git a/mopidy/frontends/mpd/protocol/status.py b/mopidy/frontends/mpd/protocol/status.py index 58fefa11..bf0c4f08 100644 --- a/mopidy/frontends/mpd/protocol/status.py +++ b/mopidy/frontends/mpd/protocol/status.py @@ -1,3 +1,5 @@ +import pykka.future + from mopidy.backends.base import PlaybackController from mopidy.frontends.mpd.protocol import handle_request from mopidy.frontends.mpd.exceptions import MpdNotImplemented @@ -130,65 +132,80 @@ def status(context): - ``updatings_db``: job id - ``error``: if there is an error, returns message here """ + futures = { + 'current_playlist.tracks': context.backend.current_playlist.tracks, + 'current_playlist.version': context.backend.current_playlist.version, + 'mixer.volume': context.mixer.volume, + 'playback.consume': context.backend.playback.consume, + 'playback.random': context.backend.playback.random, + 'playback.repeat': context.backend.playback.repeat, + 'playback.single': context.backend.playback.single, + 'playback.state': context.backend.playback.state, + 'playback.current_cp_track': context.backend.playback.current_cp_track, + 'playback.current_playlist_position': + context.backend.playback.current_playlist_position, + 'playback.time_position': context.backend.playback.time_position, + } + pykka.future.get_all(futures.values()) result = [ - ('volume', _status_volume(context)), - ('repeat', _status_repeat(context)), - ('random', _status_random(context)), - ('single', _status_single(context)), - ('consume', _status_consume(context)), - ('playlist', _status_playlist_version(context)), - ('playlistlength', _status_playlist_length(context)), - ('xfade', _status_xfade(context)), - ('state', _status_state(context)), + ('volume', _status_volume(futures)), + ('repeat', _status_repeat(futures)), + ('random', _status_random(futures)), + ('single', _status_single(futures)), + ('consume', _status_consume(futures)), + ('playlist', _status_playlist_version(futures)), + ('playlistlength', _status_playlist_length(futures)), + ('xfade', _status_xfade(futures)), + ('state', _status_state(futures)), ] - if context.backend.playback.current_track.get() is not None: - result.append(('song', _status_songpos(context))) - result.append(('songid', _status_songid(context))) - if context.backend.playback.state.get() in (PlaybackController.PLAYING, + if futures['playback.current_cp_track'].get() is not None: + result.append(('song', _status_songpos(futures))) + result.append(('songid', _status_songid(futures))) + if futures['playback.state'].get() in (PlaybackController.PLAYING, PlaybackController.PAUSED): - result.append(('time', _status_time(context))) - result.append(('elapsed', _status_time_elapsed(context))) - result.append(('bitrate', _status_bitrate(context))) + result.append(('time', _status_time(futures))) + result.append(('elapsed', _status_time_elapsed(futures))) + result.append(('bitrate', _status_bitrate(futures))) return result -def _status_bitrate(context): - current_track = context.backend.playback.current_track.get() - if current_track is not None: - return current_track.bitrate +def _status_bitrate(futures): + current_cp_track = futures['playback.current_cp_track'].get() + if current_cp_track is not None: + return current_cp_track[1].bitrate -def _status_consume(context): - if context.backend.playback.consume.get(): +def _status_consume(futures): + if futures['playback.consume'].get(): return 1 else: return 0 -def _status_playlist_length(context): - return len(context.backend.current_playlist.tracks.get()) +def _status_playlist_length(futures): + return len(futures['current_playlist.tracks'].get()) -def _status_playlist_version(context): - return context.backend.current_playlist.version.get() +def _status_playlist_version(futures): + return futures['current_playlist.version'].get() -def _status_random(context): - return int(context.backend.playback.random.get()) +def _status_random(futures): + return int(futures['playback.random'].get()) -def _status_repeat(context): - return int(context.backend.playback.repeat.get()) +def _status_repeat(futures): + return int(futures['playback.repeat'].get()) -def _status_single(context): - return int(context.backend.playback.single.get()) +def _status_single(futures): + return int(futures['playback.single'].get()) -def _status_songid(context): - current_cpid = context.backend.playback.current_cpid.get() - if current_cpid is not None: - return current_cpid +def _status_songid(futures): + current_cp_track = futures['playback.current_cp_track'].get() + if current_cp_track is not None: + return current_cp_track[0] else: - return _status_songpos(context) + return _status_songpos(futures) -def _status_songpos(context): - return context.backend.playback.current_playlist_position.get() +def _status_songpos(futures): + return futures['playback.current_playlist_position'].get() -def _status_state(context): - state = context.backend.playback.state.get() +def _status_state(futures): + state = futures['playback.state'].get() if state == PlaybackController.PLAYING: return u'play' elif state == PlaybackController.STOPPED: @@ -196,28 +213,28 @@ def _status_state(context): elif state == PlaybackController.PAUSED: return u'pause' -def _status_time(context): - return u'%s:%s' % (_status_time_elapsed(context) // 1000, - _status_time_total(context) // 1000) +def _status_time(futures): + return u'%s:%s' % (_status_time_elapsed(futures) // 1000, + _status_time_total(futures) // 1000) -def _status_time_elapsed(context): - return context.backend.playback.time_position.get() +def _status_time_elapsed(futures): + return futures['playback.time_position'].get() -def _status_time_total(context): - current_track = context.backend.playback.current_track.get() - if current_track is None: +def _status_time_total(futures): + current_cp_track = futures['playback.current_cp_track'].get() + if current_cp_track is None: return 0 - elif current_track.length is None: + elif current_cp_track[1].length is None: return 0 else: - return current_track.length + return current_cp_track[1].length -def _status_volume(context): - volume = context.mixer.volume.get() +def _status_volume(futures): + volume = futures['mixer.volume'].get() if volume is not None: return volume else: return 0 -def _status_xfade(context): - return 0 # TODO +def _status_xfade(futures): + return 0 # Not supported From caedac252eb8da7d2c12d888bbdd4ec843eb5767 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 4 Jun 2011 20:45:03 +0200 Subject: [PATCH 150/218] Move import into methods where needed to avoid import loops when importing mopidy.models --- mopidy/models.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mopidy/models.py b/mopidy/models.py index ef60ebbe..2ede4352 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -1,4 +1,3 @@ -from mopidy.frontends.mpd import translator class ImmutableObject(object): """ @@ -183,6 +182,7 @@ class Track(ImmutableObject): super(Track, self).__init__(*args, **kwargs) def mpd_format(self, *args, **kwargs): + from mopidy.frontends.mpd import translator return translator.track_to_mpd_format(self, *args, **kwargs) @@ -222,4 +222,5 @@ class Playlist(ImmutableObject): return len(self.tracks) def mpd_format(self, *args, **kwargs): + from mopidy.frontends.mpd import translator return translator.playlist_to_mpd_format(self, *args, **kwargs) From 3f97f3f78b4bfabff53c3201ae24ba7e6d8550e6 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 4 Jun 2011 20:46:34 +0200 Subject: [PATCH 151/218] Convert cp_track from tuple to namedtuple, to get more readable code --- mopidy/backends/base/current_playlist.py | 4 +++- mopidy/backends/base/playback.py | 8 ++++---- .../frontends/mpd/protocol/current_playlist.py | 4 ++-- mopidy/frontends/mpd/protocol/status.py | 12 ++++++------ mopidy/models.py | 4 ++++ tests/backends/base/current_playlist.py | 8 ++++---- tests/models_test.py | 17 ++++++++++++++++- 7 files changed, 39 insertions(+), 18 deletions(-) diff --git a/mopidy/backends/base/current_playlist.py b/mopidy/backends/base/current_playlist.py index ffdce176..2633f166 100644 --- a/mopidy/backends/base/current_playlist.py +++ b/mopidy/backends/base/current_playlist.py @@ -2,6 +2,8 @@ from copy import copy import logging import random +from mopidy.models import CpTrack + logger = logging.getLogger('mopidy.backends.base') class CurrentPlaylistController(object): @@ -66,7 +68,7 @@ class CurrentPlaylistController(object): """ assert at_position <= len(self._cp_tracks), \ u'at_position can not be greater than playlist length' - cp_track = (self.version, track) + cp_track = CpTrack(self.version, track) if at_position is not None: self._cp_tracks.insert(at_position, cp_track) else: diff --git a/mopidy/backends/base/playback.py b/mopidy/backends/base/playback.py index 88ae141d..4ea7a13f 100644 --- a/mopidy/backends/base/playback.py +++ b/mopidy/backends/base/playback.py @@ -80,12 +80,12 @@ class PlaybackController(object): def _get_cpid(self, cp_track): if cp_track is None: return None - return cp_track[0] + return cp_track.cpid def _get_track(self, cp_track): if cp_track is None: return None - return cp_track[1] + return cp_track.track @property def current_cpid(self): @@ -331,7 +331,7 @@ class PlaybackController(object): self.stop(clear_current_track=True) if self.consume: - self.backend.current_playlist.remove(cpid=original_cp_track[0]) + self.backend.current_playlist.remove(cpid=original_cp_track.cpid) def on_current_playlist_change(self): """ @@ -389,7 +389,7 @@ class PlaybackController(object): self.state = self.STOPPED self.current_cp_track = cp_track self.state = self.PLAYING - if not self.provider.play(cp_track[1]): + if not self.provider.play(cp_track.track): # Track is not playable if self.random and self._shuffled: self._shuffled.remove(cp_track) diff --git a/mopidy/frontends/mpd/protocol/current_playlist.py b/mopidy/frontends/mpd/protocol/current_playlist.py index 82e096a0..8e26013d 100644 --- a/mopidy/frontends/mpd/protocol/current_playlist.py +++ b/mopidy/frontends/mpd/protocol/current_playlist.py @@ -59,7 +59,7 @@ def addid(context, uri, songpos=None): raise MpdArgError(u'Bad song index', command=u'addid') cp_track = context.backend.current_playlist.add(track, at_position=songpos).get() - return ('Id', cp_track[0]) + return ('Id', cp_track.cpid) @handle_request(r'^delete "(?P\d+):(?P\d+)*"$') def delete_range(context, start, end=None): @@ -217,7 +217,7 @@ def playlistid(context, cpid=None): cp_track = context.backend.current_playlist.get(cpid=cpid).get() position = context.backend.current_playlist.cp_tracks.get().index( cp_track) - return cp_track[1].mpd_format(position=position, cpid=cpid) + return cp_track.track.mpd_format(position=position, cpid=cpid) except LookupError: raise MpdNoExistError(u'No such song', command=u'playlistid') else: diff --git a/mopidy/frontends/mpd/protocol/status.py b/mopidy/frontends/mpd/protocol/status.py index bf0c4f08..abbb8d7f 100644 --- a/mopidy/frontends/mpd/protocol/status.py +++ b/mopidy/frontends/mpd/protocol/status.py @@ -28,9 +28,9 @@ def currentsong(context): """ current_cp_track = context.backend.playback.current_cp_track.get() if current_cp_track is not None: - return current_cp_track[1].mpd_format( + return current_cp_track.track.mpd_format( position=context.backend.playback.current_playlist_position.get(), - cpid=current_cp_track[0]) + cpid=current_cp_track.cpid) @handle_request(r'^idle$') @handle_request(r'^idle (?P.+)$') @@ -171,7 +171,7 @@ def status(context): def _status_bitrate(futures): current_cp_track = futures['playback.current_cp_track'].get() if current_cp_track is not None: - return current_cp_track[1].bitrate + return current_cp_track.track.bitrate def _status_consume(futures): if futures['playback.consume'].get(): @@ -197,7 +197,7 @@ def _status_single(futures): def _status_songid(futures): current_cp_track = futures['playback.current_cp_track'].get() if current_cp_track is not None: - return current_cp_track[0] + return current_cp_track.cpid else: return _status_songpos(futures) @@ -224,10 +224,10 @@ def _status_time_total(futures): current_cp_track = futures['playback.current_cp_track'].get() if current_cp_track is None: return 0 - elif current_cp_track[1].length is None: + elif current_cp_track.track.length is None: return 0 else: - return current_cp_track[1].length + return current_cp_track.track.length def _status_volume(futures): volume = futures['mixer.volume'].get() diff --git a/mopidy/models.py b/mopidy/models.py index 2ede4352..ed323b71 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -1,3 +1,4 @@ +from collections import namedtuple class ImmutableObject(object): """ @@ -128,6 +129,9 @@ class Album(ImmutableObject): super(Album, self).__init__(*args, **kwargs) +CpTrack = namedtuple('CpTrack', ['cpid', 'track']) + + class Track(ImmutableObject): """ :param uri: track URI diff --git a/tests/backends/base/current_playlist.py b/tests/backends/base/current_playlist.py index 427ce76d..b84391af 100644 --- a/tests/backends/base/current_playlist.py +++ b/tests/backends/base/current_playlist.py @@ -23,14 +23,14 @@ class CurrentPlaylistControllerTest(object): cp_track = self.controller.add(track) self.assertEqual(track, self.controller.tracks[-1]) self.assertEqual(cp_track, self.controller.cp_tracks[-1]) - self.assertEqual(track, cp_track[1]) + self.assertEqual(track, cp_track.track) def test_add_at_position(self): for track in self.tracks[:-1]: cp_track = self.controller.add(track, 0) self.assertEqual(track, self.controller.tracks[0]) self.assertEqual(cp_track, self.controller.cp_tracks[0]) - self.assertEqual(track, cp_track[1]) + self.assertEqual(track, cp_track.track) @populate_playlist def test_add_at_position_outside_of_playlist(self): @@ -40,12 +40,12 @@ class CurrentPlaylistControllerTest(object): @populate_playlist def test_get_by_cpid(self): cp_track = self.controller.cp_tracks[1] - self.assertEqual(cp_track, self.controller.get(cpid=cp_track[0])) + self.assertEqual(cp_track, self.controller.get(cpid=cp_track.cpid)) @populate_playlist def test_get_by_uri(self): cp_track = self.controller.cp_tracks[1] - self.assertEqual(cp_track, self.controller.get(uri=cp_track[1].uri)) + self.assertEqual(cp_track, self.controller.get(uri=cp_track.track.uri)) @populate_playlist def test_get_by_uri_raises_error_for_invalid_uri(self): diff --git a/tests/models_test.py b/tests/models_test.py index afbf9d50..637a8209 100644 --- a/tests/models_test.py +++ b/tests/models_test.py @@ -1,7 +1,7 @@ import datetime as dt import unittest -from mopidy.models import Artist, Album, Track, Playlist +from mopidy.models import Artist, Album, CpTrack, Track, Playlist from tests import SkipTest @@ -274,6 +274,21 @@ class AlbumTest(unittest.TestCase): self.assertNotEqual(hash(album1), hash(album2)) +class CpTrackTest(unittest.TestCase): + def setUp(self): + self.cpid = 123 + self.track = Track() + self.cp_track = CpTrack(self.cpid, self.track) + + def test_cp_track_can_be_accessed_as_a_tuple(self): + self.assertEqual(self.cpid, self.cp_track[0]) + self.assertEqual(self.track, self.cp_track[1]) + + def test_cp_track_can_be_accessed_by_attribute_names(self): + self.assertEqual(self.cpid, self.cp_track.cpid) + self.assertEqual(self.track, self.cp_track.track) + + class TrackTest(unittest.TestCase): def test_uri(self): uri = u'an_uri' From 03dbbeb9cf3aebe67bedeb5e1b13aff08dc3d3f3 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 6 Jun 2011 18:51:10 +0200 Subject: [PATCH 152/218] Ignore .idea --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 3fed7452..21adc7af 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ *.pyc *.swp .coverage +.idea .noseids .tox MANIFEST From 30afee49c5c5ed1c03d85b6b50c84e60e3e0c5c0 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 7 Jun 2011 01:55:36 +0200 Subject: [PATCH 153/218] Import entire exceptions module to make import list shorter --- mopidy/frontends/mpd/dispatcher.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/mopidy/frontends/mpd/dispatcher.py b/mopidy/frontends/mpd/dispatcher.py index 29ceacd1..91cdc5e7 100644 --- a/mopidy/frontends/mpd/dispatcher.py +++ b/mopidy/frontends/mpd/dispatcher.py @@ -6,8 +6,7 @@ from pykka.registry import ActorRegistry from mopidy import settings from mopidy.backends.base import Backend -from mopidy.frontends.mpd.exceptions import (MpdAckError, MpdArgError, - MpdPermissionError, MpdSystemError, MpdUnknownCommand) +from mopidy.frontends.mpd import exceptions from mopidy.frontends.mpd.protocol import mpd_commands, request_handlers # Do not remove the following import. The protocol modules must be imported to # get them registered as request handlers. @@ -61,7 +60,7 @@ class MpdDispatcher(object): def _catch_mpd_ack_errors_filter(self, request, response, filter_chain): try: return self._call_next_filter(request, response, filter_chain) - except MpdAckError as mpd_ack_error: + except exceptions.MpdAckError as mpd_ack_error: if self.command_list_index is not None: mpd_ack_error.index = self.command_list_index return [mpd_ack_error.get_mpd_ack()] @@ -83,7 +82,7 @@ class MpdDispatcher(object): if command_name in command_names_not_requiring_auth: return self._call_next_filter(request, response, filter_chain) else: - raise MpdPermissionError(command=command_name) + raise exceptions.MpdPermissionError(command=command_name) ### Filter: command list @@ -129,7 +128,7 @@ class MpdDispatcher(object): return self._call_next_filter(request, response, filter_chain) except ActorDeadError as e: logger.warning(u'Tried to communicate with dead actor.') - raise MpdSystemError(e.message) + raise exceptions.MpdSystemError(e.message) def _call_handler(self, request): (handler, kwargs) = self._find_handler(request) @@ -142,8 +141,9 @@ class MpdDispatcher(object): return (request_handlers[pattern], matches.groupdict()) command_name = request.split(' ')[0] if command_name in [command.name for command in mpd_commands]: - raise MpdArgError(u'incorrect arguments', command=command_name) - raise MpdUnknownCommand(command=command_name) + raise exceptions.MpdArgError(u'incorrect arguments', + command=command_name) + raise exceptions.MpdUnknownCommand(command=command_name) def _format_response(self, response): formatted_response = [] From 92937bd3ac90b8582634f95f4c29c5a8050a4b42 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 7 Jun 2011 02:26:58 +0200 Subject: [PATCH 154/218] Make the error_code a class attribute on MPD exceptions --- mopidy/frontends/mpd/exceptions.py | 33 ++++++++++++--------------- tests/frontends/mpd/exception_test.py | 5 ++-- 2 files changed, 17 insertions(+), 21 deletions(-) diff --git a/mopidy/frontends/mpd/exceptions.py b/mopidy/frontends/mpd/exceptions.py index c6020658..661d6905 100644 --- a/mopidy/frontends/mpd/exceptions.py +++ b/mopidy/frontends/mpd/exceptions.py @@ -16,10 +16,11 @@ class MpdAckError(MopidyException): ACK_ERROR_PLAYER_SYNC = 55 ACK_ERROR_EXIST = 56 - def __init__(self, message=u'', error_code=0, index=0, command=u''): - super(MpdAckError, self).__init__(message, error_code, index, command) + error_code = 0 + + def __init__(self, message=u'', index=0, command=u''): + super(MpdAckError, self).__init__(message, index, command) self.message = message - self.error_code = error_code self.index = index self.command = command @@ -30,42 +31,38 @@ class MpdAckError(MopidyException): ACK [%(error_code)i@%(index)i] {%(command)s} description """ return u'ACK [%i@%i] {%s} %s' % ( - self.error_code, self.index, self.command, self.message) + self.__class__.error_code, self.index, self.command, self.message) class MpdArgError(MpdAckError): - def __init__(self, *args, **kwargs): - super(MpdArgError, self).__init__(*args, **kwargs) - self.error_code = MpdAckError.ACK_ERROR_ARG + error_code = MpdAckError.ACK_ERROR_ARG class MpdPasswordError(MpdAckError): - def __init__(self, *args, **kwargs): - super(MpdPasswordError, self).__init__(*args, **kwargs) - self.error_code = MpdAckError.ACK_ERROR_PASSWORD + error_code = MpdAckError.ACK_ERROR_PASSWORD class MpdPermissionError(MpdAckError): + error_code = MpdAckError.ACK_ERROR_PERMISSION + def __init__(self, *args, **kwargs): super(MpdPermissionError, self).__init__(*args, **kwargs) self.message = u'you don\'t have permission for "%s"' % self.command - self.error_code = MpdAckError.ACK_ERROR_PERMISSION class MpdUnknownCommand(MpdAckError): + error_code = MpdAckError.ACK_ERROR_UNKNOWN + def __init__(self, *args, **kwargs): super(MpdUnknownCommand, self).__init__(*args, **kwargs) self.message = u'unknown command "%s"' % self.command self.command = u'' - self.error_code = MpdAckError.ACK_ERROR_UNKNOWN class MpdNoExistError(MpdAckError): - def __init__(self, *args, **kwargs): - super(MpdNoExistError, self).__init__(*args, **kwargs) - self.error_code = MpdAckError.ACK_ERROR_NO_EXIST + error_code = MpdAckError.ACK_ERROR_NO_EXIST class MpdSystemError(MpdAckError): - def __init__(self, *args, **kwargs): - super(MpdSystemError, self).__init__(*args, **kwargs) - self.error_code = MpdAckError.ACK_ERROR_SYSTEM + error_code = MpdAckError.ACK_ERROR_SYSTEM class MpdNotImplemented(MpdAckError): + error_code = 0 + def __init__(self, *args, **kwargs): super(MpdNotImplemented, self).__init__(*args, **kwargs) self.message = u'Not implemented' diff --git a/tests/frontends/mpd/exception_test.py b/tests/frontends/mpd/exception_test.py index 6750189b..df2cd65e 100644 --- a/tests/frontends/mpd/exception_test.py +++ b/tests/frontends/mpd/exception_test.py @@ -25,10 +25,9 @@ class MpdExceptionsTest(unittest.TestCase): def test_get_mpd_ack_with_values(self): try: - raise MpdAckError('A description', error_code=6, index=7, - command='foo') + raise MpdAckError('A description', index=7, command='foo') except MpdAckError as e: - self.assertEqual(e.get_mpd_ack(), u'ACK [6@7] {foo} A description') + self.assertEqual(e.get_mpd_ack(), u'ACK [0@7] {foo} A description') def test_mpd_unknown_command(self): try: From d664c11e2285560e56aaaec646a578fa64b84d7e Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 7 Jun 2011 14:09:15 +0200 Subject: [PATCH 155/218] Pull network related functions out of mopidy.frontends.mpd.server --- mopidy/frontends/mpd/server.py | 33 +++------------------------ mopidy/utils/network.py | 36 ++++++++++++++++++++++++++++++ tests/frontends/mpd/server_test.py | 23 ------------------- tests/utils/network_test.py | 19 ++++++++++++++++ 4 files changed, 58 insertions(+), 53 deletions(-) create mode 100644 mopidy/utils/network.py create mode 100644 tests/utils/network_test.py diff --git a/mopidy/frontends/mpd/server.py b/mopidy/frontends/mpd/server.py index 1be46ef4..87a1cd0a 100644 --- a/mopidy/frontends/mpd/server.py +++ b/mopidy/frontends/mpd/server.py @@ -1,28 +1,13 @@ import asyncore import logging -import re -import socket import sys from mopidy import settings +from mopidy.utils import network from .session import MpdSession logger = logging.getLogger('mopidy.frontends.mpd.server') -def _try_ipv6_socket(): - """Determine if system really supports IPv6""" - if not socket.has_ipv6: - return False - try: - socket.socket(socket.AF_INET6).close() - return True - except IOError, e: - logger.debug(u'Platform supports IPv6, but socket ' - 'creation failed, disabling: %s', e) - return False - -has_ipv6 = _try_ipv6_socket() - class MpdServer(asyncore.dispatcher): """ The MPD server. Creates a :class:`mopidy.frontends.mpd.session.MpdSession` @@ -35,15 +20,9 @@ class MpdServer(asyncore.dispatcher): def start(self): """Start MPD server.""" try: - if has_ipv6: - self.create_socket(socket.AF_INET6, socket.SOCK_STREAM) - # Explicitly configure socket to work for both IPv4 and IPv6 - self.socket.setsockopt( - socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0) - else: - self.create_socket(socket.AF_INET, socket.SOCK_STREAM) + self.socket = network.create_socket() self.set_reuse_addr() - hostname = self._format_hostname(settings.MPD_SERVER_HOSTNAME) + hostname = network.format_hostname(settings.MPD_SERVER_HOSTNAME) port = settings.MPD_SERVER_PORT logger.debug(u'MPD server is binding to [%s]:%s', hostname, port) self.bind((hostname, port)) @@ -65,9 +44,3 @@ class MpdServer(asyncore.dispatcher): def handle_close(self): """Handle end of client connection.""" self.close() - - def _format_hostname(self, hostname): - if (has_ipv6 - and re.match('\d+.\d+.\d+.\d+', hostname) is not None): - hostname = '::ffff:%s' % hostname - return hostname diff --git a/mopidy/utils/network.py b/mopidy/utils/network.py new file mode 100644 index 00000000..1dedf7d7 --- /dev/null +++ b/mopidy/utils/network.py @@ -0,0 +1,36 @@ +import logging +import re +import socket + +logger = logging.getLogger('mopidy.utils.server') + +def _try_ipv6_socket(): + """Determine if system really supports IPv6""" + if not socket.has_ipv6: + return False + try: + socket.socket(socket.AF_INET6).close() + return True + except IOError, e: + logger.debug(u'Platform supports IPv6, but socket ' + 'creation failed, disabling: %s', e) + return False + +#: Boolean value that indicates if creating an IPv6 socket will succeed. +has_ipv6 = _try_ipv6_socket() + +def create_socket(): + """Create a TCP socket with or without IPv6 depending on system support""" + if has_ipv6: + sock = socket.socket(socket.AF_INET6, socket.SOCK_STREAM) + # Explicitly configure socket to work for both IPv4 and IPv6 + sock.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0) + else: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + return sock + +def format_hostname(hostname): + """Format hostname for display.""" + if (has_ipv6 and re.match('\d+.\d+.\d+.\d+', hostname) is not None): + hostname = '::ffff:%s' % hostname + return hostname diff --git a/tests/frontends/mpd/server_test.py b/tests/frontends/mpd/server_test.py index 32e90450..76bf9e33 100644 --- a/tests/frontends/mpd/server_test.py +++ b/tests/frontends/mpd/server_test.py @@ -5,29 +5,6 @@ from mopidy.backends.dummy import DummyBackend from mopidy.frontends.mpd import server from mopidy.mixers.dummy import DummyMixer -class MpdServerTest(unittest.TestCase): - def setUp(self): - self.backend = DummyBackend.start().proxy() - self.mixer = DummyMixer.start().proxy() - self.server = server.MpdServer() - self.has_ipv6 = server.has_ipv6 - - def tearDown(self): - self.backend.stop().get() - self.mixer.stop().get() - server.has_ipv6 = self.has_ipv6 - - def test_format_hostname_prefixes_ipv4_addresses_when_ipv6_available(self): - server.has_ipv6 = True - self.assertEqual(self.server._format_hostname('0.0.0.0'), - '::ffff:0.0.0.0') - self.assertEqual(self.server._format_hostname('127.0.0.1'), - '::ffff:127.0.0.1') - - def test_format_hostname_does_nothing_when_only_ipv4_available(self): - server.has_ipv6 = False - self.assertEquals(self.server._format_hostname('0.0.0.0'), '0.0.0.0') - class MpdSessionTest(unittest.TestCase): def setUp(self): self.backend = DummyBackend.start().proxy() diff --git a/tests/utils/network_test.py b/tests/utils/network_test.py new file mode 100644 index 00000000..6217e910 --- /dev/null +++ b/tests/utils/network_test.py @@ -0,0 +1,19 @@ +import unittest + +from mopidy.utils import network + +class FormatHostnameTest(unittest.TestCase): + def setUp(self): + self.has_ipv6 = network.has_ipv6 + + def tearDown(self): + network.has_ipv6 = self.has_ipv6 + + def test_format_hostname_prefixes_ipv4_addresses_when_ipv6_available(self): + network.has_ipv6 = True + self.assertEqual(network.format_hostname('0.0.0.0'), '::ffff:0.0.0.0') + self.assertEqual(network.format_hostname('1.0.0.1'), '::ffff:1.0.0.1') + + def test_format_hostname_does_nothing_when_only_ipv4_available(self): + network.has_ipv6 = False + self.assertEquals(network.format_hostname('0.0.0.0'), '0.0.0.0') From ea9159a9babc17da7505ba334b0b71cd4c45a28c Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 7 Jun 2011 15:23:33 +0200 Subject: [PATCH 156/218] Add test for try_ipv6_socket --- tests/utils/network_test.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/utils/network_test.py b/tests/utils/network_test.py index 6217e910..5a753f6e 100644 --- a/tests/utils/network_test.py +++ b/tests/utils/network_test.py @@ -1,3 +1,4 @@ +import mock import unittest from mopidy.utils import network @@ -17,3 +18,20 @@ class FormatHostnameTest(unittest.TestCase): def test_format_hostname_does_nothing_when_only_ipv4_available(self): network.has_ipv6 = False self.assertEquals(network.format_hostname('0.0.0.0'), '0.0.0.0') + +class TryIPv6SocketTest(unittest.TestCase): + @mock.patch('socket.has_ipv6', False) + def test_system_that_claims_no_ipv6_support(self): + self.assertFalse(network._try_ipv6_socket()) + + @mock.patch('socket.has_ipv6', True) + @mock.patch('socket.socket') + def test_system_with_broken_ipv6(self, socket_mock): + socket_mock.side_effect = IOError() + self.assertFalse(network._try_ipv6_socket()) + + @mock.patch('socket.has_ipv6', True) + @mock.patch('socket.socket') + def test_with_working_ipv6(self, socket_mock): + socket_mock.return_value = mock.Mock() + self.assertTrue(network._try_ipv6_socket()) From 14a9a3fb6684f3eab628d59adacbff0a154716e7 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 7 Jun 2011 15:25:48 +0200 Subject: [PATCH 157/218] Use mocking for network.has_ipv6 monkey patching --- tests/utils/network_test.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/tests/utils/network_test.py b/tests/utils/network_test.py index 5a753f6e..2f0cda66 100644 --- a/tests/utils/network_test.py +++ b/tests/utils/network_test.py @@ -4,17 +4,13 @@ import unittest from mopidy.utils import network class FormatHostnameTest(unittest.TestCase): - def setUp(self): - self.has_ipv6 = network.has_ipv6 - - def tearDown(self): - network.has_ipv6 = self.has_ipv6 - + @mock.patch('mopidy.utils.network.has_ipv6', True) def test_format_hostname_prefixes_ipv4_addresses_when_ipv6_available(self): network.has_ipv6 = True self.assertEqual(network.format_hostname('0.0.0.0'), '::ffff:0.0.0.0') self.assertEqual(network.format_hostname('1.0.0.1'), '::ffff:1.0.0.1') + @mock.patch('mopidy.utils.network.has_ipv6', False) def test_format_hostname_does_nothing_when_only_ipv4_available(self): network.has_ipv6 = False self.assertEquals(network.format_hostname('0.0.0.0'), '0.0.0.0') From cf3b6dcb2bdf4bcea2ce5b5f375c6f17b0601141 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 7 Jun 2011 15:45:52 +0200 Subject: [PATCH 158/218] Add create socket test --- tests/utils/network_test.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/utils/network_test.py b/tests/utils/network_test.py index 2f0cda66..66229036 100644 --- a/tests/utils/network_test.py +++ b/tests/utils/network_test.py @@ -1,8 +1,11 @@ import mock +import socket import unittest from mopidy.utils import network +from tests import SkipTest + class FormatHostnameTest(unittest.TestCase): @mock.patch('mopidy.utils.network.has_ipv6', True) def test_format_hostname_prefixes_ipv4_addresses_when_ipv6_available(self): @@ -15,6 +18,7 @@ class FormatHostnameTest(unittest.TestCase): network.has_ipv6 = False self.assertEquals(network.format_hostname('0.0.0.0'), '0.0.0.0') + class TryIPv6SocketTest(unittest.TestCase): @mock.patch('socket.has_ipv6', False) def test_system_that_claims_no_ipv6_support(self): @@ -31,3 +35,23 @@ class TryIPv6SocketTest(unittest.TestCase): def test_with_working_ipv6(self, socket_mock): socket_mock.return_value = mock.Mock() self.assertTrue(network._try_ipv6_socket()) + + +class CreateSocketTest(unittest.TestCase): + @mock.patch('mopidy.utils.network.has_ipv6', False) + @mock.patch('socket.socket') + def test_ipv4_socket(self, socket_mock): + network.create_socket() + self.assertEqual(socket_mock.call_args[0], + (socket.AF_INET, socket.SOCK_STREAM)) + + @mock.patch('mopidy.utils.network.has_ipv6', True) + @mock.patch('socket.socket') + def test_ipv6_socket(self, socket_mock): + network.create_socket() + self.assertEqual(socket_mock.call_args[0], + (socket.AF_INET6, socket.SOCK_STREAM)) + + @SkipTest + def test_ipv6_only_is_set(self): + pass From 20d3b48bb5ffb8d2fbbc2ee7c4b14b3ecc751d65 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 7 Jun 2011 16:02:29 +0200 Subject: [PATCH 159/218] 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 160/218] 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 8b9fb90449a74db0fa719f34c3ac7e5fe013a99a Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 7 Jun 2011 16:11:34 +0200 Subject: [PATCH 161/218] Fix logging of mopidy server port --- mopidy/frontends/mpd/server.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/mopidy/frontends/mpd/server.py b/mopidy/frontends/mpd/server.py index 68796c49..927e2a00 100644 --- a/mopidy/frontends/mpd/server.py +++ b/mopidy/frontends/mpd/server.py @@ -27,9 +27,7 @@ class MpdServer(asyncore.dispatcher): logger.debug(u'MPD server is binding to [%s]:%s', hostname, port) self.bind((hostname, port)) self.listen(1) - logger.info(u'MPD server running at [%s]:%s', - self._format_hostname(settings.MPD_SERVER_HOSTNAME), - settings.MPD_SERVER_PORT) + logger.info(u'MPD server running at [%s]:%s', hostname, port) except IOError, e: logger.error(u'MPD server startup failed: %s' % str(e).decode('utf-8')) From 474805c9bee6178875e0936dd9ce899f222d5857 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 7 Jun 2011 23:43:45 +0200 Subject: [PATCH 162/218] 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 163/218] 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 164/218] 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 165/218] 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 166/218] 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 167/218] 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 168/218] 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 169/218] 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 170/218] 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 171/218] 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 172/218] 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 173/218] 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 174/218] 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 175/218] 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 176/218] 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 177/218] 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 178/218] 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 179/218] 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 180/218] 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 181/218] 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 182/218] 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 183/218] 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 184/218] 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 185/218] 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 186/218] 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 187/218] 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 188/218] 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 189/218] 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 190/218] 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 191/218] 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 192/218] 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 193/218] 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 194/218] 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 195/218] 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 196/218] 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 197/218] 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 198/218] 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 199/218] 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 200/218] 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 201/218] 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 202/218] 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 203/218] 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 204/218] 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 205/218] 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 206/218] 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 207/218] 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 208/218] 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 209/218] 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 210/218] 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 211/218] 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 212/218] 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 213/218] 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""" From 30a8fd5e857f449d36a55dba89350783e1ed5a29 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 14 Jun 2011 14:56:25 +0200 Subject: [PATCH 214/218] Remove source file for outdated and removed sequence diagram --- docs/_static/thread_communication.txt | 37 --------------------------- 1 file changed, 37 deletions(-) delete mode 100644 docs/_static/thread_communication.txt diff --git a/docs/_static/thread_communication.txt b/docs/_static/thread_communication.txt deleted file mode 100644 index 4119004e..00000000 --- a/docs/_static/thread_communication.txt +++ /dev/null @@ -1,37 +0,0 @@ -Script for use with www.websequencediagrams.com -=============================================== - -Main -> Core: create -activate Core -note over Core: create NadMixer -Core -> NadTalker: create -activate NadTalker -note over NadTalker: calibrate device -note over Core: create DespotifyBackend -Core -> despotify: connect to Spotify -activate despotify -note over Core: create MpdFrontend -Main -> Server: create -activate Server -note over Server: open port -Client -> Server: connect -note over Server: open session -Client -> Server: play 1 -Server -> Core: play 1 -Core -> despotify: play first track -Client -> Server: setvol 50 -Server -> Core: setvol 50 -Core -> NadTalker: volume = 50 -Client -> Server: status -Server -> Core: status -Core -> NadTalker: volume? -NadTalker -> Core: volume = 50 -Core -> Server: status response -Server -> Client: status response -despotify -> Core: end of track callback -Core -> despotify: play second track -Client -> Server: stop -Server -> Core: stop -Core -> despotify: stop -Client -> Server: disconnect -note over Server: close session From 462f1bd89335b98af20506fa73b7e7c2f433af5e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 15 Jun 2011 22:17:35 +0200 Subject: [PATCH 215/218] Make tox work by allowing the use of global site-packages --- tox.ini | 1 + 1 file changed, 1 insertion(+) diff --git a/tox.ini b/tox.ini index 8b91c6b7..48676e46 100644 --- a/tox.ini +++ b/tox.ini @@ -4,6 +4,7 @@ envlist = py26,py27,docs [testenv] deps = nose commands = nosetests [] +sitepackages = True [testenv:docs] basepython = python From 6a779229058eb67fd42d51b680b0b3c119219288 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 15 Jun 2011 22:19:41 +0200 Subject: [PATCH 216/218] Ask about missing settings in alphabetic order --- 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 500477e2..cab94089 100644 --- a/mopidy/utils/settings.py +++ b/mopidy/utils/settings.py @@ -73,7 +73,7 @@ class SettingsProxy(object): raise SettingsError(u'Settings validation failed.') def _read_missing_settings_from_stdin(self, current, runtime): - for setting, value in current.iteritems(): + for setting, value in sorted(current.iteritems()): if isinstance(value, basestring) and len(value) == 0: runtime[setting] = self._read_from_stdin(setting + u': ') From e6e2ab58553516f7a9698bb8a67d44bd7bb00575 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 15 Jun 2011 22:31:54 +0200 Subject: [PATCH 217/218] Add shutdown procedure improvements to changelog --- docs/changes.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/changes.rst b/docs/changes.rst index c93e0ee8..63f7d336 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -74,6 +74,9 @@ Please note that 0.5.0 requires some updated dependencies, as listed under - Added :option:`--interactive` for reading missing local settings from ``stdin``. (Fixes: :issue:`96`) + - Improve shutdown procedure at CTRL+C. Add signal handler for ``SIGTERM``, + which initiates the same shutdown procedure as CTRL+C does. + - Tag cache generator: - Made it possible to abort :command:`mopidy-scan` with CTRL+C. From d30ee6a9688d070a5c7d75271cc06d8ef49f28f0 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 15 Jun 2011 22:32:57 +0200 Subject: [PATCH 218/218] Update changelog for v0.5.0 release --- docs/changes.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 63f7d336..4ccf62c9 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -5,12 +5,12 @@ Changes This change log is used to track all major changes to Mopidy. -v0.5.0 (in development) -======================= +v0.5.0 (2011-06-15) +=================== Since last time we've added support for audio streaming to SHOUTcast servers and fixed the longstanding playlist loading issue in the Spotify backend. As -always the release has a bunch of bug fixes. +always the release has a bunch of bug fixes and minor improvements. Please note that 0.5.0 requires some updated dependencies, as listed under *Important changes* below.