From 77fde2fce73571a6c602b1e97ba337891fe13532 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 14 Aug 2010 17:26:25 +0200 Subject: [PATCH 01/80] Remove skip test for local backend --- tests/backends/local_test.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/backends/local_test.py b/tests/backends/local_test.py index a5222276..ccc8bdd0 100644 --- a/tests/backends/local_test.py +++ b/tests/backends/local_test.py @@ -19,8 +19,6 @@ from tests import SkipTest, data_folder song = data_folder('song%s.wav') generate_song = lambda i: path_to_uri(song % i) -raise SkipTest - # FIXME can be switched to generic test class LocalCurrentPlaylistControllerTest(BaseCurrentPlaylistControllerTest, unittest.TestCase): From 8950385815210cbd89182eeda68f26a7ddd82cb0 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 14 Aug 2010 17:27:26 +0200 Subject: [PATCH 02/80] Update local backend tests to setup output and queues --- tests/backends/base.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/tests/backends/base.py b/tests/backends/base.py index 64ca7797..d0389c0d 100644 --- a/tests/backends/base.py +++ b/tests/backends/base.py @@ -4,10 +4,12 @@ import shutil import tempfile import threading import time +import multiprocessing from mopidy import settings from mopidy.mixers.dummy import DummyMixer from mopidy.models import Playlist, Track, Album, Artist +from mopidy.utils import get_class from tests import SkipTest, data_folder @@ -32,7 +34,10 @@ class BaseCurrentPlaylistControllerTest(object): backend_class = None def setUp(self): - self.backend = self.backend_class(mixer_class=DummyMixer) + self.output_queue = multiprocessing.Queue() + self.core_queue = multiprocessing.Queue() + self.output = get_class(settings.OUTPUT)(self.core_queue, self.output_queue) + self.backend = self.backend_class(self.core_queue, self.output_queue, DummyMixer) self.controller = self.backend.current_playlist self.playback = self.backend.playback @@ -40,6 +45,7 @@ class BaseCurrentPlaylistControllerTest(object): def tearDown(self): self.backend.destroy() + self.output.destroy() def test_add(self): for track in self.tracks: @@ -281,7 +287,10 @@ class BasePlaybackControllerTest(object): backend_class = None def setUp(self): - self.backend = self.backend_class(mixer_class=DummyMixer) + self.output_queue = multiprocessing.Queue() + self.core_queue = multiprocessing.Queue() + self.output = get_class(settings.OUTPUT)(self.core_queue, self.output_queue) + self.backend = self.backend_class(self.core_queue, self.output_queue, DummyMixer) self.playback = self.backend.playback self.current_playlist = self.backend.current_playlist @@ -292,6 +301,7 @@ class BasePlaybackControllerTest(object): def tearDown(self): self.backend.destroy() + self.output.destroy() def test_initial_state_is_stopped(self): self.assertEqual(self.playback.state, self.playback.STOPPED) From 85100f21432d5dc70b2643ea85fb2d8f7b5e9372 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 14 Aug 2010 17:28:04 +0200 Subject: [PATCH 03/80] Remove appsrc from output until local backend tests are done --- mopidy/outputs/gstreamer.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mopidy/outputs/gstreamer.py b/mopidy/outputs/gstreamer.py index c2547bdb..4cbeb205 100644 --- a/mopidy/outputs/gstreamer.py +++ b/mopidy/outputs/gstreamer.py @@ -43,7 +43,8 @@ class GStreamerProcess(BaseProcess): """ pipeline_description = ' ! '.join([ - 'appsrc name=src uridecodebin name=uri', + #'appsrc name=src uridecodebin name=uri', + 'uridecodebin name=uri', 'volume name=volume', 'autoaudiosink name=sink', ]) From a094c665465f2f54271e60b52db51c4af899ada5 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 14 Aug 2010 17:29:50 +0200 Subject: [PATCH 04/80] Strip out gst stuff from localbackend and start using output --- mopidy/backends/local/__init__.py | 87 +++++++------------------------ 1 file changed, 19 insertions(+), 68 deletions(-) diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index 87d2f7c0..658e73f9 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -1,34 +1,17 @@ -import gobject -gobject.threads_init() -# FIXME make sure we don't get hit by -# http://jameswestby.net/ -# weblog/tech/14-caution-python-multiprocessing-and-glib-dont-mix.html - -import pygst -pygst.require('0.10') - -import gst import logging import os import glob import shutil -import threading +import multiprocessing from mopidy.backends.base import * from mopidy.models import Playlist, Track, Album from mopidy import settings from mopidy.utils import parse_m3u, parse_mpd_tag_cache +from mopidy.process import pickle_connection logger = logging.getLogger(u'mopidy.backends.local') -class LocalMessages(threading.Thread): - def run(self): - gobject.MainLoop().run() - -message_thread = LocalMessages() -message_thread.daemon = True -message_thread.start() - class LocalBackend(BaseBackend): """ A backend for playing music from a local music archive. @@ -49,71 +32,39 @@ class LocalBackend(BaseBackend): class LocalPlaybackController(BasePlaybackController): def __init__(self, backend): super(LocalPlaybackController, self).__init__(backend) - - self._bin = gst.element_factory_make("playbin", "player") - self._bus = self._bin.get_bus() - sink = gst.element_factory_make("fakesink", "fakesink") - - # FIXME cleanup fakesink? - - self._bin.set_property("video-sink", sink) - self._bus.add_signal_watch() - self._bus_id = self._bus.connect('message', self._message) - self.stop() - def _set_state(self, state): - self._bin.set_state(state) - (_, new, _) = self._bin.get_state() - return new == state + def _send_recv(self, message): + (my_end, other_end) = multiprocessing.Pipe() + message.update({'reply_to': pickle_connection(other_end)}) + self.backend.output_queue.put(message) + my_end.poll(None) + return my_end.recv() - def _message(self, bus, message): - if message.type == gst.MESSAGE_EOS: - self.end_of_track_callback() - elif message.type == gst.MESSAGE_ERROR: - self._bin.set_state(gst.STATE_NULL) - error, debug = message.parse_error() - logger.error('%s %s', error, debug) + def _send(self, message): + self.backend.output_queue.put(message) + + def _set_state(self, state): + return self._send_recv({'command': 'set_state', 'state': state}) def _play(self, track): - self._bin.set_state(gst.STATE_READY) - self._bin.set_property('uri', track.uri) - return self._set_state(gst.STATE_PLAYING) + return self._send_recv({'command': 'play_uri', 'uri': track.uri}) def _stop(self): - return self._set_state(gst.STATE_READY) + return self._set_state('READY') def _pause(self): - return self._set_state(gst.STATE_PAUSED) + return self._set_state('PAUSED') def _resume(self): - return self._set_state(gst.STATE_PLAYING) + return self._set_state('PLAYING') def _seek(self, time_position): - self._bin.seek_simple(gst.Format(gst.FORMAT_TIME), - gst.SEEK_FLAG_FLUSH, time_position * gst.MSECOND) - self._set_state(gst.STATE_PLAYING) + pass @property def time_position(self): - try: - return self._bin.query_position(gst.FORMAT_TIME)[0] // gst.MSECOND - except gst.QueryError, e: - logger.error('time_position failed: %s', e) - return 0 - - def destroy(self): - playbin, self._bin = self._bin, None - bus, self._bus = self._bus, None - - bus.disconnect(self._bus_id) - bus.remove_signal_watch() - playbin.get_state() - playbin.set_state(gst.STATE_NULL) - bus.set_flushing(True) - - del bus - del playbin + pass class LocalStoredPlaylistsController(BaseStoredPlaylistsController): From 3dc9240ea4e61b64488c297c60c83736dc1a6762 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 14 Aug 2010 17:52:12 +0200 Subject: [PATCH 05/80] Add time position stuff to gstreamer output --- mopidy/backends/local/__init__.py | 4 ++-- mopidy/outputs/gstreamer.py | 22 ++++++++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index 658e73f9..967aab7a 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -60,11 +60,11 @@ class LocalPlaybackController(BasePlaybackController): return self._set_state('PLAYING') def _seek(self, time_position): - pass + self._send({'command': 'set_position', 'position': time_position}) @property def time_position(self): - pass + return self._send_recv({'command': 'get_position'}) class LocalStoredPlaylistsController(BaseStoredPlaylistsController): diff --git a/mopidy/outputs/gstreamer.py b/mopidy/outputs/gstreamer.py index 4cbeb205..2929007c 100644 --- a/mopidy/outputs/gstreamer.py +++ b/mopidy/outputs/gstreamer.py @@ -105,6 +105,12 @@ class GStreamerProcess(BaseProcess): connection.send(volume) elif message['command'] == 'set_volume': self.set_volume(message['volume']) + elif message['command'] == 'set_position': + self.set_position(message['position']) + elif message['command'] == 'get_position': + response = self.get_position() + connection = unpickle_connection(message['reply_to']) + connection.send(response) else: logger.warning(u'Cannot handle message: %s', message) @@ -180,3 +186,19 @@ class GStreamerProcess(BaseProcess): """Set volume in range [0..100]""" gst_volume = volume / 100.0 self.gst_volume.set_property('volume', gst_volume) + + def set_position(self, position): + logger.info('Seeking to %s' % position) + self.gst_pipeline.seek_simple(gst.Format(gst.FORMAT_TIME), + gst.SEEK_FLAG_FLUSH, position * gst.MSECOND) + self.set_state('PLAYING') + + def get_position(self): + try: + position = self.gst_pipeline.query_position(gst.FORMAT_TIME)[0] + return position // gst.MSECOND + except gst.QueryError, e: + logger.error('time_position failed: %s', e) + return 0 + + From f5b6d0e9944ea472c698bb892ad47d80d7169e1f Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 14 Aug 2010 18:29:33 +0200 Subject: [PATCH 06/80] Switch to using playbin in order to get simple base case working --- mopidy/outputs/gstreamer.py | 5 ++++- tests/outputs/gstreamer_test.py | 4 ++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/mopidy/outputs/gstreamer.py b/mopidy/outputs/gstreamer.py index 9ce9df15..da42a15b 100644 --- a/mopidy/outputs/gstreamer.py +++ b/mopidy/outputs/gstreamer.py @@ -48,6 +48,7 @@ class GStreamerProcess(BaseProcess): 'volume name=volume', 'autoaudiosink name=sink', ]) + pipeline_description = 'playbin' def __init__(self, core_queue, output_queue): super(GStreamerProcess, self).__init__() @@ -130,7 +131,7 @@ class GStreamerProcess(BaseProcess): def play_uri(self, uri): """Play audio at URI""" self.set_state('READY') - self.gst_uri_bin.set_property('uri', uri) + self.gst_pipeline.set_property('uri', uri) return self.set_state('PLAYING') def deliver_data(self, caps_string, data): @@ -178,10 +179,12 @@ class GStreamerProcess(BaseProcess): def get_volume(self): """Get volume in range [0..100]""" + return 0 gst_volume = self.gst_volume.get_property('volume') return int(gst_volume * 100) def set_volume(self, volume): + return """Set volume in range [0..100]""" gst_volume = volume / 100.0 self.gst_volume.set_property('volume', gst_volume) diff --git a/tests/outputs/gstreamer_test.py b/tests/outputs/gstreamer_test.py index f483a68a..1bdd35d9 100644 --- a/tests/outputs/gstreamer_test.py +++ b/tests/outputs/gstreamer_test.py @@ -35,18 +35,22 @@ class GStreamerOutputTest(unittest.TestCase): message = {'command': 'play_uri', 'uri': self.song_uri + 'bogus'} self.assertEqual(False, self.send_recv(message)) + @SkipTest def test_default_get_volume_result(self): message = {'command': 'get_volume'} self.assertEqual(100, self.send_recv(message)) + @SkipTest def test_set_volume(self): self.send({'command': 'set_volume', 'volume': 50}) self.assertEqual(50, self.send_recv({'command': 'get_volume'})) + @SkipTest def test_set_volume_to_zero(self): self.send({'command': 'set_volume', 'volume': 0}) self.assertEqual(0, self.send_recv({'command': 'get_volume'})) + @SkipTest def test_set_volume_to_one_hundred(self): self.send({'command': 'set_volume', 'volume': 100}) self.assertEqual(100, self.send_recv({'command': 'get_volume'})) From bb73f8ae5ff920178bd90f5b62a4369d21f57c8d Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 14 Aug 2010 18:53:31 +0200 Subject: [PATCH 07/80] Update set_state to block for async state changes --- mopidy/outputs/gstreamer.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/mopidy/outputs/gstreamer.py b/mopidy/outputs/gstreamer.py index da42a15b..13563a80 100644 --- a/mopidy/outputs/gstreamer.py +++ b/mopidy/outputs/gstreamer.py @@ -170,6 +170,10 @@ class GStreamerProcess(BaseProcess): """ result = self.gst_pipeline.set_state( getattr(gst, 'STATE_' + state_name)) + # Block until state change has occured, required for at least the local + # backends seek functionality. (Optional solution is to block in set + # position to ensure that change occours) + self.gst_pipeline.get_state() if result == gst.STATE_CHANGE_FAILURE: logger.warning('Setting GStreamer state to %s: failed', state_name) return False @@ -190,7 +194,6 @@ class GStreamerProcess(BaseProcess): self.gst_volume.set_property('volume', gst_volume) def set_position(self, position): - logger.info('Seeking to %s' % position) self.gst_pipeline.seek_simple(gst.Format(gst.FORMAT_TIME), gst.SEEK_FLAG_FLUSH, position * gst.MSECOND) self.set_state('PLAYING') From 504802b02da825089901e1cf3407e5e33592c2c9 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 14 Aug 2010 19:06:08 +0200 Subject: [PATCH 08/80] Update test_end_of_track_callback_gets_called to check that end_of_track is in core_queue --- tests/backends/base.py | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/tests/backends/base.py b/tests/backends/base.py index d0389c0d..582ccc9c 100644 --- a/tests/backends/base.py +++ b/tests/backends/base.py @@ -2,7 +2,6 @@ import os import random import shutil import tempfile -import threading import time import multiprocessing @@ -600,22 +599,10 @@ class BasePlaybackControllerTest(object): @populate_playlist def test_end_of_track_callback_gets_called(self): - end_of_track_callback = self.playback.end_of_track_callback - event = threading.Event() - - def wrapper(): - result = end_of_track_callback() - event.set() - return result - - self.playback.end_of_track_callback = wrapper - self.playback.play() self.playback.seek(self.tracks[0].length - 10) - - event.wait(5) - - self.assert_(event.is_set()) + message = self.core_queue.get() + self.assertEqual('end_of_track', message['command']) @populate_playlist def test_new_playlist_loaded_callback_when_playing(self): From cc9de4eff1092358f7eb2960ec1658250b294ef0 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 14 Aug 2010 19:14:52 +0200 Subject: [PATCH 09/80] Add extra backend test that check that pause resumes correct song --- tests/backends/base.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/backends/base.py b/tests/backends/base.py index 582ccc9c..97719c08 100644 --- a/tests/backends/base.py +++ b/tests/backends/base.py @@ -350,6 +350,17 @@ class BasePlaybackControllerTest(object): self.assertEqual(self.playback.state, self.playback.PLAYING) self.assertEqual(track, self.playback.current_track) + @populate_playlist + def test_play_when_pause_after_next(self): + self.playback.play() + self.playback.next() + self.playback.next() + track = self.playback.current_track + self.playback.pause() + self.playback.play() + self.assertEqual(self.playback.state, self.playback.PLAYING) + self.assertEqual(track, self.playback.current_track) + @populate_playlist def test_play_sets_current_track(self): self.playback.play() From e93e3d4265045a9eacf0d5a785086ba5a8b948b8 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 14 Aug 2010 22:26:32 +0200 Subject: [PATCH 10/80] Attempt to simplify gstreamer output with only playbin --- mopidy/outputs/gstreamer.py | 40 +++++++++++-------------------------- 1 file changed, 12 insertions(+), 28 deletions(-) diff --git a/mopidy/outputs/gstreamer.py b/mopidy/outputs/gstreamer.py index 13563a80..b2012626 100644 --- a/mopidy/outputs/gstreamer.py +++ b/mopidy/outputs/gstreamer.py @@ -42,24 +42,11 @@ class GStreamerProcess(BaseProcess): http://jameswestby.net/weblog/tech/14-caution-python-multiprocessing-and-glib-dont-mix.html. """ - pipeline_description = ' ! '.join([ - #'appsrc name=src uridecodebin name=uri', - 'uridecodebin name=uri', - 'volume name=volume', - 'autoaudiosink name=sink', - ]) - pipeline_description = 'playbin' - def __init__(self, core_queue, output_queue): super(GStreamerProcess, self).__init__() self.core_queue = core_queue self.output_queue = output_queue self.gst_pipeline = None - self.gst_bus = None - self.gst_bus_id = None - self.gst_uri_bin = None - self.gst_data_src = None - self.gst_volume = None def run_inside_try(self): self.setup() @@ -75,16 +62,12 @@ class GStreamerProcess(BaseProcess): messages_thread.daemon = True messages_thread.start() - self.gst_pipeline = gst.parse_launch(self.pipeline_description) - self.gst_data_src = self.gst_pipeline.get_by_name('src') - self.gst_uri_bin = self.gst_pipeline.get_by_name('uri') - self.gst_volume = self.gst_pipeline.get_by_name('volume') + self.gst_pipeline = gst.parse_launch('playbin') # Setup bus and message processor - self.gst_bus = self.gst_pipeline.get_bus() - self.gst_bus.add_signal_watch() - self.gst_bus_id = self.gst_bus.connect('message', - self.process_gst_message) + gst_bus = self.gst_pipeline.get_bus() + gst_bus.add_signal_watch() + gst_bus.connect('message', self.process_gst_message) def process_mopidy_message(self, message): """Process messages from the rest of Mopidy.""" @@ -136,11 +119,12 @@ class GStreamerProcess(BaseProcess): def deliver_data(self, caps_string, data): """Deliver audio data to be played""" + data_src = self.gst_pipeline.get_by_name('src') caps = gst.caps_from_string(caps_string) buffer_ = gst.Buffer(buffer(data)) buffer_.set_caps(caps) - self.gst_data_src.set_property('caps', caps) - self.gst_data_src.emit('push-buffer', buffer_) + data_src.set_property('caps', caps) + data_src.emit('push-buffer', buffer_) def end_of_data_stream(self): """ @@ -149,7 +133,7 @@ class GStreamerProcess(BaseProcess): 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_data_src.emit('end-of-stream') + self.gst_pipeline.get_by_name('src').emit('end-of-stream') def set_state(self, state_name): """ @@ -184,14 +168,14 @@ class GStreamerProcess(BaseProcess): def get_volume(self): """Get volume in range [0..100]""" return 0 - gst_volume = self.gst_volume.get_property('volume') - return int(gst_volume * 100) + gst_volume = self.gst_pipeline.get_by_name('volume') + return int(gst_volume.get_property('volume') * 100) def set_volume(self, volume): return """Set volume in range [0..100]""" - gst_volume = volume / 100.0 - self.gst_volume.set_property('volume', gst_volume) + gst_volume = self.gst_pipeline.get_by_name('volume') + gst_volume.set_property('volume', volume / 100.0) def set_position(self, position): self.gst_pipeline.seek_simple(gst.Format(gst.FORMAT_TIME), From b2b169eaa9a972f223079b73c0cf6b9413388627 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 14 Aug 2010 23:54:54 +0200 Subject: [PATCH 11/80] Use uridecodebin with auto connecting to play local music --- mopidy/outputs/gstreamer.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/mopidy/outputs/gstreamer.py b/mopidy/outputs/gstreamer.py index b2012626..da4cbd20 100644 --- a/mopidy/outputs/gstreamer.py +++ b/mopidy/outputs/gstreamer.py @@ -47,6 +47,7 @@ class GStreamerProcess(BaseProcess): self.core_queue = core_queue self.output_queue = output_queue self.gst_pipeline = None + self.gst_decoder = None def run_inside_try(self): self.setup() @@ -62,13 +63,26 @@ class GStreamerProcess(BaseProcess): messages_thread.daemon = True messages_thread.start() - self.gst_pipeline = gst.parse_launch('playbin') + self.gst_pipeline = gst.parse_launch(' ! '.join([ + 'volume name=volume', + 'autoaudiosink' + ])) + + decode_bin = gst.element_factory_make('uridecodebin', 'uri') + decode_bin.connect('pad-added', self.process_new_pad, 'volume') + app_src = gst.element_factory_make('appsrc', 'src') + + self.gst_pipeline.add(decode_bin) + self.gst_pipeline.add(app_src) # Setup bus and message processor gst_bus = self.gst_pipeline.get_bus() gst_bus.add_signal_watch() gst_bus.connect('message', self.process_gst_message) + def process_new_pad(self, source, pad, target): + pad.link(self.gst_pipeline.get_by_name(target).get_pad('sink')) + def process_mopidy_message(self, message): """Process messages from the rest of Mopidy.""" if message['command'] == 'play_uri': @@ -114,7 +128,7 @@ class GStreamerProcess(BaseProcess): def play_uri(self, uri): """Play audio at URI""" self.set_state('READY') - self.gst_pipeline.set_property('uri', uri) + self.gst_pipeline.get_by_name('uri').set_property('uri', uri) return self.set_state('PLAYING') def deliver_data(self, caps_string, data): From 869e3cbc6796f3ea11ebc3938fc24abc819a472e Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 14 Aug 2010 23:55:21 +0200 Subject: [PATCH 12/80] Use alsasink ase there seems to be a bug with using pulse in this maner --- mopidy/outputs/gstreamer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/outputs/gstreamer.py b/mopidy/outputs/gstreamer.py index da4cbd20..cec27db0 100644 --- a/mopidy/outputs/gstreamer.py +++ b/mopidy/outputs/gstreamer.py @@ -65,7 +65,7 @@ class GStreamerProcess(BaseProcess): self.gst_pipeline = gst.parse_launch(' ! '.join([ 'volume name=volume', - 'autoaudiosink' + 'alsasink' ])) decode_bin = gst.element_factory_make('uridecodebin', 'uri') From fbe75e799a68892f0a9477ec85306f7e9cd33588 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 15 Aug 2010 00:25:39 +0200 Subject: [PATCH 13/80] Remove unused property --- mopidy/outputs/gstreamer.py | 1 - 1 file changed, 1 deletion(-) diff --git a/mopidy/outputs/gstreamer.py b/mopidy/outputs/gstreamer.py index cec27db0..292b5a2e 100644 --- a/mopidy/outputs/gstreamer.py +++ b/mopidy/outputs/gstreamer.py @@ -47,7 +47,6 @@ class GStreamerProcess(BaseProcess): self.core_queue = core_queue self.output_queue = output_queue self.gst_pipeline = None - self.gst_decoder = None def run_inside_try(self): self.setup() From da763be9d705b24abdcc0b81ae946f62ba8d95ca Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 15 Aug 2010 01:05:30 +0200 Subject: [PATCH 14/80] Add multiqueue to pipeline so appsrc in theory should work again --- mopidy/outputs/gstreamer.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/mopidy/outputs/gstreamer.py b/mopidy/outputs/gstreamer.py index 292b5a2e..0b694aaf 100644 --- a/mopidy/outputs/gstreamer.py +++ b/mopidy/outputs/gstreamer.py @@ -63,24 +63,31 @@ class GStreamerProcess(BaseProcess): messages_thread.start() self.gst_pipeline = gst.parse_launch(' ! '.join([ + 'multiqueue name=queue', 'volume name=volume', 'alsasink' ])) decode_bin = gst.element_factory_make('uridecodebin', 'uri') - decode_bin.connect('pad-added', self.process_new_pad, 'volume') + decode_bin.connect('pad-added', self.process_new_pad) app_src = gst.element_factory_make('appsrc', 'src') + queue = self.gst_pipeline.get_by_name('queue') self.gst_pipeline.add(decode_bin) self.gst_pipeline.add(app_src) + self.gst_uri_pad = queue.get_request_pad('sink0') + self.gst_src_pad = queue.get_request_pad('sink1') + + app_src.get_pad('src').link(self.gst_src_pad) + # Setup bus and message processor gst_bus = self.gst_pipeline.get_bus() gst_bus.add_signal_watch() gst_bus.connect('message', self.process_gst_message) - def process_new_pad(self, source, pad, target): - pad.link(self.gst_pipeline.get_by_name(target).get_pad('sink')) + def process_new_pad(self, source, pad): + pad.link(self.gst_uri_pad) def process_mopidy_message(self, message): """Process messages from the rest of Mopidy.""" From 83fbc8ea221f57cec665ff9e28aeed7279e4a46e Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 15 Aug 2010 03:03:32 +0200 Subject: [PATCH 15/80] Start preprations for supporting switching between sources --- mopidy/outputs/gstreamer.py | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/mopidy/outputs/gstreamer.py b/mopidy/outputs/gstreamer.py index 0b694aaf..aedc0eaa 100644 --- a/mopidy/outputs/gstreamer.py +++ b/mopidy/outputs/gstreamer.py @@ -47,6 +47,7 @@ class GStreamerProcess(BaseProcess): self.core_queue = core_queue self.output_queue = output_queue self.gst_pipeline = None + self.gst_target = None def run_inside_try(self): self.setup() @@ -63,23 +64,15 @@ class GStreamerProcess(BaseProcess): messages_thread.start() self.gst_pipeline = gst.parse_launch(' ! '.join([ - 'multiqueue name=queue', 'volume name=volume', 'alsasink' ])) + self.gst_target = self.gst_pipeline.get_by_name('volume') + decode_bin = gst.element_factory_make('uridecodebin', 'uri') decode_bin.connect('pad-added', self.process_new_pad) - app_src = gst.element_factory_make('appsrc', 'src') - queue = self.gst_pipeline.get_by_name('queue') - self.gst_pipeline.add(decode_bin) - self.gst_pipeline.add(app_src) - - self.gst_uri_pad = queue.get_request_pad('sink0') - self.gst_src_pad = queue.get_request_pad('sink1') - - app_src.get_pad('src').link(self.gst_src_pad) # Setup bus and message processor gst_bus = self.gst_pipeline.get_bus() @@ -87,7 +80,7 @@ class GStreamerProcess(BaseProcess): gst_bus.connect('message', self.process_gst_message) def process_new_pad(self, source, pad): - pad.link(self.gst_uri_pad) + pad.link(self.gst_target.get_pad('sink')) def process_mopidy_message(self, message): """Process messages from the rest of Mopidy.""" From 151d5db56684159cd37f4d3fe7b244669787114c Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 17 Aug 2010 01:32:06 +0200 Subject: [PATCH 16/80] Setup output based on which backend we are running --- mopidy/outputs/gstreamer.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/mopidy/outputs/gstreamer.py b/mopidy/outputs/gstreamer.py index aedc0eaa..ff732448 100644 --- a/mopidy/outputs/gstreamer.py +++ b/mopidy/outputs/gstreamer.py @@ -8,6 +8,7 @@ import gst import logging import threading +from mopidy import settings from mopidy.process import BaseProcess, unpickle_connection logger = logging.getLogger('mopidy.outputs.gstreamer') @@ -47,7 +48,6 @@ class GStreamerProcess(BaseProcess): self.core_queue = core_queue self.output_queue = output_queue self.gst_pipeline = None - self.gst_target = None def run_inside_try(self): self.setup() @@ -68,19 +68,24 @@ class GStreamerProcess(BaseProcess): 'alsasink' ])) - self.gst_target = self.gst_pipeline.get_by_name('volume') + pad = self.gst_pipeline.get_by_name('volume').get_pad('sink') - decode_bin = gst.element_factory_make('uridecodebin', 'uri') - decode_bin.connect('pad-added', self.process_new_pad) - self.gst_pipeline.add(decode_bin) + if settings.BACKENDS[0] == 'mopidy.backends.local.LocalBackend': + uri_bin = gst.element_factory_make('uridecodebin', 'uri') + uri_bin.connect('pad-added', self.process_new_pad, pad) + self.gst_pipeline.add(uri_bin) + else: + app_src = gst.element_factory_make('appsrc', 'src') + self.gst_pipeline.add(app_src) + app_src.get_pad('src').link(pad) # Setup bus and message processor gst_bus = self.gst_pipeline.get_bus() gst_bus.add_signal_watch() gst_bus.connect('message', self.process_gst_message) - def process_new_pad(self, source, pad): - pad.link(self.gst_target.get_pad('sink')) + def process_new_pad(self, source, pad, target_pad): + pad.link(target_pad) def process_mopidy_message(self, message): """Process messages from the rest of Mopidy.""" From 4000fda43ff05e6591d5c921e453b9b09624bca2 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 17 Aug 2010 01:32:50 +0200 Subject: [PATCH 17/80] Reable get/set volume in output --- mopidy/outputs/gstreamer.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/mopidy/outputs/gstreamer.py b/mopidy/outputs/gstreamer.py index ff732448..2f559f4f 100644 --- a/mopidy/outputs/gstreamer.py +++ b/mopidy/outputs/gstreamer.py @@ -185,12 +185,10 @@ class GStreamerProcess(BaseProcess): def get_volume(self): """Get volume in range [0..100]""" - return 0 gst_volume = self.gst_pipeline.get_by_name('volume') return int(gst_volume.get_property('volume') * 100) def set_volume(self, volume): - return """Set volume in range [0..100]""" gst_volume = self.gst_pipeline.get_by_name('volume') gst_volume.set_property('volume', volume / 100.0) @@ -207,5 +205,3 @@ class GStreamerProcess(BaseProcess): except gst.QueryError, e: logger.error('time_position failed: %s', e) return 0 - - From 77550ce2914f0eacc948b1fdde555e1dee14a720 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 17 Aug 2010 01:33:10 +0200 Subject: [PATCH 18/80] Use autoaudiosink --- mopidy/outputs/gstreamer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/outputs/gstreamer.py b/mopidy/outputs/gstreamer.py index 2f559f4f..898aa42b 100644 --- a/mopidy/outputs/gstreamer.py +++ b/mopidy/outputs/gstreamer.py @@ -65,7 +65,7 @@ class GStreamerProcess(BaseProcess): self.gst_pipeline = gst.parse_launch(' ! '.join([ 'volume name=volume', - 'alsasink' + 'autoaudiosink' ])) pad = self.gst_pipeline.get_by_name('volume').get_pad('sink') From a969daf046932cd77b353b647a58a1750e2d4160 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 18 Aug 2010 00:57:06 +0200 Subject: [PATCH 19/80] Add audioconvert to output pipeline to avoid gst.LinkError: --- mopidy/outputs/gstreamer.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mopidy/outputs/gstreamer.py b/mopidy/outputs/gstreamer.py index 898aa42b..982c7a79 100644 --- a/mopidy/outputs/gstreamer.py +++ b/mopidy/outputs/gstreamer.py @@ -64,11 +64,12 @@ class GStreamerProcess(BaseProcess): messages_thread.start() self.gst_pipeline = gst.parse_launch(' ! '.join([ + 'audioconvert name=convert', 'volume name=volume', 'autoaudiosink' ])) - pad = self.gst_pipeline.get_by_name('volume').get_pad('sink') + pad = self.gst_pipeline.get_by_name('convert').get_pad('sink') if settings.BACKENDS[0] == 'mopidy.backends.local.LocalBackend': uri_bin = gst.element_factory_make('uridecodebin', 'uri') From 94ef06ac78e20910699b6e13db25a91028a6ab00 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 18 Aug 2010 23:41:13 +0200 Subject: [PATCH 20/80] Nuke local settings to ensure test consistency --- tests/__init__.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/__init__.py b/tests/__init__.py index b08afb01..c8618f3f 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -9,6 +9,11 @@ except ImportError: class SkipTest(Exception): pass +from mopidy import settings + +# Nuke any local settings to ensure same test env all over +settings.local.clear() + def data_folder(name): folder = os.path.dirname(__file__) folder = os.path.join(folder, 'data') From a20251daac86aef3ac5387d046562b5b55b28f83 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 18 Aug 2010 23:41:44 +0200 Subject: [PATCH 21/80] Ensure that backend tests setup right backend in setting --- tests/backends/local/backend_test.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/backends/local/backend_test.py b/tests/backends/local/backend_test.py index aff84658..b95c6dde 100644 --- a/tests/backends/local/backend_test.py +++ b/tests/backends/local/backend_test.py @@ -27,6 +27,15 @@ class LocalCurrentPlaylistControllerTest(BaseCurrentPlaylistControllerTest, backend_class = LocalBackend + def setUp(self): + self.original_backends = settings.BACKENDS + settings.BACKENDS = ('mopidy.backends.local.LocalBackend',) + super(LocalCurrentPlaylistControllerTest, self).setUp() + + def tearDown(self): + super(LocalCurrentPlaylistControllerTest, self).tearDown() + settings.BACKENDS = settings.original_backends + class LocalPlaybackControllerTest(BasePlaybackControllerTest, unittest.TestCase): @@ -35,10 +44,17 @@ class LocalPlaybackControllerTest(BasePlaybackControllerTest, backend_class = LocalBackend def setUp(self): + self.original_backends = settings.BACKENDS + settings.BACKENDS = ('mopidy.backends.local.LocalBackend',) + super(LocalPlaybackControllerTest, self).setUp() # Two tests does not work at all when using the fake sink #self.backend.playback.use_fake_sink() + def tearDown(self): + super(LocalPlaybackControllerTest, self).tearDown() + settings.BACKENDS = settings.original_backends + def add_track(self, path): uri = path_to_uri(data_folder(path)) track = Track(uri=uri, length=4464) From 1fdcddd94b67a722671b4c9ad659189a4bffbd0d Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 19 Aug 2010 00:05:20 +0200 Subject: [PATCH 22/80] We follow the development process from nvie.com/git-model --- docs/development/contributing.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/development/contributing.rst b/docs/development/contributing.rst index a2ef4a15..dc982dac 100644 --- a/docs/development/contributing.rst +++ b/docs/development/contributing.rst @@ -58,6 +58,8 @@ Code style Commit guidelines ================= +- We follow the development process described at http://nvie.com/git-model. + - Keep commits small and on topic. - If a commit looks too big you should be working in a feature branch not a From a8a8af4d17f0c7c71e28ba68d66e986c092759f4 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 19 Aug 2010 00:05:38 +0200 Subject: [PATCH 23/80] We turned of Hudson's IRC nagging a long time ago --- docs/development/contributing.rst | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/development/contributing.rst b/docs/development/contributing.rst index dc982dac..16200162 100644 --- a/docs/development/contributing.rst +++ b/docs/development/contributing.rst @@ -109,8 +109,7 @@ Continuous integration server We run a continuous integration server called Hudson at http://hudson.mopidy.com/ that runs all test on multiple platforms (Ubuntu, OS -X, etc.) for every commit we push to GitHub. If the build is broken or fixed, -Hudson will issue notifications to our IRC channel. +X, etc.) for every commit we push to GitHub. In addition to running tests, Hudson also does coverage statistics and uses pylint to check for errors and possible improvements in our code. So, if you're From 53d2d93f77884ab4800f74418f13a4c93658e75d Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 19 Aug 2010 00:05:55 +0200 Subject: [PATCH 24/80] We now publish docs for both the master and develop branches --- docs/development/contributing.rst | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/docs/development/contributing.rst b/docs/development/contributing.rst index 16200162..eac94799 100644 --- a/docs/development/contributing.rst +++ b/docs/development/contributing.rst @@ -136,9 +136,16 @@ Then, to generate docs:: .. note:: - The documentation at http://www.mopidy.com/docs/ is automatically updated - within 10 minutes after a documentation update is pushed to - ``jodal/mopidy/master`` at GitHub. + The documentation at http://www.mopidy.com/ is automatically updated when a + documentation update is pushed to ``jodal/mopidy`` at GitHub. + + Documentation generated from the ``master`` branch is published at + http://www.mopidy.com/docs/master/, and will always be valid for the latest + release. + + Documentation generated from the ``develop`` branch is published at + http://www.mopidy.com/docs/develop/, and will always be valid for the + latest development snapshot. Creating releases From 714529fee6db20243cc052b66312ad099489ebfb Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 19 Aug 2010 00:09:36 +0200 Subject: [PATCH 25/80] Link from README to both docs versions --- README.rst | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index 1e4430e2..6855135e 100644 --- a/README.rst +++ b/README.rst @@ -9,9 +9,10 @@ in Spotify's vast archive, manage playlists, and play music, you can use most platforms, including Windows, Mac OS X, Linux, and iPhone and Android phones. To install Mopidy, check out -`the installation docs `_. +`the installation docs `_. -* `Documentation `_ +* `Documentation `_ +* `Documentation (development version) `_ * `Source code `_ * `Issue tracker `_ * IRC: ``#mopidy`` at `irc.freenode.net `_ From cf2408913775e416ea43dfc48e2a94939ed7d4e9 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 19 Aug 2010 00:52:42 +0200 Subject: [PATCH 26/80] Renable some output tests --- tests/outputs/gstreamer_test.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/tests/outputs/gstreamer_test.py b/tests/outputs/gstreamer_test.py index e1081ca0..db15c952 100644 --- a/tests/outputs/gstreamer_test.py +++ b/tests/outputs/gstreamer_test.py @@ -1,6 +1,7 @@ import multiprocessing import unittest +from mopidy import settings from mopidy.outputs.gstreamer import GStreamerOutput from mopidy.process import pickle_connection from mopidy.utils.path import path_to_uri @@ -9,6 +10,8 @@ from tests import data_folder, SkipTest class GStreamerOutputTest(unittest.TestCase): def setUp(self): + self.original_backends = settings.BACKENDS + settings.BACKENDS = ('mopidy.backends.local.LocalBackend',) self.song_uri = path_to_uri(data_folder('song1.wav')) self.output_queue = multiprocessing.Queue() self.core_queue = multiprocessing.Queue() @@ -16,6 +19,7 @@ class GStreamerOutputTest(unittest.TestCase): def tearDown(self): self.output.destroy() + settings.BACKENDS = settings.original_backends def send_recv(self, message): (my_end, other_end) = multiprocessing.Pipe() @@ -24,35 +28,30 @@ class GStreamerOutputTest(unittest.TestCase): my_end.poll(None) return my_end.recv() + def send(self, message): self.output_queue.put(message) - @SkipTest def test_play_uri_existing_file(self): message = {'command': 'play_uri', 'uri': self.song_uri} self.assertEqual(True, self.send_recv(message)) - @SkipTest def test_play_uri_non_existing_file(self): message = {'command': 'play_uri', 'uri': self.song_uri + 'bogus'} self.assertEqual(False, self.send_recv(message)) - @SkipTest def test_default_get_volume_result(self): message = {'command': 'get_volume'} self.assertEqual(100, self.send_recv(message)) - @SkipTest def test_set_volume(self): self.send({'command': 'set_volume', 'volume': 50}) self.assertEqual(50, self.send_recv({'command': 'get_volume'})) - @SkipTest def test_set_volume_to_zero(self): self.send({'command': 'set_volume', 'volume': 0}) self.assertEqual(0, self.send_recv({'command': 'get_volume'})) - @SkipTest def test_set_volume_to_one_hundred(self): self.send({'command': 'set_volume', 'volume': 100}) self.assertEqual(100, self.send_recv({'command': 'get_volume'})) From 222bbf4998280a2c28c2594769e5d1e299ae7586 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 19 Aug 2010 00:56:24 +0200 Subject: [PATCH 27/80] Only block set state when handling async changes --- mopidy/outputs/gstreamer.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/mopidy/outputs/gstreamer.py b/mopidy/outputs/gstreamer.py index 982c7a79..61b4f19b 100644 --- a/mopidy/outputs/gstreamer.py +++ b/mopidy/outputs/gstreamer.py @@ -173,13 +173,13 @@ class GStreamerProcess(BaseProcess): """ result = self.gst_pipeline.set_state( getattr(gst, 'STATE_' + state_name)) - # Block until state change has occured, required for at least the local - # backends seek functionality. (Optional solution is to block in set - # position to ensure that change occours) - self.gst_pipeline.get_state() if result == gst.STATE_CHANGE_FAILURE: logger.warning('Setting GStreamer state to %s: failed', state_name) return False + elif result == gst.STATE_CHANGE_ASYNC: + # Block until ready + self.gst_pipeline.get_state() + return True else: logger.debug('Setting GStreamer state to %s: OK', state_name) return True From 1132f554c779fb0401d3fe14e06b5dfc7d22e763 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 19 Aug 2010 01:01:32 +0200 Subject: [PATCH 28/80] Only block after seek before starting to play to ensure that seek has happened --- mopidy/outputs/gstreamer.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/mopidy/outputs/gstreamer.py b/mopidy/outputs/gstreamer.py index 61b4f19b..c1435c2c 100644 --- a/mopidy/outputs/gstreamer.py +++ b/mopidy/outputs/gstreamer.py @@ -176,10 +176,6 @@ class GStreamerProcess(BaseProcess): if result == gst.STATE_CHANGE_FAILURE: logger.warning('Setting GStreamer state to %s: failed', state_name) return False - elif result == gst.STATE_CHANGE_ASYNC: - # Block until ready - self.gst_pipeline.get_state() - return True else: logger.debug('Setting GStreamer state to %s: OK', state_name) return True @@ -197,6 +193,7 @@ class GStreamerProcess(BaseProcess): def set_position(self, position): self.gst_pipeline.seek_simple(gst.Format(gst.FORMAT_TIME), gst.SEEK_FLAG_FLUSH, position * gst.MSECOND) + self.gst_pipeline.get_state() # Block until state change self.set_state('PLAYING') def get_position(self): From 01b854b8913b5bfb61621bf5815c266b1ce1dc3c Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 19 Aug 2010 01:34:26 +0200 Subject: [PATCH 29/80] Flushing seek takes care of starting pipeline for us --- mopidy/outputs/gstreamer.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/mopidy/outputs/gstreamer.py b/mopidy/outputs/gstreamer.py index c1435c2c..655487d5 100644 --- a/mopidy/outputs/gstreamer.py +++ b/mopidy/outputs/gstreamer.py @@ -193,8 +193,7 @@ class GStreamerProcess(BaseProcess): def set_position(self, position): self.gst_pipeline.seek_simple(gst.Format(gst.FORMAT_TIME), gst.SEEK_FLAG_FLUSH, position * gst.MSECOND) - self.gst_pipeline.get_state() # Block until state change - self.set_state('PLAYING') + self.gst_pipeline.get_state() def get_position(self): try: From 42bd13f30a159ea6601c1eaed83efab208f85a26 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 19 Aug 2010 19:50:08 +0200 Subject: [PATCH 30/80] Ensure that seek triggers playing --- mopidy/backends/local/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index 5199a9e1..d7257cad 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -63,6 +63,8 @@ class LocalPlaybackController(BasePlaybackController): def _seek(self, time_position): self._send({'command': 'set_position', 'position': time_position}) + if self.state == self.STOPPED: + self._set_state('PLAYING') @property def time_position(self): From 10c1cab0c7ea2ee1b04d817974f6e826171ee3f7 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 19 Aug 2010 23:49:59 +0200 Subject: [PATCH 31/80] Reorganize mopidy.frontends.mpd - Rename .frontend.MpdFrontend to .dispatcher.MpdDispatcher, as it dispatches requests. - Move exceptions into .exceptions. - Move .server.MpdSession to .session.MpdSession. - Move handle_pattern to .protocol.handle_pattern. --- mopidy/frontends/mpd/__init__.py | 93 ------------------- .../mpd/{frontend.py => dispatcher.py} | 21 ++--- mopidy/frontends/mpd/exceptions.py | 57 ++++++++++++ mopidy/frontends/mpd/protocol/__init__.py | 36 +++++++ mopidy/frontends/mpd/protocol/audio_output.py | 3 +- mopidy/frontends/mpd/protocol/command_list.py | 3 +- mopidy/frontends/mpd/protocol/connection.py | 9 +- .../mpd/protocol/current_playlist.py | 3 +- mopidy/frontends/mpd/protocol/empty.py | 6 ++ mopidy/frontends/mpd/protocol/music_db.py | 4 +- mopidy/frontends/mpd/protocol/playback.py | 3 +- mopidy/frontends/mpd/protocol/reflection.py | 4 +- mopidy/frontends/mpd/protocol/status.py | 3 +- mopidy/frontends/mpd/protocol/stickers.py | 3 +- .../mpd/protocol/stored_playlists.py | 4 +- mopidy/frontends/mpd/server.py | 73 +-------------- mopidy/frontends/mpd/session.py | 71 ++++++++++++++ mopidy/settings.py | 4 +- tests/frontends/mpd/audio_output_test.py | 4 +- tests/frontends/mpd/command_list_test.py | 4 +- tests/frontends/mpd/connection_test.py | 4 +- tests/frontends/mpd/current_playlist_test.py | 4 +- ...est_handler_test.py => dispatcher_test.py} | 16 ++-- tests/frontends/mpd/exception_test.py | 2 +- tests/frontends/mpd/music_db_test.py | 4 +- tests/frontends/mpd/playback_test.py | 6 +- tests/frontends/mpd/reflection_test.py | 4 +- tests/frontends/mpd/status_test.py | 48 +++++----- tests/frontends/mpd/stickers_test.py | 4 +- tests/frontends/mpd/stored_playlists_test.py | 4 +- 30 files changed, 258 insertions(+), 246 deletions(-) rename mopidy/frontends/mpd/{frontend.py => dispatcher.py} (82%) create mode 100644 mopidy/frontends/mpd/exceptions.py create mode 100644 mopidy/frontends/mpd/protocol/empty.py create mode 100644 mopidy/frontends/mpd/session.py rename tests/frontends/mpd/{request_handler_test.py => dispatcher_test.py} (75%) diff --git a/mopidy/frontends/mpd/__init__.py b/mopidy/frontends/mpd/__init__.py index 83d6ce4c..e69de29b 100644 --- a/mopidy/frontends/mpd/__init__.py +++ b/mopidy/frontends/mpd/__init__.py @@ -1,93 +0,0 @@ -import re - -from mopidy import MopidyException - -class MpdAckError(MopidyException): - """ - Available MPD error codes:: - - ACK_ERROR_NOT_LIST = 1 - ACK_ERROR_ARG = 2 - ACK_ERROR_PASSWORD = 3 - ACK_ERROR_PERMISSION = 4 - ACK_ERROR_UNKNOWN = 5 - ACK_ERROR_NO_EXIST = 50 - ACK_ERROR_PLAYLIST_MAX = 51 - ACK_ERROR_SYSTEM = 52 - ACK_ERROR_PLAYLIST_LOAD = 53 - ACK_ERROR_UPDATE_ALREADY = 54 - 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) - self.message = message - self.error_code = error_code - self.index = index - self.command = command - - def get_mpd_ack(self): - """ - MPD error code format:: - - 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) - -class MpdArgError(MpdAckError): - def __init__(self, *args, **kwargs): - super(MpdArgError, self).__init__(*args, **kwargs) - self.error_code = 2 # ACK_ERROR_ARG - -class MpdUnknownCommand(MpdAckError): - 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 = 5 # ACK_ERROR_UNKNOWN - -class MpdNoExistError(MpdAckError): - def __init__(self, *args, **kwargs): - super(MpdNoExistError, self).__init__(*args, **kwargs) - self.error_code = 50 # ACK_ERROR_NO_EXIST - -class MpdNotImplemented(MpdAckError): - def __init__(self, *args, **kwargs): - super(MpdNotImplemented, self).__init__(*args, **kwargs) - self.message = u'Not implemented' - -mpd_commands = set() -request_handlers = {} - -def handle_pattern(pattern): - """ - Decorator for connecting command handlers to command patterns. - - 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 - argument a default value. - - For example, if the command is ``do that thing`` the ``what`` argument will - be ``this thing``:: - - @handle_pattern('^do (?P.+)$') - def do(what): - ... - - :param pattern: regexp pattern for matching commands - :type pattern: string - """ - def decorator(func): - match = re.search('([a-z_]+)', pattern) - if match is not None: - mpd_commands.add(match.group()) - if pattern in request_handlers: - raise ValueError(u'Tried to redefine handler for %s with %s' % ( - pattern, func)) - request_handlers[pattern] = func - func.__doc__ = ' - *Pattern:* ``%s``\n\n%s' % ( - pattern, func.__doc__ or '') - return func - return decorator diff --git a/mopidy/frontends/mpd/frontend.py b/mopidy/frontends/mpd/dispatcher.py similarity index 82% rename from mopidy/frontends/mpd/frontend.py rename to mopidy/frontends/mpd/dispatcher.py index 9a0251eb..2a477e1c 100644 --- a/mopidy/frontends/mpd/frontend.py +++ b/mopidy/frontends/mpd/dispatcher.py @@ -1,20 +1,18 @@ -import logging import re -from mopidy.frontends.mpd import (mpd_commands, request_handlers, - handle_pattern, MpdAckError, MpdArgError, MpdUnknownCommand) +from mopidy.frontends.mpd.exceptions import (MpdAckError, MpdArgError, + 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. from mopidy.frontends.mpd.protocol import (audio_output, command_list, - connection, current_playlist, music_db, playback, reflection, status, - stickers, stored_playlists) + connection, current_playlist, empty, music_db, playback, reflection, + status, stickers, stored_playlists) from mopidy.utils import flatten -logger = logging.getLogger('mopidy.frontends.mpd.frontend') - -class MpdFrontend(object): +class MpdDispatcher(object): """ - The MPD frontend dispatches MPD requests to the correct handler. + Dispatches MPD requests to the correct handler. """ def __init__(self, backend=None): @@ -72,8 +70,3 @@ class MpdFrontend(object): if add_ok and (not response or not response[-1].startswith(u'ACK')): response.append(u'OK') return response - -@handle_pattern(r'^$') -def empty(frontend): - """The original MPD server returns ``OK`` on an empty request.""" - pass diff --git a/mopidy/frontends/mpd/exceptions.py b/mopidy/frontends/mpd/exceptions.py new file mode 100644 index 00000000..2a18b2f3 --- /dev/null +++ b/mopidy/frontends/mpd/exceptions.py @@ -0,0 +1,57 @@ +from mopidy import MopidyException + +class MpdAckError(MopidyException): + """ + Available MPD error codes:: + + ACK_ERROR_NOT_LIST = 1 + ACK_ERROR_ARG = 2 + ACK_ERROR_PASSWORD = 3 + ACK_ERROR_PERMISSION = 4 + ACK_ERROR_UNKNOWN = 5 + ACK_ERROR_NO_EXIST = 50 + ACK_ERROR_PLAYLIST_MAX = 51 + ACK_ERROR_SYSTEM = 52 + ACK_ERROR_PLAYLIST_LOAD = 53 + ACK_ERROR_UPDATE_ALREADY = 54 + 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) + self.message = message + self.error_code = error_code + self.index = index + self.command = command + + def get_mpd_ack(self): + """ + MPD error code format:: + + 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) + +class MpdArgError(MpdAckError): + def __init__(self, *args, **kwargs): + super(MpdArgError, self).__init__(*args, **kwargs) + self.error_code = 2 # ACK_ERROR_ARG + +class MpdUnknownCommand(MpdAckError): + 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 = 5 # ACK_ERROR_UNKNOWN + +class MpdNoExistError(MpdAckError): + def __init__(self, *args, **kwargs): + super(MpdNoExistError, self).__init__(*args, **kwargs) + self.error_code = 50 # ACK_ERROR_NO_EXIST + +class MpdNotImplemented(MpdAckError): + def __init__(self, *args, **kwargs): + super(MpdNotImplemented, self).__init__(*args, **kwargs) + self.message = u'Not implemented' diff --git a/mopidy/frontends/mpd/protocol/__init__.py b/mopidy/frontends/mpd/protocol/__init__.py index 00932e90..c27bf0de 100644 --- a/mopidy/frontends/mpd/protocol/__init__.py +++ b/mopidy/frontends/mpd/protocol/__init__.py @@ -10,8 +10,44 @@ implement our own MPD server which is compatible with the numerous existing `MPD clients `_. """ +import re + #: The MPD protocol uses UTF-8 for encoding all data. ENCODING = u'utf-8' #: The MPD protocol uses ``\n`` as line terminator. LINE_TERMINATOR = u'\n' + +mpd_commands = set() +request_handlers = {} + +def handle_pattern(pattern): + """ + Decorator for connecting command handlers to command patterns. + + 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 + argument a default value. + + For example, if the command is ``do that thing`` the ``what`` argument will + be ``this thing``:: + + @handle_pattern('^do (?P.+)$') + def do(what): + ... + + :param pattern: regexp pattern for matching commands + :type pattern: string + """ + def decorator(func): + match = re.search('([a-z_]+)', pattern) + if match is not None: + mpd_commands.add(match.group()) + if pattern in request_handlers: + raise ValueError(u'Tried to redefine handler for %s with %s' % ( + pattern, func)) + request_handlers[pattern] = func + func.__doc__ = ' - *Pattern:* ``%s``\n\n%s' % ( + pattern, func.__doc__ or '') + return func + return decorator diff --git a/mopidy/frontends/mpd/protocol/audio_output.py b/mopidy/frontends/mpd/protocol/audio_output.py index e659b162..d25fc118 100644 --- a/mopidy/frontends/mpd/protocol/audio_output.py +++ b/mopidy/frontends/mpd/protocol/audio_output.py @@ -1,4 +1,5 @@ -from mopidy.frontends.mpd import handle_pattern, MpdNotImplemented +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): diff --git a/mopidy/frontends/mpd/protocol/command_list.py b/mopidy/frontends/mpd/protocol/command_list.py index 900c26b0..b3df0be6 100644 --- a/mopidy/frontends/mpd/protocol/command_list.py +++ b/mopidy/frontends/mpd/protocol/command_list.py @@ -1,4 +1,5 @@ -from mopidy.frontends.mpd import handle_pattern, MpdUnknownCommand +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): diff --git a/mopidy/frontends/mpd/protocol/connection.py b/mopidy/frontends/mpd/protocol/connection.py index 4312ded5..0ce3ef51 100644 --- a/mopidy/frontends/mpd/protocol/connection.py +++ b/mopidy/frontends/mpd/protocol/connection.py @@ -1,4 +1,5 @@ -from mopidy.frontends.mpd import handle_pattern, MpdNotImplemented +from mopidy.frontends.mpd.protocol import handle_pattern +from mopidy.frontends.mpd.exceptions import MpdNotImplemented @handle_pattern(r'^close$') def close(frontend): @@ -9,8 +10,7 @@ def close(frontend): Closes the connection to MPD. """ - # TODO Does not work after multiprocessing branch merge - #frontend.session.do_close() + pass # TODO @handle_pattern(r'^kill$') def kill(frontend): @@ -21,8 +21,7 @@ def kill(frontend): Kills MPD. """ - # TODO Does not work after multiprocessing branch merge - #frontend.session.do_kill() + pass # TODO @handle_pattern(r'^password "(?P[^"]+)"$') def password_(frontend, password): diff --git a/mopidy/frontends/mpd/protocol/current_playlist.py b/mopidy/frontends/mpd/protocol/current_playlist.py index b9111d9e..90a53f5f 100644 --- a/mopidy/frontends/mpd/protocol/current_playlist.py +++ b/mopidy/frontends/mpd/protocol/current_playlist.py @@ -1,4 +1,5 @@ -from mopidy.frontends.mpd import (handle_pattern, MpdArgError, MpdNoExistError, +from mopidy.frontends.mpd.protocol import handle_pattern +from mopidy.frontends.mpd.exceptions import (MpdArgError, MpdNoExistError, MpdNotImplemented) @handle_pattern(r'^add "(?P[^"]*)"$') diff --git a/mopidy/frontends/mpd/protocol/empty.py b/mopidy/frontends/mpd/protocol/empty.py new file mode 100644 index 00000000..a39d79eb --- /dev/null +++ b/mopidy/frontends/mpd/protocol/empty.py @@ -0,0 +1,6 @@ +from mopidy.frontends.mpd.protocol import handle_pattern + +@handle_pattern(r'^$') +def empty(frontend): + """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 5aec6eae..d4dcf50d 100644 --- a/mopidy/frontends/mpd/protocol/music_db.py +++ b/mopidy/frontends/mpd/protocol/music_db.py @@ -1,7 +1,7 @@ import re -from mopidy.frontends.mpd import handle_pattern, MpdNotImplemented -from mopidy.frontends.mpd.protocol import stored_playlists +from mopidy.frontends.mpd.protocol import handle_pattern, stored_playlists +from mopidy.frontends.mpd.exceptions import MpdNotImplemented def _build_query(mpd_query): """ diff --git a/mopidy/frontends/mpd/protocol/playback.py b/mopidy/frontends/mpd/protocol/playback.py index 7abc4509..762e2da7 100644 --- a/mopidy/frontends/mpd/protocol/playback.py +++ b/mopidy/frontends/mpd/protocol/playback.py @@ -1,4 +1,5 @@ -from mopidy.frontends.mpd import (handle_pattern, MpdArgError, MpdNoExistError, +from mopidy.frontends.mpd.protocol import handle_pattern +from mopidy.frontends.mpd.exceptions import (MpdArgError, MpdNoExistError, MpdNotImplemented) @handle_pattern(r'^consume (?P[01])$') diff --git a/mopidy/frontends/mpd/protocol/reflection.py b/mopidy/frontends/mpd/protocol/reflection.py index 0c349746..d2c9c599 100644 --- a/mopidy/frontends/mpd/protocol/reflection.py +++ b/mopidy/frontends/mpd/protocol/reflection.py @@ -1,5 +1,5 @@ -from mopidy.frontends.mpd import (handle_pattern, mpd_commands, - MpdNotImplemented) +from mopidy.frontends.mpd.protocol import handle_pattern, mpd_commands +from mopidy.frontends.mpd.exceptions import MpdNotImplemented @handle_pattern(r'^commands$') def commands(frontend): diff --git a/mopidy/frontends/mpd/protocol/status.py b/mopidy/frontends/mpd/protocol/status.py index 16e73dea..e18f1ea4 100644 --- a/mopidy/frontends/mpd/protocol/status.py +++ b/mopidy/frontends/mpd/protocol/status.py @@ -1,4 +1,5 @@ -from mopidy.frontends.mpd import handle_pattern, MpdNotImplemented +from mopidy.frontends.mpd.protocol import handle_pattern +from mopidy.frontends.mpd.exceptions import MpdNotImplemented @handle_pattern(r'^clearerror$') def clearerror(frontend): diff --git a/mopidy/frontends/mpd/protocol/stickers.py b/mopidy/frontends/mpd/protocol/stickers.py index c184d1f9..145665eb 100644 --- a/mopidy/frontends/mpd/protocol/stickers.py +++ b/mopidy/frontends/mpd/protocol/stickers.py @@ -1,4 +1,5 @@ -from mopidy.frontends.mpd import handle_pattern, MpdNotImplemented +from mopidy.frontends.mpd.protocol import handle_pattern +from mopidy.frontends.mpd.exceptions import MpdNotImplemented @handle_pattern(r'^sticker delete "(?P[^"]+)" ' r'"(?P[^"]+)"( "(?P[^"]+)")*$') diff --git a/mopidy/frontends/mpd/protocol/stored_playlists.py b/mopidy/frontends/mpd/protocol/stored_playlists.py index 3d7a8710..c34b1676 100644 --- a/mopidy/frontends/mpd/protocol/stored_playlists.py +++ b/mopidy/frontends/mpd/protocol/stored_playlists.py @@ -1,7 +1,7 @@ import datetime as dt -from mopidy.frontends.mpd import (handle_pattern, MpdNoExistError, - MpdNotImplemented) +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): diff --git a/mopidy/frontends/mpd/server.py b/mopidy/frontends/mpd/server.py index 91d8e67a..db13e516 100644 --- a/mopidy/frontends/mpd/server.py +++ b/mopidy/frontends/mpd/server.py @@ -1,21 +1,18 @@ -import asynchat import asyncore import logging -import multiprocessing import re import socket import sys -from mopidy import get_mpd_protocol_version, settings -from mopidy.frontends.mpd.protocol import ENCODING, LINE_TERMINATOR -from mopidy.process import pickle_connection -from mopidy.utils import indent +from mopidy import settings +from .session import MpdSession logger = logging.getLogger('mopidy.frontends.mpd.server') class MpdServer(asyncore.dispatcher): """ - The MPD server. Creates a :class:`MpdSession` for each client connection. + The MPD server. Creates a :class:`mopidy.frontends.mpd.session.MpdSession` + for each client connection. """ def __init__(self, core_queue): @@ -58,65 +55,3 @@ class MpdServer(asyncore.dispatcher): and re.match('\d+.\d+.\d+.\d+', hostname) is not None): hostname = '::ffff:%s' % hostname return hostname - - -class MpdSession(asynchat.async_chat): - """ - The MPD client session. Keeps track of a single client and dispatches its - MPD requests to the frontend. - """ - - def __init__(self, server, client_socket, client_socket_address, - core_queue): - asynchat.async_chat.__init__(self, sock=client_socket) - self.server = server - self.client_address = client_socket_address[0] - self.client_port = client_socket_address[1] - self.core_queue = core_queue - self.input_buffer = [] - self.set_terminator(LINE_TERMINATOR.encode(ENCODING)) - - def start(self): - """Start a new client session.""" - self.send_response(u'OK MPD %s' % get_mpd_protocol_version()) - - def collect_incoming_data(self, data): - """Collect incoming data into buffer until a terminator is found.""" - self.input_buffer.append(data) - - def found_terminator(self): - """Handle request when a terminator is found.""" - 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) - except UnicodeDecodeError as e: - logger.warning(u'Received invalid data: %s', e) - - def handle_request(self, request): - """Handle request by sending it to the MPD frontend.""" - my_end, other_end = multiprocessing.Pipe() - self.core_queue.put({ - 'command': 'mpd_request', - 'request': request, - 'reply_to': pickle_connection(other_end), - }) - my_end.poll(None) - response = my_end.recv() - if response is not None: - self.handle_response(response) - - def handle_response(self, response): - """Handle response from the MPD frontend.""" - 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) diff --git a/mopidy/frontends/mpd/session.py b/mopidy/frontends/mpd/session.py new file mode 100644 index 00000000..0a7533e7 --- /dev/null +++ b/mopidy/frontends/mpd/session.py @@ -0,0 +1,71 @@ +import asynchat +import logging +import multiprocessing + +from mopidy import get_mpd_protocol_version +from mopidy.process import pickle_connection +from mopidy.utils import indent +from .protocol import ENCODING, LINE_TERMINATOR + +logger = logging.getLogger('mopidy.frontends.mpd.session') + +class MpdSession(asynchat.async_chat): + """ + The MPD client session. Keeps track of a single client and dispatches its + MPD requests to the frontend. + """ + + def __init__(self, server, client_socket, client_socket_address, + core_queue): + asynchat.async_chat.__init__(self, sock=client_socket) + self.server = server + self.client_address = client_socket_address[0] + self.client_port = client_socket_address[1] + self.core_queue = core_queue + self.input_buffer = [] + self.set_terminator(LINE_TERMINATOR.encode(ENCODING)) + + def start(self): + """Start a new client session.""" + self.send_response(u'OK MPD %s' % get_mpd_protocol_version()) + + def collect_incoming_data(self, data): + """Collect incoming data into buffer until a terminator is found.""" + self.input_buffer.append(data) + + def found_terminator(self): + """Handle request when a terminator is found.""" + 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) + except UnicodeDecodeError as e: + logger.warning(u'Received invalid data: %s', e) + + def handle_request(self, request): + """Handle request by sending it to the MPD frontend.""" + my_end, other_end = multiprocessing.Pipe() + self.core_queue.put({ + 'command': 'mpd_request', + 'request': request, + 'reply_to': pickle_connection(other_end), + }) + my_end.poll(None) + response = my_end.recv() + if response is not None: + self.handle_response(response) + + def handle_response(self, response): + """Handle response from the MPD frontend.""" + 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) diff --git a/mopidy/settings.py b/mopidy/settings.py index c9e3606e..895a1e24 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -45,8 +45,8 @@ DUMP_LOG_FILENAME = u'dump.log' #: #: Default:: #: -#: FRONTEND = u'mopidy.frontends.mpd.frontend.MpdFrontend' -FRONTEND = u'mopidy.frontends.mpd.frontend.MpdFrontend' +#: FRONTEND = u'mopidy.frontends.mpd.dispatcher.MpdDispatcher' +FRONTEND = u'mopidy.frontends.mpd.dispatcher.MpdDispatcher' #: Path to folder with local music. #: diff --git a/tests/frontends/mpd/audio_output_test.py b/tests/frontends/mpd/audio_output_test.py index 24201341..b81e727e 100644 --- a/tests/frontends/mpd/audio_output_test.py +++ b/tests/frontends/mpd/audio_output_test.py @@ -1,13 +1,13 @@ import unittest from mopidy.backends.dummy import DummyBackend -from mopidy.frontends.mpd import frontend +from mopidy.frontends.mpd import dispatcher from mopidy.mixers.dummy import DummyMixer class AudioOutputHandlerTest(unittest.TestCase): def setUp(self): self.b = DummyBackend(mixer_class=DummyMixer) - self.h = frontend.MpdFrontend(backend=self.b) + self.h = dispatcher.MpdDispatcher(backend=self.b) def test_enableoutput(self): result = self.h.handle_request(u'enableoutput "0"') diff --git a/tests/frontends/mpd/command_list_test.py b/tests/frontends/mpd/command_list_test.py index 683a1013..6c801c3f 100644 --- a/tests/frontends/mpd/command_list_test.py +++ b/tests/frontends/mpd/command_list_test.py @@ -1,13 +1,13 @@ import unittest from mopidy.backends.dummy import DummyBackend -from mopidy.frontends.mpd import frontend +from mopidy.frontends.mpd import dispatcher from mopidy.mixers.dummy import DummyMixer class CommandListsTest(unittest.TestCase): def setUp(self): self.b = DummyBackend(mixer_class=DummyMixer) - self.h = frontend.MpdFrontend(backend=self.b) + self.h = dispatcher.MpdDispatcher(backend=self.b) def test_command_list_begin(self): result = self.h.handle_request(u'command_list_begin') diff --git a/tests/frontends/mpd/connection_test.py b/tests/frontends/mpd/connection_test.py index 341e630c..21753054 100644 --- a/tests/frontends/mpd/connection_test.py +++ b/tests/frontends/mpd/connection_test.py @@ -1,13 +1,13 @@ import unittest from mopidy.backends.dummy import DummyBackend -from mopidy.frontends.mpd import frontend +from mopidy.frontends.mpd import dispatcher from mopidy.mixers.dummy import DummyMixer class ConnectionHandlerTest(unittest.TestCase): def setUp(self): self.b = DummyBackend(mixer_class=DummyMixer) - self.h = frontend.MpdFrontend(backend=self.b) + self.h = dispatcher.MpdDispatcher(backend=self.b) def test_close(self): result = self.h.handle_request(u'close') diff --git a/tests/frontends/mpd/current_playlist_test.py b/tests/frontends/mpd/current_playlist_test.py index e27e58c5..c53e2b8d 100644 --- a/tests/frontends/mpd/current_playlist_test.py +++ b/tests/frontends/mpd/current_playlist_test.py @@ -1,14 +1,14 @@ import unittest from mopidy.backends.dummy import DummyBackend -from mopidy.frontends.mpd import frontend +from mopidy.frontends.mpd import dispatcher from mopidy.mixers.dummy import DummyMixer from mopidy.models import Track class CurrentPlaylistHandlerTest(unittest.TestCase): def setUp(self): self.b = DummyBackend(mixer_class=DummyMixer) - self.h = frontend.MpdFrontend(backend=self.b) + self.h = dispatcher.MpdDispatcher(backend=self.b) def test_add(self): needle = Track(uri='dummy://foo') diff --git a/tests/frontends/mpd/request_handler_test.py b/tests/frontends/mpd/dispatcher_test.py similarity index 75% rename from tests/frontends/mpd/request_handler_test.py rename to tests/frontends/mpd/dispatcher_test.py index ac8bd7e9..2a2ee4db 100644 --- a/tests/frontends/mpd/request_handler_test.py +++ b/tests/frontends/mpd/dispatcher_test.py @@ -1,19 +1,21 @@ import unittest from mopidy.backends.dummy import DummyBackend -from mopidy.frontends.mpd import frontend, MpdAckError +from mopidy.frontends.mpd import dispatcher +from mopidy.frontends.mpd.exceptions import MpdAckError +from mopidy.frontends.mpd.protocol import request_handlers, handle_pattern from mopidy.mixers.dummy import DummyMixer -class RequestHandlerTest(unittest.TestCase): +class MpdDispatcherTest(unittest.TestCase): def setUp(self): self.b = DummyBackend(mixer_class=DummyMixer) - self.h = frontend.MpdFrontend(backend=self.b) + self.h = dispatcher.MpdDispatcher(backend=self.b) def test_register_same_pattern_twice_fails(self): func = lambda: None try: - frontend.handle_pattern('a pattern')(func) - frontend.handle_pattern('a pattern')(func) + handle_pattern('a pattern')(func) + handle_pattern('a pattern')(func) self.fail('Registering a pattern twice shoulde raise ValueError') except ValueError: pass @@ -28,7 +30,7 @@ class RequestHandlerTest(unittest.TestCase): def test_finding_handler_for_known_command_returns_handler_and_kwargs(self): expected_handler = lambda x: None - frontend.request_handlers['known_command (?P.+)'] = \ + request_handlers['known_command (?P.+)'] = \ expected_handler (handler, kwargs) = self.h.find_handler('known_command an_arg') self.assertEqual(handler, expected_handler) @@ -41,7 +43,7 @@ class RequestHandlerTest(unittest.TestCase): def test_handling_known_request(self): expected = 'magic' - frontend.request_handlers['known request'] = lambda x: expected + request_handlers['known request'] = lambda x: expected result = self.h.handle_request('known request') self.assert_(u'OK' in result) self.assert_(expected in result) diff --git a/tests/frontends/mpd/exception_test.py b/tests/frontends/mpd/exception_test.py index e337550f..ef222d46 100644 --- a/tests/frontends/mpd/exception_test.py +++ b/tests/frontends/mpd/exception_test.py @@ -1,6 +1,6 @@ import unittest -from mopidy.frontends.mpd import (MpdAckError, MpdUnknownCommand, +from mopidy.frontends.mpd.exceptions import (MpdAckError, MpdUnknownCommand, MpdNotImplemented) class MpdExceptionsTest(unittest.TestCase): diff --git a/tests/frontends/mpd/music_db_test.py b/tests/frontends/mpd/music_db_test.py index fc8f980a..5fcc393c 100644 --- a/tests/frontends/mpd/music_db_test.py +++ b/tests/frontends/mpd/music_db_test.py @@ -1,13 +1,13 @@ import unittest from mopidy.backends.dummy import DummyBackend -from mopidy.frontends.mpd import frontend +from mopidy.frontends.mpd import dispatcher from mopidy.mixers.dummy import DummyMixer class MusicDatabaseHandlerTest(unittest.TestCase): def setUp(self): self.b = DummyBackend(mixer_class=DummyMixer) - self.h = frontend.MpdFrontend(backend=self.b) + self.h = dispatcher.MpdDispatcher(backend=self.b) def test_count(self): result = self.h.handle_request(u'count "tag" "needle"') diff --git a/tests/frontends/mpd/playback_test.py b/tests/frontends/mpd/playback_test.py index 17263aef..3ba48a54 100644 --- a/tests/frontends/mpd/playback_test.py +++ b/tests/frontends/mpd/playback_test.py @@ -1,14 +1,14 @@ import unittest from mopidy.backends.dummy import DummyBackend -from mopidy.frontends.mpd import frontend +from mopidy.frontends.mpd import dispatcher from mopidy.mixers.dummy import DummyMixer from mopidy.models import Track class PlaybackOptionsHandlerTest(unittest.TestCase): def setUp(self): self.b = DummyBackend(mixer_class=DummyMixer) - self.h = frontend.MpdFrontend(backend=self.b) + self.h = dispatcher.MpdDispatcher(backend=self.b) def test_consume_off(self): result = self.h.handle_request(u'consume "0"') @@ -167,7 +167,7 @@ class PlaybackOptionsHandlerTest(unittest.TestCase): class PlaybackControlHandlerTest(unittest.TestCase): def setUp(self): self.b = DummyBackend(mixer_class=DummyMixer) - self.h = frontend.MpdFrontend(backend=self.b) + self.h = dispatcher.MpdDispatcher(backend=self.b) def test_next(self): result = self.h.handle_request(u'next') diff --git a/tests/frontends/mpd/reflection_test.py b/tests/frontends/mpd/reflection_test.py index 5491946c..a4491d75 100644 --- a/tests/frontends/mpd/reflection_test.py +++ b/tests/frontends/mpd/reflection_test.py @@ -1,13 +1,13 @@ import unittest from mopidy.backends.dummy import DummyBackend -from mopidy.frontends.mpd import frontend +from mopidy.frontends.mpd import dispatcher from mopidy.mixers.dummy import DummyMixer class ReflectionHandlerTest(unittest.TestCase): def setUp(self): self.b = DummyBackend(mixer_class=DummyMixer) - self.h = frontend.MpdFrontend(backend=self.b) + self.h = dispatcher.MpdDispatcher(backend=self.b) def test_commands_returns_list_of_all_commands(self): result = self.h.handle_request(u'commands') diff --git a/tests/frontends/mpd/status_test.py b/tests/frontends/mpd/status_test.py index 9839acfe..fbd0ff9e 100644 --- a/tests/frontends/mpd/status_test.py +++ b/tests/frontends/mpd/status_test.py @@ -1,14 +1,14 @@ import unittest from mopidy.backends.dummy import DummyBackend -from mopidy.frontends.mpd import frontend +from mopidy.frontends.mpd import dispatcher from mopidy.mixers.dummy import DummyMixer from mopidy.models import Track class StatusHandlerTest(unittest.TestCase): def setUp(self): self.b = DummyBackend(mixer_class=DummyMixer) - self.h = frontend.MpdFrontend(backend=self.b) + self.h = dispatcher.MpdDispatcher(backend=self.b) def test_clearerror(self): result = self.h.handle_request(u'clearerror') @@ -51,7 +51,7 @@ class StatusHandlerTest(unittest.TestCase): self.assert_(u'OK' in result) def test_stats_method(self): - result = frontend.status.stats(self.h) + result = dispatcher.status.stats(self.h) self.assert_('artists' in result) self.assert_(int(result['artists']) >= 0) self.assert_('albums' in result) @@ -72,106 +72,106 @@ class StatusHandlerTest(unittest.TestCase): self.assert_(u'OK' in result) def test_status_method_contains_volume_which_defaults_to_0(self): - result = dict(frontend.status.status(self.h)) + result = dict(dispatcher.status.status(self.h)) self.assert_('volume' in result) self.assertEqual(int(result['volume']), 0) def test_status_method_contains_volume(self): self.b.mixer.volume = 17 - result = dict(frontend.status.status(self.h)) + result = dict(dispatcher.status.status(self.h)) self.assert_('volume' in result) self.assertEqual(int(result['volume']), 17) def test_status_method_contains_repeat_is_0(self): - result = dict(frontend.status.status(self.h)) + result = dict(dispatcher.status.status(self.h)) 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(frontend.status.status(self.h)) + result = dict(dispatcher.status.status(self.h)) self.assert_('repeat' in result) self.assertEqual(int(result['repeat']), 1) def test_status_method_contains_random_is_0(self): - result = dict(frontend.status.status(self.h)) + result = dict(dispatcher.status.status(self.h)) 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(frontend.status.status(self.h)) + result = dict(dispatcher.status.status(self.h)) self.assert_('random' in result) self.assertEqual(int(result['random']), 1) def test_status_method_contains_single(self): - result = dict(frontend.status.status(self.h)) + result = dict(dispatcher.status.status(self.h)) self.assert_('single' in result) self.assert_(int(result['single']) in (0, 1)) def test_status_method_contains_consume_is_0(self): - result = dict(frontend.status.status(self.h)) + result = dict(dispatcher.status.status(self.h)) 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(frontend.status.status(self.h)) + result = dict(dispatcher.status.status(self.h)) self.assert_('consume' in result) self.assertEqual(int(result['consume']), 1) def test_status_method_contains_playlist(self): - result = dict(frontend.status.status(self.h)) + result = dict(dispatcher.status.status(self.h)) 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(frontend.status.status(self.h)) + result = dict(dispatcher.status.status(self.h)) self.assert_('playlistlength' in result) self.assert_(int(result['playlistlength']) >= 0) def test_status_method_contains_xfade(self): - result = dict(frontend.status.status(self.h)) + result = dict(dispatcher.status.status(self.h)) self.assert_('xfade' in result) self.assert_(int(result['xfade']) >= 0) def test_status_method_contains_state_is_play(self): self.b.playback.state = self.b.playback.PLAYING - result = dict(frontend.status.status(self.h)) + result = dict(dispatcher.status.status(self.h)) self.assert_('state' in result) self.assertEqual(result['state'], 'play') def test_status_method_contains_state_is_stop(self): self.b.playback.state = self.b.playback.STOPPED - result = dict(frontend.status.status(self.h)) + result = dict(dispatcher.status.status(self.h)) self.assert_('state' in result) self.assertEqual(result['state'], 'stop') def test_status_method_contains_state_is_pause(self): self.b.playback.state = self.b.playback.PLAYING self.b.playback.state = self.b.playback.PAUSED - result = dict(frontend.status.status(self.h)) + result = dict(dispatcher.status.status(self.h)) 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(frontend.status.status(self.h)) + result = dict(dispatcher.status.status(self.h)) 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(frontend.status.status(self.h)) + result = dict(dispatcher.status.status(self.h)) self.assert_('songid' in result) self.assertEqual(int(result['songid']), 1) 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(frontend.status.status(self.h)) + result = dict(dispatcher.status.status(self.h)) self.assert_('time' in result) (position, total) = result['time'].split(':') position = int(position) @@ -181,7 +181,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(frontend.status.status(self.h)) + result = dict(dispatcher.status.status(self.h)) self.assert_('time' in result) (position, total) = result['time'].split(':') position = int(position) @@ -191,13 +191,13 @@ class StatusHandlerTest(unittest.TestCase): def test_status_method_when_playing_contains_elapsed(self): self.b.playback.state = self.b.playback.PAUSED self.b.playback._play_time_accumulated = 59123 - result = dict(frontend.status.status(self.h)) + result = dict(dispatcher.status.status(self.h)) 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(frontend.status.status(self.h)) + result = dict(dispatcher.status.status(self.h)) self.assert_('bitrate' in result) self.assertEqual(int(result['bitrate']), 320) diff --git a/tests/frontends/mpd/stickers_test.py b/tests/frontends/mpd/stickers_test.py index 401eaf57..5b66d723 100644 --- a/tests/frontends/mpd/stickers_test.py +++ b/tests/frontends/mpd/stickers_test.py @@ -1,13 +1,13 @@ import unittest from mopidy.backends.dummy import DummyBackend -from mopidy.frontends.mpd import frontend +from mopidy.frontends.mpd import dispatcher from mopidy.mixers.dummy import DummyMixer class StickersHandlerTest(unittest.TestCase): def setUp(self): self.b = DummyBackend(mixer_class=DummyMixer) - self.h = frontend.MpdFrontend(backend=self.b) + self.h = dispatcher.MpdDispatcher(backend=self.b) def test_sticker_get(self): result = self.h.handle_request( diff --git a/tests/frontends/mpd/stored_playlists_test.py b/tests/frontends/mpd/stored_playlists_test.py index 6e5717af..a24cbb88 100644 --- a/tests/frontends/mpd/stored_playlists_test.py +++ b/tests/frontends/mpd/stored_playlists_test.py @@ -2,14 +2,14 @@ import datetime as dt import unittest from mopidy.backends.dummy import DummyBackend -from mopidy.frontends.mpd import frontend +from mopidy.frontends.mpd import dispatcher from mopidy.mixers.dummy import DummyMixer from mopidy.models import Track, Playlist class StoredPlaylistsHandlerTest(unittest.TestCase): def setUp(self): self.b = DummyBackend(mixer_class=DummyMixer) - self.h = frontend.MpdFrontend(backend=self.b) + self.h = dispatcher.MpdDispatcher(backend=self.b) def test_listplaylist(self): self.b.stored_playlists.playlists = [ From ef03bbe19e2a49ff4a40098d0fecac7da032ab71 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Fri, 20 Aug 2010 00:17:08 +0200 Subject: [PATCH 32/80] Add tests to check return value of seek --- tests/backends/base.py | 44 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 38 insertions(+), 6 deletions(-) diff --git a/tests/backends/base.py b/tests/backends/base.py index 005aadae..e6c10a5a 100644 --- a/tests/backends/base.py +++ b/tests/backends/base.py @@ -794,7 +794,8 @@ class BasePlaybackControllerTest(object): @populate_playlist def test_end_of_track_callback_gets_called(self): self.playback.play() - self.playback.seek(self.tracks[0].length - 10) + result = self.playback.seek(self.tracks[0].length - 10) + self.assert_(result, 'Seek failed') message = self.core_queue.get() self.assertEqual('end_of_track', message['command']) @@ -879,11 +880,20 @@ class BasePlaybackControllerTest(object): @populate_playlist def test_seek_when_stopped(self): + result = self.playback.seek(1000) + self.assert_(result, 'Seek return value was %s' % result) + + @populate_playlist + def test_seek_when_stopped_updates_position(self): self.playback.seek(1000) position = self.playback.time_position self.assert_(position >= 990, position) def test_seek_on_empty_playlist(self): + result = self.playback.seek(0) + self.assert_(not result, 'Seek return value was %s' % result) + + def test_seek_on_empty_playlist_updates_position(self): self.playback.seek(0) self.assertEqual(self.playback.state, self.playback.STOPPED) @@ -894,6 +904,12 @@ class BasePlaybackControllerTest(object): @populate_playlist def test_seek_when_playing(self): + self.playback.play() + result = self.playback.seek(self.tracks[0].length - 1000) + self.assert_(result, 'Seek return value was %s' % result) + + @populate_playlist + def test_seek_when_playing_updates_position(self): length = self.backend.current_playlist.tracks[0].length self.playback.play() self.playback.seek(length - 1000) @@ -902,6 +918,13 @@ class BasePlaybackControllerTest(object): @populate_playlist def test_seek_when_paused(self): + self.playback.play() + self.playback.pause() + result = self.playback.seek(self.tracks[0].length - 1000) + self.assert_(result, 'Seek return value was %s' % result) + + @populate_playlist + def test_seek_when_paused_updates_position(self): length = self.backend.current_playlist.tracks[0].length self.playback.play() self.playback.pause() @@ -918,6 +941,13 @@ class BasePlaybackControllerTest(object): @populate_playlist def test_seek_beyond_end_of_song(self): + raise SkipTest # FIXME need to decide return value + self.playback.play() + result = self.playback.seek(self.tracks[0].length*100) + self.assert_(not result, 'Seek return value was %s' % result) + + @populate_playlist + def test_seek_beyond_end_of_song_jumps_to_next_song(self): self.playback.play() self.playback.seek(self.tracks[0].length*100) self.assertEqual(self.playback.current_track, self.tracks[1]) @@ -930,17 +960,19 @@ class BasePlaybackControllerTest(object): @populate_playlist def test_seek_beyond_start_of_song(self): + raise SkipTest # FIXME need to decide return value + self.playback.play() + result = self.playback.seek(-1000) + self.assert_(not result, 'Seek return value was %s' % result) + + @populate_playlist + def test_seek_beyond_start_of_song_update_postion(self): self.playback.play() self.playback.seek(-1000) position = self.playback.time_position self.assert_(position >= 0, position) self.assertEqual(self.playback.state, self.playback.PLAYING) - @populate_playlist - def test_seek_return_value(self): - self.playback.play() - self.assertEqual(self.playback.seek(0), None) - @populate_playlist def test_stop_when_stopped(self): self.playback.stop() From dbdfb3a8c776e8e37b31e9efc3d850b8a13c5c7a Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Fri, 20 Aug 2010 00:17:34 +0200 Subject: [PATCH 33/80] Fix seek return value tests --- mopidy/backends/base/playback.py | 5 ++++- mopidy/backends/local/__init__.py | 5 ++--- mopidy/outputs/gstreamer.py | 10 +++++++--- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/mopidy/backends/base/playback.py b/mopidy/backends/base/playback.py index d1acc05a..2b5d7478 100644 --- a/mopidy/backends/base/playback.py +++ b/mopidy/backends/base/playback.py @@ -440,7 +440,10 @@ class BasePlaybackController(object): :param time_position: time position in milliseconds :type time_position: int + :rtype: :class:`True` if successful, else :class:`False` """ + # FIXME I think return value is only realy usefull for internal + # testing, as such it should probably not be exposed in api. if self.state == self.STOPPED: self.play() elif self.state == self.PAUSED: @@ -455,7 +458,7 @@ class BasePlaybackController(object): self._play_time_started = self._current_wall_time self._play_time_accumulated = time_position - self._seek(time_position) + return self._seek(time_position) def _seek(self, time_position): """ diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index d7257cad..bd8a4301 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -62,9 +62,8 @@ class LocalPlaybackController(BasePlaybackController): return self._set_state('PLAYING') def _seek(self, time_position): - self._send({'command': 'set_position', 'position': time_position}) - if self.state == self.STOPPED: - self._set_state('PLAYING') + return self._send_recv({'command': 'set_position', + 'position': time_position}) @property def time_position(self): diff --git a/mopidy/outputs/gstreamer.py b/mopidy/outputs/gstreamer.py index 655487d5..de5b7734 100644 --- a/mopidy/outputs/gstreamer.py +++ b/mopidy/outputs/gstreamer.py @@ -109,7 +109,9 @@ class GStreamerProcess(BaseProcess): elif message['command'] == 'set_volume': self.set_volume(message['volume']) elif message['command'] == 'set_position': - self.set_position(message['position']) + response = self.set_position(message['position']) + connection = unpickle_connection(message['reply_to']) + connection.send(response) elif message['command'] == 'get_position': response = self.get_position() connection = unpickle_connection(message['reply_to']) @@ -191,9 +193,11 @@ class GStreamerProcess(BaseProcess): gst_volume.set_property('volume', volume / 100.0) def set_position(self, position): - self.gst_pipeline.seek_simple(gst.Format(gst.FORMAT_TIME), + 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) - self.gst_pipeline.get_state() + self.gst_pipeline.get_state() # block until seek is done + return handeled def get_position(self): try: From 5e10ad0e05c617f5b90ce64cc1cfe0677d30d119 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 20 Aug 2010 00:38:36 +0200 Subject: [PATCH 34/80] Replace SERVER and FRONTEND with a new FRONTENDS setting --- docs/api/mpd.rst | 19 +++++++++++++++--- docs/changes.rst | 3 +++ mopidy/__main__.py | 6 +++--- mopidy/frontends/mpd/__init__.py | 33 ++++++++++++++++++++++++++++++++ mopidy/frontends/mpd/session.py | 4 ++-- mopidy/process.py | 11 +++++------ mopidy/settings.py | 16 ++++++---------- mopidy/utils/settings.py | 4 +++- 8 files changed, 71 insertions(+), 25 deletions(-) diff --git a/docs/api/mpd.rst b/docs/api/mpd.rst index 7bf7fe7b..6361e909 100644 --- a/docs/api/mpd.rst +++ b/docs/api/mpd.rst @@ -4,6 +4,8 @@ .. automodule:: mopidy.frontends.mpd :synopsis: MPD frontend + :members: + :undoc-members: MPD server @@ -17,10 +19,21 @@ MPD server .. inheritance-diagram:: mopidy.frontends.mpd.server -MPD frontend -============ +MPD session +=========== -.. automodule:: mopidy.frontends.mpd.frontend +.. automodule:: mopidy.frontends.mpd.session + :synopsis: MPD client session + :members: + :undoc-members: + +.. inheritance-diagram:: mopidy.frontends.mpd.session + + +MPD dispatcher +============== + +.. automodule:: mopidy.frontends.mpd.dispatcher :synopsis: MPD request dispatcher :members: :undoc-members: diff --git a/docs/changes.rst b/docs/changes.rst index 341ef850..7c2ce19d 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -58,6 +58,9 @@ greatly improved MPD client support. - Support for single track repeat added. (Fixes: :issue:`4`) - Rename ``mopidy.frontends.mpd.{serializer => translator}`` to match naming in backends. + - Remove setting :attr:`mopidy.settings.SERVER` and + :attr:`mopidy.settings.FRONTEND` in favour of the new + :attr:`mopidy.settings.FRONTENDS`. - Backends: diff --git a/mopidy/__main__.py b/mopidy/__main__.py index a2230180..1c0318e7 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -24,11 +24,11 @@ def main(): logger.info('-- Starting Mopidy --') get_or_create_folder('~/.mopidy/') core_queue = multiprocessing.Queue() - get_class(settings.SERVER)(core_queue).start() output_class = get_class(settings.OUTPUT) backend_class = get_class(settings.BACKENDS[0]) - frontend_class = get_class(settings.FRONTEND) - core = CoreProcess(core_queue, output_class, backend_class, frontend_class) + frontend = get_class(settings.FRONTENDS[0])() + frontend.start_server(core_queue) + core = CoreProcess(core_queue, output_class, backend_class, frontend) core.start() asyncore.loop() diff --git a/mopidy/frontends/mpd/__init__.py b/mopidy/frontends/mpd/__init__.py index e69de29b..53f2003f 100644 --- a/mopidy/frontends/mpd/__init__.py +++ b/mopidy/frontends/mpd/__init__.py @@ -0,0 +1,33 @@ +from mopidy.frontends.mpd.dispatcher import MpdDispatcher +from mopidy.frontends.mpd.server import MpdServer + +class MpdFrontend(object): + """ + The MPD frontend. + """ + + def __init__(self): + self.server = None + self.dispatcher = None + + def start_server(self, core_queue): + """ + Starts the MPD server. + + :param core_queue: the core queue + :type core_queue: :class:`multiprocessing.Queue` + """ + self.server = MpdServer(core_queue) + self.server.start() + + def create_dispatcher(self, backend): + """ + Creates a dispatcher for MPD requests. + + :param backend: the backend + :type backend: :class:`mopidy.backends.base.BaseBackend` + :rtype: :class:`mopidy.frontends.mpd.dispatcher.MpdDispatcher` + """ + + self.dispatcher = MpdDispatcher(backend) + return self.dispatcher diff --git a/mopidy/frontends/mpd/session.py b/mopidy/frontends/mpd/session.py index 0a7533e7..305c8b84 100644 --- a/mopidy/frontends/mpd/session.py +++ b/mopidy/frontends/mpd/session.py @@ -11,8 +11,8 @@ logger = logging.getLogger('mopidy.frontends.mpd.session') class MpdSession(asynchat.async_chat): """ - The MPD client session. Keeps track of a single client and dispatches its - MPD requests to the frontend. + The MPD client session. Keeps track of a single client and passes its + MPD requests to the dispatcher. """ def __init__(self, server, client_socket, client_socket_address, diff --git a/mopidy/process.py b/mopidy/process.py index 01ac8ed4..5fac3d65 100644 --- a/mopidy/process.py +++ b/mopidy/process.py @@ -36,17 +36,16 @@ class BaseProcess(multiprocessing.Process): class CoreProcess(BaseProcess): - def __init__(self, core_queue, output_class, backend_class, - frontend_class): + def __init__(self, core_queue, output_class, backend_class, frontend): super(CoreProcess, self).__init__() self.core_queue = core_queue self.output_queue = None self.output_class = output_class self.backend_class = backend_class - self.frontend_class = frontend_class self.output = None self.backend = None - self.frontend = None + self.frontend = frontend + self.dispatcher = None def run_inside_try(self): self.setup() @@ -58,13 +57,13 @@ class CoreProcess(BaseProcess): self.output_queue = multiprocessing.Queue() self.output = self.output_class(self.core_queue, self.output_queue) self.backend = self.backend_class(self.core_queue, self.output_queue) - self.frontend = self.frontend_class(self.backend) + self.dispatcher = self.frontend.create_dispatcher(self.backend) def process_message(self, message): if message.get('to') == 'output': self.output_queue.put(message) elif message['command'] == 'mpd_request': - response = self.frontend.handle_request(message['request']) + response = self.dispatcher.handle_request(message['request']) connection = unpickle_connection(message['reply_to']) connection.send(response) elif message['command'] == 'end_of_track': diff --git a/mopidy/settings.py b/mopidy/settings.py index 895a1e24..8a0ae862 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -41,12 +41,15 @@ DUMP_LOG_FORMAT = CONSOLE_LOG_FORMAT #: DUMP_LOG_FILENAME = u'dump.log' DUMP_LOG_FILENAME = u'dump.log' -#: Protocol frontend to use. +#: List of server frontends to use. #: #: Default:: #: -#: FRONTEND = u'mopidy.frontends.mpd.dispatcher.MpdDispatcher' -FRONTEND = u'mopidy.frontends.mpd.dispatcher.MpdDispatcher' +#: FRONTENDS = (u'mopidy.frontends.mpd.MpdFrontend',) +#: +#: .. note:: +#: Currently only the first frontend in the list is used. +FRONTENDS = (u'mopidy.frontends.mpd.MpdFrontend',) #: Path to folder with local music. #: @@ -127,13 +130,6 @@ MIXER_MAX_VOLUME = 100 #: OUTPUT = u'mopidy.outputs.gstreamer.GStreamerOutput' OUTPUT = u'mopidy.outputs.gstreamer.GStreamerOutput' -#: Server to use. -#: -#: Default:: -#: -#: SERVER = u'mopidy.frontends.mpd.server.MpdServer' -SERVER = u'mopidy.frontends.mpd.server.MpdServer' - #: Which address Mopidy's MPD server should bind to. #: #:Examples: diff --git a/mopidy/utils/settings.py b/mopidy/utils/settings.py index 478a03e6..f1d213de 100644 --- a/mopidy/utils/settings.py +++ b/mopidy/utils/settings.py @@ -37,7 +37,7 @@ class SettingsProxy(object): def current(self): current = copy(self.default) current.update(self.local) - return current + return current def __getattr__(self, attr): if not self._is_setting(attr): @@ -81,6 +81,8 @@ def validate_settings(defaults, settings): errors = {} changed = { + 'FRONTEND': 'FRONTENDS', + 'SERVER': None, 'SERVER_HOSTNAME': 'MPD_SERVER_HOSTNAME', 'SERVER_PORT': 'MPD_SERVER_PORT', 'SPOTIFY_LIB_APPKEY': None, From a91a5eb04aacd070a17ebfbcf09ceb1eb2eb2517 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Fri, 20 Aug 2010 00:43:12 +0200 Subject: [PATCH 35/80] Put in place better logging for exceptions in processes --- mopidy/process.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/mopidy/process.py b/mopidy/process.py index 01ac8ed4..5ad1b0b4 100644 --- a/mopidy/process.py +++ b/mopidy/process.py @@ -30,6 +30,9 @@ class BaseProcess(multiprocessing.Process): except ImportError as e: logger.error(e) sys.exit(1) + except Exception as e: + logger.exception(e) + raise e def run_inside_try(self): raise NotImplementedError From 222982e444de47082be763e3f7ab2bcf5442183c Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 20 Aug 2010 00:44:09 +0200 Subject: [PATCH 36/80] Move get_mpd_protocol_version() into MPD frontend --- mopidy/__init__.py | 3 --- mopidy/frontends/mpd/protocol/__init__.py | 3 +++ mopidy/frontends/mpd/session.py | 5 ++--- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/mopidy/__init__.py b/mopidy/__init__.py index e3321041..7cdcad6a 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -5,9 +5,6 @@ if not (2, 6) <= sys.version_info < (3,): def get_version(): return u'0.1.0a4' -def get_mpd_protocol_version(): - return u'0.16.0' - class MopidyException(Exception): def __init__(self, message, *args, **kwargs): super(MopidyException, self).__init__(message, *args, **kwargs) diff --git a/mopidy/frontends/mpd/protocol/__init__.py b/mopidy/frontends/mpd/protocol/__init__.py index c27bf0de..756aa3c3 100644 --- a/mopidy/frontends/mpd/protocol/__init__.py +++ b/mopidy/frontends/mpd/protocol/__init__.py @@ -18,6 +18,9 @@ ENCODING = u'utf-8' #: The MPD protocol uses ``\n`` as line terminator. LINE_TERMINATOR = u'\n' +#: The MPD protocol version is 0.16.0. +VERSION = u'0.16.0' + mpd_commands = set() request_handlers = {} diff --git a/mopidy/frontends/mpd/session.py b/mopidy/frontends/mpd/session.py index 305c8b84..e5d2d12d 100644 --- a/mopidy/frontends/mpd/session.py +++ b/mopidy/frontends/mpd/session.py @@ -2,10 +2,9 @@ import asynchat import logging import multiprocessing -from mopidy import get_mpd_protocol_version +from mopidy.frontends.mpd.protocol import ENCODING, LINE_TERMINATOR, VERSION from mopidy.process import pickle_connection from mopidy.utils import indent -from .protocol import ENCODING, LINE_TERMINATOR logger = logging.getLogger('mopidy.frontends.mpd.session') @@ -27,7 +26,7 @@ class MpdSession(asynchat.async_chat): def start(self): """Start a new client session.""" - self.send_response(u'OK MPD %s' % get_mpd_protocol_version()) + self.send_response(u'OK MPD %s' % VERSION) def collect_incoming_data(self, data): """Collect incoming data into buffer until a terminator is found.""" From 366f5d8feece07e40eed6d68a57c1506815a3195 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Fri, 20 Aug 2010 00:45:23 +0200 Subject: [PATCH 37/80] Don't use return values from backend in frontend.mpd.protocol.playback.seek --- mopidy/frontends/mpd/protocol/playback.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/frontends/mpd/protocol/playback.py b/mopidy/frontends/mpd/protocol/playback.py index 7abc4509..4c602f3b 100644 --- a/mopidy/frontends/mpd/protocol/playback.py +++ b/mopidy/frontends/mpd/protocol/playback.py @@ -300,7 +300,7 @@ def seek(frontend, songpos, seconds): """ if frontend.backend.playback.current_playlist_position != songpos: playpos(frontend, songpos) - return frontend.backend.playback.seek(int(seconds) * 1000) + frontend.backend.playback.seek(int(seconds) * 1000) @handle_pattern(r'^seekid "(?P\d+)" "(?P\d+)"$') def seekid(frontend, cpid, seconds): @@ -313,7 +313,7 @@ def seekid(frontend, cpid, seconds): """ if frontend.backend.playback.current_cpid != cpid: playid(frontend, cpid) - return frontend.backend.playback.seek(int(seconds) * 1000) + frontend.backend.playback.seek(int(seconds) * 1000) @handle_pattern(r'^setvol "(?P[-+]*\d+)"$') def setvol(frontend, volume): From ce878ca79785f8edceb1429fa0a64a3addd9b622 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 20 Aug 2010 00:51:01 +0200 Subject: [PATCH 38/80] Turn on last modified time stamps in Sphinx as the docs now are only built conditionally --- docs/conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/conf.py b/docs/conf.py index c95c39df..d0d8f3af 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -130,7 +130,7 @@ html_static_path = ['_static'] # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. -#html_last_updated_fmt = '%b %d, %Y' +html_last_updated_fmt = '%b %d, %Y' # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. From 7f25b0b5149339dac8cf688831fe6f4093a14fab Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Fri, 20 Aug 2010 00:56:28 +0200 Subject: [PATCH 39/80] Introduce GSTREAMER_AUDIO_SINK setting to control which sink we use --- mopidy/outputs/gstreamer.py | 2 +- mopidy/settings.py | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/mopidy/outputs/gstreamer.py b/mopidy/outputs/gstreamer.py index de5b7734..8f321976 100644 --- a/mopidy/outputs/gstreamer.py +++ b/mopidy/outputs/gstreamer.py @@ -66,7 +66,7 @@ class GStreamerProcess(BaseProcess): self.gst_pipeline = gst.parse_launch(' ! '.join([ 'audioconvert name=convert', 'volume name=volume', - 'autoaudiosink' + settings.GSTREAMER_AUDIO_SINK, ])) pad = self.gst_pipeline.get_by_name('convert').get_pad('sink') diff --git a/mopidy/settings.py b/mopidy/settings.py index c9e3606e..3198ed93 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -167,3 +167,8 @@ SPOTIFY_USERNAME = u'' #: #: Used by :mod:`mopidy.backends.libspotify`. SPOTIFY_PASSWORD = u'' + +#: Which GStreamer audio sink to use in output pipeline. +#: +#: Default: autoaudiosink +GSTREAMER_AUDIO_SINK = u'autoaudiosink' From aede9762f8d442d502552d389bb46a3acb3d2272 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 20 Aug 2010 01:07:23 +0200 Subject: [PATCH 40/80] Split BaseProcess snd CoreProcess into two files --- mopidy/__main__.py | 2 +- mopidy/backends/libspotify/playback.py | 2 +- mopidy/{process.py => core.py} | 37 ++----------------------- mopidy/frontends/mpd/session.py | 2 +- mopidy/mixers/gstreamer_software.py | 2 +- mopidy/mixers/nad.py | 2 +- mopidy/outputs/gstreamer.py | 2 +- mopidy/utils/process.py | 38 ++++++++++++++++++++++++++ 8 files changed, 46 insertions(+), 41 deletions(-) rename mopidy/{process.py => core.py} (63%) create mode 100644 mopidy/utils/process.py diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 1c0318e7..ac090f16 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -10,7 +10,7 @@ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../'))) from mopidy import get_version, settings, SettingsError -from mopidy.process import CoreProcess +from mopidy.core import CoreProcess from mopidy.utils import get_class from mopidy.utils.path import get_or_create_folder from mopidy.utils.settings import list_settings_optparse_callback diff --git a/mopidy/backends/libspotify/playback.py b/mopidy/backends/libspotify/playback.py index 1195e9bc..ed5ba697 100644 --- a/mopidy/backends/libspotify/playback.py +++ b/mopidy/backends/libspotify/playback.py @@ -4,7 +4,7 @@ import multiprocessing from spotify import Link, SpotifyError from mopidy.backends.base import BasePlaybackController -from mopidy.process import pickle_connection +from mopidy.utils.process import pickle_connection logger = logging.getLogger('mopidy.backends.libspotify.playback') diff --git a/mopidy/process.py b/mopidy/core.py similarity index 63% rename from mopidy/process.py rename to mopidy/core.py index 008129b3..06e87294 100644 --- a/mopidy/process.py +++ b/mopidy/core.py @@ -1,42 +1,9 @@ import logging import multiprocessing -from multiprocessing.reduction import reduce_connection -import pickle -import sys -from mopidy import SettingsError - -logger = logging.getLogger('mopidy.process') - -def pickle_connection(connection): - return pickle.dumps(reduce_connection(connection)) - -def unpickle_connection(pickled_connection): - # From http://stackoverflow.com/questions/1446004 - (func, args) = pickle.loads(pickled_connection) - return func(*args) - - -class BaseProcess(multiprocessing.Process): - def run(self): - try: - self.run_inside_try() - except KeyboardInterrupt: - logger.info(u'Interrupted by user') - sys.exit(0) - except SettingsError as e: - logger.error(e.message) - sys.exit(1) - except ImportError as e: - logger.error(e) - sys.exit(1) - except Exception as e: - logger.exception(e) - raise e - - def run_inside_try(self): - raise NotImplementedError +from mopidy.utils.process import BaseProcess, unpickle_connection +logger = logging.getLogger('mopidy.core') class CoreProcess(BaseProcess): def __init__(self, core_queue, output_class, backend_class, frontend): diff --git a/mopidy/frontends/mpd/session.py b/mopidy/frontends/mpd/session.py index e5d2d12d..a9eba73e 100644 --- a/mopidy/frontends/mpd/session.py +++ b/mopidy/frontends/mpd/session.py @@ -3,8 +3,8 @@ import logging import multiprocessing from mopidy.frontends.mpd.protocol import ENCODING, LINE_TERMINATOR, VERSION -from mopidy.process import pickle_connection from mopidy.utils import indent +from mopidy.utils.process import pickle_connection logger = logging.getLogger('mopidy.frontends.mpd.session') diff --git a/mopidy/mixers/gstreamer_software.py b/mopidy/mixers/gstreamer_software.py index 2910ef72..1225cafd 100644 --- a/mopidy/mixers/gstreamer_software.py +++ b/mopidy/mixers/gstreamer_software.py @@ -1,7 +1,7 @@ import multiprocessing from mopidy.mixers import BaseMixer -from mopidy.process import pickle_connection +from mopidy.utils.process import pickle_connection class GStreamerSoftwareMixer(BaseMixer): """Mixer which uses GStreamer to control volume in software.""" diff --git a/mopidy/mixers/nad.py b/mopidy/mixers/nad.py index d78863aa..b8a4f41a 100644 --- a/mopidy/mixers/nad.py +++ b/mopidy/mixers/nad.py @@ -3,9 +3,9 @@ from serial import Serial from multiprocessing import Pipe from mopidy.mixers import BaseMixer -from mopidy.process import BaseProcess from mopidy.settings import (MIXER_EXT_PORT, MIXER_EXT_SOURCE, MIXER_EXT_SPEAKERS_A, MIXER_EXT_SPEAKERS_B) +from mopidy.utils.process import BaseProcess logger = logging.getLogger('mopidy.mixers.nad') diff --git a/mopidy/outputs/gstreamer.py b/mopidy/outputs/gstreamer.py index ca5a98c5..815edbad 100644 --- a/mopidy/outputs/gstreamer.py +++ b/mopidy/outputs/gstreamer.py @@ -8,7 +8,7 @@ import gst import logging import threading -from mopidy.process import BaseProcess, unpickle_connection +from mopidy.utils.process import BaseProcess, unpickle_connection logger = logging.getLogger('mopidy.outputs.gstreamer') diff --git a/mopidy/utils/process.py b/mopidy/utils/process.py new file mode 100644 index 00000000..5e783b73 --- /dev/null +++ b/mopidy/utils/process.py @@ -0,0 +1,38 @@ +import logging +import multiprocessing +from multiprocessing.reduction import reduce_connection +import pickle +import sys + +from mopidy import SettingsError + +logger = logging.getLogger('mopidy.utils.process') + +def pickle_connection(connection): + return pickle.dumps(reduce_connection(connection)) + +def unpickle_connection(pickled_connection): + # From http://stackoverflow.com/questions/1446004 + (func, args) = pickle.loads(pickled_connection) + return func(*args) + + +class BaseProcess(multiprocessing.Process): + def run(self): + try: + self.run_inside_try() + except KeyboardInterrupt: + logger.info(u'Interrupted by user') + sys.exit(0) + except SettingsError as e: + logger.error(e.message) + sys.exit(1) + except ImportError as e: + logger.error(e) + sys.exit(1) + except Exception as e: + logger.exception(e) + raise e + + def run_inside_try(self): + raise NotImplementedError From d43ddab5908a543236a05860fb15658ec154aa5b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 20 Aug 2010 01:24:33 +0200 Subject: [PATCH 41/80] Fix import in test --- tests/outputs/gstreamer_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/outputs/gstreamer_test.py b/tests/outputs/gstreamer_test.py index c063aaee..5f681f23 100644 --- a/tests/outputs/gstreamer_test.py +++ b/tests/outputs/gstreamer_test.py @@ -2,8 +2,8 @@ import multiprocessing import unittest from mopidy.outputs.gstreamer import GStreamerOutput -from mopidy.process import pickle_connection from mopidy.utils.path import path_to_uri +from mopidy.utils.process import pickle_connection from tests import data_folder, SkipTest From 88a4d64a5946250e2093e8371081384062e1b4bd Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 20 Aug 2010 01:24:56 +0200 Subject: [PATCH 42/80] Move MPD server into its own process --- docs/changes.rst | 5 +++-- mopidy/__main__.py | 3 ++- mopidy/frontends/mpd/__init__.py | 7 +++---- mopidy/frontends/mpd/process.py | 18 ++++++++++++++++++ 4 files changed, 26 insertions(+), 7 deletions(-) create mode 100644 mopidy/frontends/mpd/process.py diff --git a/docs/changes.rst b/docs/changes.rst index 7c2ce19d..4fdc8c1f 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -39,8 +39,6 @@ greatly improved MPD client support. the packages created by ``setup.py`` for i.e. PyPI. - MPD frontend: - - Relocate from :mod:`mopidy.mpd` to :mod:`mopidy.frontends.mpd`. - - Split gigantic protocol implementation into eleven modules. - Search improvements, including support for multi-word search. - Fixed ``play "-1"`` and ``playid "-1"`` behaviour when playlist is empty or when a current track is set. @@ -56,11 +54,14 @@ greatly improved MPD client support. - Fix ``load`` so that one can append a playlist to the current playlist, and make it return the correct error message if the playlist is not found. - Support for single track repeat added. (Fixes: :issue:`4`) + - Relocate from :mod:`mopidy.mpd` to :mod:`mopidy.frontends.mpd`. + - Split gigantic protocol implementation into eleven modules. - Rename ``mopidy.frontends.mpd.{serializer => translator}`` to match naming in backends. - Remove setting :attr:`mopidy.settings.SERVER` and :attr:`mopidy.settings.FRONTEND` in favour of the new :attr:`mopidy.settings.FRONTENDS`. + - Run MPD server in its own process. - Backends: diff --git a/mopidy/__main__.py b/mopidy/__main__.py index ac090f16..247c68b3 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -30,7 +30,8 @@ def main(): frontend.start_server(core_queue) core = CoreProcess(core_queue, output_class, backend_class, frontend) core.start() - asyncore.loop() + #asyncore.loop() + logger.debug('Main done') def _parse_options(): parser = optparse.OptionParser(version='Mopidy %s' % get_version()) diff --git a/mopidy/frontends/mpd/__init__.py b/mopidy/frontends/mpd/__init__.py index 53f2003f..048f5748 100644 --- a/mopidy/frontends/mpd/__init__.py +++ b/mopidy/frontends/mpd/__init__.py @@ -1,5 +1,5 @@ from mopidy.frontends.mpd.dispatcher import MpdDispatcher -from mopidy.frontends.mpd.server import MpdServer +from mopidy.frontends.mpd.process import MpdProcess class MpdFrontend(object): """ @@ -17,8 +17,8 @@ class MpdFrontend(object): :param core_queue: the core queue :type core_queue: :class:`multiprocessing.Queue` """ - self.server = MpdServer(core_queue) - self.server.start() + self.process = MpdProcess(core_queue) + self.process.start() def create_dispatcher(self, backend): """ @@ -28,6 +28,5 @@ class MpdFrontend(object): :type backend: :class:`mopidy.backends.base.BaseBackend` :rtype: :class:`mopidy.frontends.mpd.dispatcher.MpdDispatcher` """ - self.dispatcher = MpdDispatcher(backend) return self.dispatcher diff --git a/mopidy/frontends/mpd/process.py b/mopidy/frontends/mpd/process.py new file mode 100644 index 00000000..95ae855f --- /dev/null +++ b/mopidy/frontends/mpd/process.py @@ -0,0 +1,18 @@ +import asyncore +import logging + +from mopidy.frontends.mpd.server import MpdServer +from mopidy.utils.process import BaseProcess + +logger = logging.getLogger('mopidy.frontends.mpd.process') + +class MpdProcess(BaseProcess): + def __init__(self, core_queue): + super(MpdProcess, self).__init__() + self.core_queue = core_queue + + def run_inside_try(self): + logger.debug(u'Starting MPD server process') + server = MpdServer(self.core_queue) + server.start() + asyncore.loop() From 560b8be86f300789f140b34317e04228606b916c Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 20 Aug 2010 01:31:56 +0200 Subject: [PATCH 43/80] Move log setup to mopidy.utils.log --- mopidy/__main__.py | 29 ++--------------------------- mopidy/utils/log.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 27 deletions(-) create mode 100644 mopidy/utils/log.py diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 247c68b3..fb22ddde 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -1,6 +1,4 @@ -import asyncore import logging -import logging.handlers import multiprocessing import optparse import os @@ -12,6 +10,7 @@ sys.path.insert(0, from mopidy import get_version, settings, SettingsError from mopidy.core import CoreProcess from mopidy.utils import get_class +from mopidy.utils.log import setup_logging from mopidy.utils.path import get_or_create_folder from mopidy.utils.settings import list_settings_optparse_callback @@ -19,7 +18,7 @@ logger = logging.getLogger('mopidy.main') def main(): options = _parse_options() - _setup_logging(options.verbosity_level, options.dump) + setup_logging(options.verbosity_level, options.dump) settings.validate() logger.info('-- Starting Mopidy --') get_or_create_folder('~/.mopidy/') @@ -30,7 +29,6 @@ def main(): frontend.start_server(core_queue) core = CoreProcess(core_queue, output_class, backend_class, frontend) core.start() - #asyncore.loop() logger.debug('Main done') def _parse_options(): @@ -49,29 +47,6 @@ def _parse_options(): help='list current settings') return parser.parse_args()[0] -def _setup_logging(verbosity_level, dump): - _setup_console_logging(verbosity_level) - if dump: - _setup_dump_logging() - -def _setup_console_logging(verbosity_level): - if verbosity_level == 0: - level = logging.WARNING - elif verbosity_level == 2: - level = logging.DEBUG - else: - level = logging.INFO - logging.basicConfig(format=settings.CONSOLE_LOG_FORMAT, level=level) - -def _setup_dump_logging(): - root = logging.getLogger('') - root.setLevel(logging.DEBUG) - formatter = logging.Formatter(settings.DUMP_LOG_FORMAT) - handler = logging.handlers.RotatingFileHandler( - settings.DUMP_LOG_FILENAME, maxBytes=102400, backupCount=3) - handler.setFormatter(formatter) - root.addHandler(handler) - if __name__ == '__main__': try: main() diff --git a/mopidy/utils/log.py b/mopidy/utils/log.py new file mode 100644 index 00000000..e75a6df6 --- /dev/null +++ b/mopidy/utils/log.py @@ -0,0 +1,28 @@ +import logging +import logging.handlers + +from mopidy import settings + +def setup_logging(verbosity_level, dump): + setup_console_logging(verbosity_level) + if dump: + setup_dump_logging() + +def setup_console_logging(verbosity_level): + if verbosity_level == 0: + level = logging.WARNING + elif verbosity_level == 2: + level = logging.DEBUG + else: + level = logging.INFO + logging.basicConfig(format=settings.CONSOLE_LOG_FORMAT, level=level) + +def setup_dump_logging(): + root = logging.getLogger('') + root.setLevel(logging.DEBUG) + formatter = logging.Formatter(settings.DUMP_LOG_FORMAT) + handler = logging.handlers.RotatingFileHandler( + settings.DUMP_LOG_FILENAME, maxBytes=102400, backupCount=3) + handler.setFormatter(formatter) + root.addHandler(handler) + From f73800f8e4ccd76d858c08d8cc8a72a6f2274fb6 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 20 Aug 2010 01:41:54 +0200 Subject: [PATCH 44/80] Validate settings a tad later --- mopidy/__main__.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index fb22ddde..a0a91fca 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -17,11 +17,14 @@ from mopidy.utils.settings import list_settings_optparse_callback logger = logging.getLogger('mopidy.main') def main(): - options = _parse_options() + options = parse_options() setup_logging(options.verbosity_level, options.dump) - settings.validate() + logger.info('-- Starting Mopidy --') + get_or_create_folder('~/.mopidy/') + settings.validate() + core_queue = multiprocessing.Queue() output_class = get_class(settings.OUTPUT) backend_class = get_class(settings.BACKENDS[0]) @@ -29,9 +32,10 @@ def main(): frontend.start_server(core_queue) core = CoreProcess(core_queue, output_class, backend_class, frontend) core.start() + logger.debug('Main done') -def _parse_options(): +def parse_options(): parser = optparse.OptionParser(version='Mopidy %s' % get_version()) parser.add_option('-q', '--quiet', action='store_const', const=0, dest='verbosity_level', From 60bca18b467ab5e97217c39223d835365c78188a Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 20 Aug 2010 01:52:20 +0200 Subject: [PATCH 45/80] Run CoreProcess in the main process --- mopidy/__main__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index a0a91fca..e56b82ab 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -31,9 +31,9 @@ def main(): frontend = get_class(settings.FRONTENDS[0])() frontend.start_server(core_queue) core = CoreProcess(core_queue, output_class, backend_class, frontend) - core.start() - logger.debug('Main done') + # Explictly call run instead of start, so it runs in this process + core.run() def parse_options(): parser = optparse.OptionParser(version='Mopidy %s' % get_version()) From ea699eb121c5c1dad32fd4246355e2d2ee1de9aa Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 20 Aug 2010 01:59:14 +0200 Subject: [PATCH 46/80] Name all processes --- mopidy/core.py | 2 +- mopidy/frontends/mpd/process.py | 2 +- mopidy/mixers/nad.py | 2 +- mopidy/outputs/gstreamer.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/mopidy/core.py b/mopidy/core.py index 06e87294..615fde26 100644 --- a/mopidy/core.py +++ b/mopidy/core.py @@ -7,7 +7,7 @@ logger = logging.getLogger('mopidy.core') class CoreProcess(BaseProcess): def __init__(self, core_queue, output_class, backend_class, frontend): - super(CoreProcess, self).__init__() + super(CoreProcess, self).__init__(name='CoreProcess') self.core_queue = core_queue self.output_queue = None self.output_class = output_class diff --git a/mopidy/frontends/mpd/process.py b/mopidy/frontends/mpd/process.py index 95ae855f..7bd95900 100644 --- a/mopidy/frontends/mpd/process.py +++ b/mopidy/frontends/mpd/process.py @@ -8,7 +8,7 @@ logger = logging.getLogger('mopidy.frontends.mpd.process') class MpdProcess(BaseProcess): def __init__(self, core_queue): - super(MpdProcess, self).__init__() + super(MpdProcess, self).__init__(name='MpdProcess') self.core_queue = core_queue def run_inside_try(self): diff --git a/mopidy/mixers/nad.py b/mopidy/mixers/nad.py index b8a4f41a..f859791b 100644 --- a/mopidy/mixers/nad.py +++ b/mopidy/mixers/nad.py @@ -74,7 +74,7 @@ class NadTalker(BaseProcess): _nad_volume = None def __init__(self, pipe=None): - super(NadTalker, self).__init__() + super(NadTalker, self).__init__(name='NadTalker') self.pipe = pipe self._device = None diff --git a/mopidy/outputs/gstreamer.py b/mopidy/outputs/gstreamer.py index 815edbad..a1544f87 100644 --- a/mopidy/outputs/gstreamer.py +++ b/mopidy/outputs/gstreamer.py @@ -49,7 +49,7 @@ class GStreamerProcess(BaseProcess): ]) def __init__(self, core_queue, output_queue): - super(GStreamerProcess, self).__init__() + super(GStreamerProcess, self).__init__(name='GStreamerProcess') self.core_queue = core_queue self.output_queue = output_queue self.gst_pipeline = None From 51b5910e685874e42b32a4399878774fb3afb6bb Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 20 Aug 2010 01:59:27 +0200 Subject: [PATCH 47/80] Add process name to BaseProcess logging --- mopidy/utils/process.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mopidy/utils/process.py b/mopidy/utils/process.py index 5e783b73..73224840 100644 --- a/mopidy/utils/process.py +++ b/mopidy/utils/process.py @@ -19,10 +19,11 @@ def unpickle_connection(pickled_connection): class BaseProcess(multiprocessing.Process): def run(self): + logger.debug(u'%s: Starting process', self.name) try: self.run_inside_try() except KeyboardInterrupt: - logger.info(u'Interrupted by user') + logger.info(u'%s: Interrupted by user', self.name) sys.exit(0) except SettingsError as e: logger.error(e.message) From a67aaef8c73ee3a7c4c0ea36b344ea952576d3a7 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 20 Aug 2010 03:08:59 +0200 Subject: [PATCH 48/80] Update class instantiation/usage graph --- docs/development/internals.rst | 99 ++++++++++++++++++++++++++-------- 1 file changed, 76 insertions(+), 23 deletions(-) diff --git a/docs/development/internals.rst b/docs/development/internals.rst index 085b55ac..73dc59e6 100644 --- a/docs/development/internals.rst +++ b/docs/development/internals.rst @@ -12,40 +12,93 @@ 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 with the despotify backend and ALSA -mixer is wired together. The gray nodes are part of external dependencies, and -not Mopidy. The red nodes lives in the ``main`` process (running an -:mod:`asyncore` loop), while the blue nodes lives in a secondary process named -``core`` (running a service loop in :class:`mopidy.core.CoreProcess`). +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 processing messages 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 - "spytify" [ color="gray" ] - "despotify" [ color="gray" ] - "alsaaudio" [ color="gray" ] - "__main__" [ color="red" ] + "main" [ color="blue" ] "CoreProcess" [ color="blue" ] - "DespotifyBackend" [ color="blue" ] - "AlsaMixer" [ color="blue" ] + + # Frontend + "MPD client" [ color="red", style="filled", shape="box" ] "MpdFrontend" [ color="blue" ] - "MpdServer" [ color="red" ] - "MpdSession" [ color="red" ] - "__main__" -> "CoreProcess" [ label="create" ] - "__main__" -> "MpdServer" [ label="create" ] - "CoreProcess" -> "DespotifyBackend" [ label="create" ] + "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" ] - "MpdServer" -> "MpdSession" [ label="create one per client" ] - "MpdSession" -> "MpdFrontend" [ label="pass MPD requests to" ] - "MpdFrontend" -> "DespotifyBackend" [ label="use backend API" ] - "DespotifyBackend" -> "AlsaMixer" [ label="create and use mixer API" ] - "DespotifyBackend" -> "spytify" [ label="use Python wrapper" ] - "spytify" -> "despotify" [ label="use C library" ] - "AlsaMixer" -> "alsaaudio" [ label="use Python library" ] + "MpdFrontend" -> "MpdProcess" [ 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. From 7f95a3b0ba58bfb919636169a991eb17e1668b7f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 20 Aug 2010 03:27:20 +0200 Subject: [PATCH 49/80] Move initialization from main to core --- mopidy/__main__.py | 11 +---------- mopidy/core.py | 29 ++++++++++++++++++++--------- 2 files changed, 21 insertions(+), 19 deletions(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index e56b82ab..86a4d5c4 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -1,5 +1,4 @@ import logging -import multiprocessing import optparse import os import sys @@ -9,7 +8,6 @@ sys.path.insert(0, from mopidy import get_version, settings, SettingsError from mopidy.core import CoreProcess -from mopidy.utils import get_class from mopidy.utils.log import setup_logging from mopidy.utils.path import get_or_create_folder from mopidy.utils.settings import list_settings_optparse_callback @@ -25,15 +23,8 @@ def main(): get_or_create_folder('~/.mopidy/') settings.validate() - core_queue = multiprocessing.Queue() - output_class = get_class(settings.OUTPUT) - backend_class = get_class(settings.BACKENDS[0]) - frontend = get_class(settings.FRONTENDS[0])() - frontend.start_server(core_queue) - core = CoreProcess(core_queue, output_class, backend_class, frontend) - # Explictly call run instead of start, so it runs in this process - core.run() + CoreProcess(options).run() def parse_options(): parser = optparse.OptionParser(version='Mopidy %s' % get_version()) diff --git a/mopidy/core.py b/mopidy/core.py index 615fde26..0d60b602 100644 --- a/mopidy/core.py +++ b/mopidy/core.py @@ -1,20 +1,19 @@ import logging import multiprocessing +from mopidy import settings +from mopidy.utils import get_class from mopidy.utils.process import BaseProcess, unpickle_connection logger = logging.getLogger('mopidy.core') class CoreProcess(BaseProcess): - def __init__(self, core_queue, output_class, backend_class, frontend): + def __init__(self, options): super(CoreProcess, self).__init__(name='CoreProcess') - self.core_queue = core_queue + self.options = options + self.core_queue = multiprocessing.Queue() self.output_queue = None - self.output_class = output_class - self.backend_class = backend_class - self.output = None self.backend = None - self.frontend = frontend self.dispatcher = None def run_inside_try(self): @@ -24,10 +23,22 @@ class CoreProcess(BaseProcess): self.process_message(message) def setup(self): + self.setup_output() + self.setup_backend() + self.setup_frontend() + + def setup_output(self): self.output_queue = multiprocessing.Queue() - self.output = self.output_class(self.core_queue, self.output_queue) - self.backend = self.backend_class(self.core_queue, self.output_queue) - self.dispatcher = self.frontend.create_dispatcher(self.backend) + get_class(settings.OUTPUT)(self.core_queue, self.output_queue) + + def setup_backend(self): + self.backend = get_class(settings.BACKENDS[0])( + self.core_queue, self.output_queue) + + def setup_frontend(self): + frontend = get_class(settings.FRONTENDS[0])() + frontend.start_server(self.core_queue) + self.dispatcher = frontend.create_dispatcher(self.backend) def process_message(self, message): if message.get('to') == 'output': From 681ac05561108a6c7c9cb63ed4bc5d6b8ef4d4ce Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 20 Aug 2010 10:19:32 +0200 Subject: [PATCH 50/80] Graph: MpdFrontend creates MpdDispatcher --- docs/development/internals.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/development/internals.rst b/docs/development/internals.rst index 73dc59e6..0af13aa8 100644 --- a/docs/development/internals.rst +++ b/docs/development/internals.rst @@ -58,6 +58,7 @@ the Spotify service, and the speakers. # 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" [ From aba0cc3ef36fe191bd53bf8695ffe09ef3269ea4 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 20 Aug 2010 12:34:13 +0200 Subject: [PATCH 51/80] Remove redundant error handling, as all these cases are handled in BaseProcess --- mopidy/__main__.py | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 86a4d5c4..02bdb4e5 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -43,14 +43,4 @@ def parse_options(): return parser.parse_args()[0] if __name__ == '__main__': - try: - main() - except KeyboardInterrupt: - logger.info(u'Interrupted by user') - sys.exit(0) - except SettingsError, e: - logger.error(e) - sys.exit(1) - except SystemExit, e: - logger.error(e) - sys.exit(1) + main() From 461830924a25641ac54834b582217391f9b2fef2 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 20 Aug 2010 12:34:41 +0200 Subject: [PATCH 52/80] libspotify thread should not stop Mopidy from exiting, and is thus a daemon thread --- mopidy/backends/libspotify/session_manager.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/mopidy/backends/libspotify/session_manager.py b/mopidy/backends/libspotify/session_manager.py index 707423aa..9ea8386b 100644 --- a/mopidy/backends/libspotify/session_manager.py +++ b/mopidy/backends/libspotify/session_manager.py @@ -19,6 +19,9 @@ class LibspotifySessionManager(SpotifySessionManager, threading.Thread): def __init__(self, username, password, core_queue, output_queue): SpotifySessionManager.__init__(self, username, password) threading.Thread.__init__(self) + # Run as a daemon thread, so Mopidy won't wait for this thread to exit + # before Mopidy exits. + self.daemon = True self.core_queue = core_queue self.output_queue = output_queue self.connected = threading.Event() From 5ea3e40594ee0b222ed131d1340f743912ef75c5 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 20 Aug 2010 12:38:33 +0200 Subject: [PATCH 53/80] Rename Thread-1 => LibspotifySessionManagerThread --- mopidy/backends/libspotify/session_manager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/backends/libspotify/session_manager.py b/mopidy/backends/libspotify/session_manager.py index 9ea8386b..22cbb0a0 100644 --- a/mopidy/backends/libspotify/session_manager.py +++ b/mopidy/backends/libspotify/session_manager.py @@ -18,7 +18,7 @@ class LibspotifySessionManager(SpotifySessionManager, threading.Thread): def __init__(self, username, password, core_queue, output_queue): SpotifySessionManager.__init__(self, username, password) - threading.Thread.__init__(self) + threading.Thread.__init__(self, name='LibspotifySessionManagerThread') # Run as a daemon thread, so Mopidy won't wait for this thread to exit # before Mopidy exits. self.daemon = True From 79729e653b838740ff543fa3de667e4078cf6fc0 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 20 Aug 2010 12:54:18 +0200 Subject: [PATCH 54/80] Make CoreProcess.setup() more functional to show dependencies explicitly --- mopidy/__main__.py | 2 +- mopidy/core.py | 32 ++++++++++++++++---------------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 02bdb4e5..044e6ec6 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -24,7 +24,7 @@ def main(): settings.validate() # Explictly call run instead of start, so it runs in this process - CoreProcess(options).run() + CoreProcess().run() def parse_options(): parser = optparse.OptionParser(version='Mopidy %s' % get_version()) diff --git a/mopidy/core.py b/mopidy/core.py index 0d60b602..b45a9b96 100644 --- a/mopidy/core.py +++ b/mopidy/core.py @@ -8,13 +8,12 @@ from mopidy.utils.process import BaseProcess, unpickle_connection logger = logging.getLogger('mopidy.core') class CoreProcess(BaseProcess): - def __init__(self, options): + def __init__(self): super(CoreProcess, self).__init__(name='CoreProcess') - self.options = options self.core_queue = multiprocessing.Queue() self.output_queue = None self.backend = None - self.dispatcher = None + self.frontend = None def run_inside_try(self): self.setup() @@ -23,28 +22,29 @@ class CoreProcess(BaseProcess): self.process_message(message) def setup(self): - self.setup_output() - self.setup_backend() - self.setup_frontend() + self.output_queue = self.setup_output(self.core_queue) + self.backend = self.setup_backend(self.core_queue, self.output_queue) + self.frontend = self.setup_frontend(self.core_queue, self.backend) - def setup_output(self): - self.output_queue = multiprocessing.Queue() - get_class(settings.OUTPUT)(self.core_queue, self.output_queue) + def setup_output(self, core_queue): + output_queue = multiprocessing.Queue() + get_class(settings.OUTPUT)(core_queue, output_queue) + return output_queue - def setup_backend(self): - self.backend = get_class(settings.BACKENDS[0])( - self.core_queue, self.output_queue) + def setup_backend(self, core_queue, output_queue): + return get_class(settings.BACKENDS[0])(core_queue, output_queue) - def setup_frontend(self): + def setup_frontend(self, core_queue, backend): frontend = get_class(settings.FRONTENDS[0])() - frontend.start_server(self.core_queue) - self.dispatcher = frontend.create_dispatcher(self.backend) + frontend.start_server(core_queue) + frontend.create_dispatcher(backend) + return frontend def process_message(self, message): if message.get('to') == 'output': self.output_queue.put(message) elif message['command'] == 'mpd_request': - response = self.dispatcher.handle_request(message['request']) + response = self.frontend.dispatcher.handle_request(message['request']) connection = unpickle_connection(message['reply_to']) connection.send(response) elif message['command'] == 'end_of_track': From 976086ae65d2948c9b91e0bd6b7a3724872ffb3f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 20 Aug 2010 16:17:22 +0200 Subject: [PATCH 55/80] Move options parsing, logging setup and settings validation into CoreProcess --- mopidy/__main__.py | 42 +++++------------------------------------- mopidy/core.py | 33 ++++++++++++++++++++++++++++++++- 2 files changed, 37 insertions(+), 38 deletions(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 044e6ec6..8aee976b 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -1,46 +1,14 @@ -import logging -import optparse import os import sys +# Add ../ to the path so we can run Mopidy from a Git checkout without +# installing it on the system. sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../'))) -from mopidy import get_version, settings, SettingsError from mopidy.core import CoreProcess -from mopidy.utils.log import setup_logging -from mopidy.utils.path import get_or_create_folder -from mopidy.utils.settings import list_settings_optparse_callback - -logger = logging.getLogger('mopidy.main') - -def main(): - options = parse_options() - setup_logging(options.verbosity_level, options.dump) - - logger.info('-- Starting Mopidy --') - - get_or_create_folder('~/.mopidy/') - settings.validate() - - # Explictly call run instead of start, so it runs in this process - CoreProcess().run() - -def parse_options(): - parser = optparse.OptionParser(version='Mopidy %s' % get_version()) - parser.add_option('-q', '--quiet', - action='store_const', const=0, dest='verbosity_level', - help='less output (warning level)') - parser.add_option('-v', '--verbose', - action='store_const', const=2, dest='verbosity_level', - help='more output (debug level)') - parser.add_option('--dump', - action='store_true', dest='dump', - help='dump debug log to file') - parser.add_option('--list-settings', - action='callback', callback=list_settings_optparse_callback, - help='list current settings') - return parser.parse_args()[0] if __name__ == '__main__': - main() + # Explictly call run() instead of start(), since we don't need to start + # another process. + CoreProcess().run() diff --git a/mopidy/core.py b/mopidy/core.py index b45a9b96..d823bfa5 100644 --- a/mopidy/core.py +++ b/mopidy/core.py @@ -1,9 +1,13 @@ import logging import multiprocessing +import optparse -from mopidy import settings +from mopidy import get_version, settings from mopidy.utils import get_class +from mopidy.utils.log import setup_logging +from mopidy.utils.path import get_or_create_folder from mopidy.utils.process import BaseProcess, unpickle_connection +from mopidy.utils.settings import list_settings_optparse_callback logger = logging.getLogger('mopidy.core') @@ -11,21 +15,48 @@ class CoreProcess(BaseProcess): def __init__(self): super(CoreProcess, self).__init__(name='CoreProcess') self.core_queue = multiprocessing.Queue() + self.options = self.parse_options() self.output_queue = None self.backend = None self.frontend = None + def parse_options(self): + parser = optparse.OptionParser(version='Mopidy %s' % get_version()) + parser.add_option('-q', '--quiet', + action='store_const', const=0, dest='verbosity_level', + help='less output (warning level)') + parser.add_option('-v', '--verbose', + action='store_const', const=2, dest='verbosity_level', + help='more output (debug level)') + parser.add_option('--dump', + action='store_true', dest='dump', + help='dump debug log to file') + parser.add_option('--list-settings', + action='callback', callback=list_settings_optparse_callback, + help='list current settings') + return parser.parse_args()[0] + def run_inside_try(self): + logger.info(u'-- Starting Mopidy --') self.setup() while True: message = self.core_queue.get() self.process_message(message) def setup(self): + self.setup_logging() + self.setup_settings() self.output_queue = self.setup_output(self.core_queue) self.backend = self.setup_backend(self.core_queue, self.output_queue) self.frontend = self.setup_frontend(self.core_queue, self.backend) + def setup_logging(self): + setup_logging(self.options.verbosity_level, self.options.dump) + + def setup_settings(self): + get_or_create_folder('~/.mopidy/') + settings.validate() + def setup_output(self, core_queue): output_queue = multiprocessing.Queue() get_class(settings.OUTPUT)(core_queue, output_queue) From 19c4f1c09f4273661bec639cba80a090e368d7e4 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 20 Aug 2010 16:21:07 +0200 Subject: [PATCH 56/80] Readd main() method, to not break bin/mopidy --- mopidy/__main__.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 8aee976b..20e78f5a 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -8,7 +8,10 @@ sys.path.insert(0, from mopidy.core import CoreProcess -if __name__ == '__main__': +def main(): # Explictly call run() instead of start(), since we don't need to start # another process. CoreProcess().run() + +if __name__ == '__main__': + main() From 1ae172d0c1608bd91ad27dfd06456f4568122cbc Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 20 Aug 2010 16:38:59 +0200 Subject: [PATCH 57/80] Move mopidy.utils.{ => log}.indent --- mopidy/frontends/mpd/session.py | 2 +- mopidy/utils/__init__.py | 9 --------- mopidy/utils/log.py | 8 ++++++++ mopidy/utils/settings.py | 2 +- 4 files changed, 10 insertions(+), 11 deletions(-) diff --git a/mopidy/frontends/mpd/session.py b/mopidy/frontends/mpd/session.py index a9eba73e..72a1f845 100644 --- a/mopidy/frontends/mpd/session.py +++ b/mopidy/frontends/mpd/session.py @@ -3,7 +3,7 @@ import logging import multiprocessing from mopidy.frontends.mpd.protocol import ENCODING, LINE_TERMINATOR, VERSION -from mopidy.utils import indent +from mopidy.utils.log import indent from mopidy.utils.process import pickle_connection logger = logging.getLogger('mopidy.frontends.mpd.session') diff --git a/mopidy/utils/__init__.py b/mopidy/utils/__init__.py index 277d2f3b..acbb4664 100644 --- a/mopidy/utils/__init__.py +++ b/mopidy/utils/__init__.py @@ -27,12 +27,3 @@ def get_class(name): except (ImportError, AttributeError): raise ImportError("Couldn't load: %s" % name) return class_object - -def indent(string, places=4, linebreak='\n'): - lines = string.split(linebreak) - if len(lines) == 1: - return string - result = u'' - for line in lines: - result += linebreak + ' ' * places + line - return result diff --git a/mopidy/utils/log.py b/mopidy/utils/log.py index e75a6df6..c892102a 100644 --- a/mopidy/utils/log.py +++ b/mopidy/utils/log.py @@ -26,3 +26,11 @@ def setup_dump_logging(): handler.setFormatter(formatter) root.addHandler(handler) +def indent(string, places=4, linebreak='\n'): + lines = string.split(linebreak) + if len(lines) == 1: + return string + result = u'' + for line in lines: + result += linebreak + ' ' * places + line + return result diff --git a/mopidy/utils/settings.py b/mopidy/utils/settings.py index f1d213de..e45c5521 100644 --- a/mopidy/utils/settings.py +++ b/mopidy/utils/settings.py @@ -6,7 +6,7 @@ import os import sys from mopidy import SettingsError -from mopidy.utils import indent +from mopidy.utils.log import indent logger = logging.getLogger('mopidy.utils.settings') From c2817959fea63db83a328a8ce61b95031b04c4bd Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 20 Aug 2010 16:39:10 +0200 Subject: [PATCH 58/80] Initialize the correct variable in init --- mopidy/frontends/mpd/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/frontends/mpd/__init__.py b/mopidy/frontends/mpd/__init__.py index 048f5748..02e3ab5f 100644 --- a/mopidy/frontends/mpd/__init__.py +++ b/mopidy/frontends/mpd/__init__.py @@ -7,7 +7,7 @@ class MpdFrontend(object): """ def __init__(self): - self.server = None + self.process = None self.dispatcher = None def start_server(self, core_queue): From df22256f096ef3e541d1a5a07ca9250668d2647a Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 21 Aug 2010 23:25:38 +0200 Subject: [PATCH 59/80] Review gstreamer-local-backend branch --- mopidy/backends/base/playback.py | 4 ++-- mopidy/backends/local/__init__.py | 9 ++++----- mopidy/outputs/gstreamer.py | 2 +- mopidy/settings.py | 12 +++++++----- tests/backends/base.py | 2 +- 5 files changed, 15 insertions(+), 14 deletions(-) diff --git a/mopidy/backends/base/playback.py b/mopidy/backends/base/playback.py index 2b5d7478..933424ad 100644 --- a/mopidy/backends/base/playback.py +++ b/mopidy/backends/base/playback.py @@ -442,8 +442,8 @@ class BasePlaybackController(object): :type time_position: int :rtype: :class:`True` if successful, else :class:`False` """ - # FIXME I think return value is only realy usefull for internal - # testing, as such it should probably not be exposed in api. + # FIXME I think return value is only really useful for internal + # testing, as such it should probably not be exposed in API. if self.state == self.STOPPED: self.play() elif self.state == self.PAUSED: diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index bd8a4301..e9e86f34 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -1,14 +1,13 @@ -import logging -import os import glob -import shutil +import logging import multiprocessing +import os +import shutil from mopidy import settings from mopidy.backends.base import * from mopidy.models import Playlist, Track, Album -from mopidy import settings -from mopidy.process import pickle_connection +from mopidy.utils.process import pickle_connection from .translator import parse_m3u, parse_mpd_tag_cache diff --git a/mopidy/outputs/gstreamer.py b/mopidy/outputs/gstreamer.py index 667b815b..453747d6 100644 --- a/mopidy/outputs/gstreamer.py +++ b/mopidy/outputs/gstreamer.py @@ -194,7 +194,7 @@ class GStreamerProcess(BaseProcess): def set_position(self, position): self.gst_pipeline.get_state() # block until state changes are done - handeled= self.gst_pipeline.seek_simple(gst.Format(gst.FORMAT_TIME), + handeled = self.gst_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 return handeled diff --git a/mopidy/settings.py b/mopidy/settings.py index 4268f059..699eb16a 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -51,6 +51,13 @@ DUMP_LOG_FILENAME = u'dump.log' #: Currently only the first frontend in the list is used. FRONTENDS = (u'mopidy.frontends.mpd.MpdFrontend',) +#: Which GStreamer audio sink to use in :mod:`mopidy.outputs.gstreamer`. +#: +#: Default:: +#: +#: GSTREAMER_AUDIO_SINK = u'autoaudiosink' +GSTREAMER_AUDIO_SINK = u'autoaudiosink' + #: Path to folder with local music. #: #: Used by :mod:`mopidy.backends.local`. @@ -163,8 +170,3 @@ SPOTIFY_USERNAME = u'' #: #: Used by :mod:`mopidy.backends.libspotify`. SPOTIFY_PASSWORD = u'' - -#: Which GStreamer audio sink to use in output pipeline. -#: -#: Default: autoaudiosink -GSTREAMER_AUDIO_SINK = u'autoaudiosink' diff --git a/tests/backends/base.py b/tests/backends/base.py index e6c10a5a..e9b78453 100644 --- a/tests/backends/base.py +++ b/tests/backends/base.py @@ -1,9 +1,9 @@ +import multiprocessing import os import random import shutil import tempfile import time -import multiprocessing from mopidy import settings from mopidy.mixers.dummy import DummyMixer From 28eb7e6c7ead936fe48fb35a8ccf4e7a95113a7b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 21 Aug 2010 23:29:00 +0200 Subject: [PATCH 60/80] Fix too long line --- mopidy/core.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mopidy/core.py b/mopidy/core.py index d823bfa5..396a2091 100644 --- a/mopidy/core.py +++ b/mopidy/core.py @@ -75,7 +75,8 @@ class CoreProcess(BaseProcess): if message.get('to') == 'output': self.output_queue.put(message) elif message['command'] == 'mpd_request': - response = self.frontend.dispatcher.handle_request(message['request']) + response = self.frontend.dispatcher.handle_request( + message['request']) connection = unpickle_connection(message['reply_to']) connection.send(response) elif message['command'] == 'end_of_track': From 95619039fd64484b94c73e4784bf81ad92a9afec Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 22 Aug 2010 19:12:29 +0200 Subject: [PATCH 61/80] Importing settings directly does not work --- mopidy/mixers/denon.py | 5 +++-- mopidy/mixers/nad.py | 39 +++++++++++++++++++++++---------------- 2 files changed, 26 insertions(+), 18 deletions(-) diff --git a/mopidy/mixers/denon.py b/mopidy/mixers/denon.py index 7cdf0d7b..32750f60 100644 --- a/mopidy/mixers/denon.py +++ b/mopidy/mixers/denon.py @@ -3,8 +3,8 @@ from threading import Lock from serial import Serial +from mopidy import settings from mopidy.mixers import BaseMixer -from mopidy.settings import MIXER_EXT_PORT logger = logging.getLogger(u'mopidy.mixers.denon') @@ -33,7 +33,8 @@ class DenonMixer(BaseMixer): """ super(DenonMixer, self).__init__(*args, **kwargs) device = kwargs.get('device', None) - self._device = device or Serial(port=MIXER_EXT_PORT, timeout=0.2) + self._device = device or Serial(port=settings.MIXER_EXT_PORT, + timeout=0.2) self._levels = ['99'] + ["%(#)02d" % {'#': v} for v in range(0, 99)] self._volume = 0 self._lock = Lock() diff --git a/mopidy/mixers/nad.py b/mopidy/mixers/nad.py index f859791b..929d2e1d 100644 --- a/mopidy/mixers/nad.py +++ b/mopidy/mixers/nad.py @@ -2,9 +2,8 @@ import logging from serial import Serial from multiprocessing import Pipe +from mopidy import settings from mopidy.mixers import BaseMixer -from mopidy.settings import (MIXER_EXT_PORT, MIXER_EXT_SOURCE, - MIXER_EXT_SPEAKERS_A, MIXER_EXT_SPEAKERS_B) from mopidy.utils.process import BaseProcess logger = logging.getLogger('mopidy.mixers.nad') @@ -91,8 +90,9 @@ class NadTalker(BaseProcess): def _open_connection(self): # Opens serial connection to the device. # Communication settings: 115200 bps 8N1 - logger.info(u'Connecting to serial device "%s"', MIXER_EXT_PORT) - self._device = Serial(port=MIXER_EXT_PORT, baudrate=115200, + logger.info(u'Connecting to serial device "%s"', + settings.MIXER_EXT_PORT) + self._device = Serial(port=settings.MIXER_EXT_PORT, baudrate=115200, timeout=self.TIMEOUT) self._get_device_model() @@ -114,20 +114,27 @@ class NadTalker(BaseProcess): self._command_device('Main.Power', 'On') def _select_speakers(self): - if MIXER_EXT_SPEAKERS_A is not None: - while self._ask_device('Main.SpeakerA') != MIXER_EXT_SPEAKERS_A: - logger.info(u'Setting speakers A "%s"', MIXER_EXT_SPEAKERS_A) - self._command_device('Main.SpeakerA', MIXER_EXT_SPEAKERS_A) - if MIXER_EXT_SPEAKERS_B is not None: - while self._ask_device('Main.SpeakerB') != MIXER_EXT_SPEAKERS_B: - logger.info(u'Setting speakers B "%s"', MIXER_EXT_SPEAKERS_B) - self._command_device('Main.SpeakerB', MIXER_EXT_SPEAKERS_B) + if settings.MIXER_EXT_SPEAKERS_A is not None: + while (self._ask_device('Main.SpeakerA') + != settings.MIXER_EXT_SPEAKERS_A): + logger.info(u'Setting speakers A "%s"', + settings.MIXER_EXT_SPEAKERS_A) + self._command_device('Main.SpeakerA', + settings.MIXER_EXT_SPEAKERS_A) + if settings.MIXER_EXT_SPEAKERS_B is not None: + while (self._ask_device('Main.SpeakerB') != + settings.MIXER_EXT_SPEAKERS_B): + logger.info(u'Setting speakers B "%s"', + settings.MIXER_EXT_SPEAKERS_B) + self._command_device('Main.SpeakerB', + settings.MIXER_EXT_SPEAKERS_B) def _select_input_source(self): - if MIXER_EXT_SOURCE is not None: - while self._ask_device('Main.Source') != MIXER_EXT_SOURCE: - logger.info(u'Selecting input source "%s"', MIXER_EXT_SOURCE) - self._command_device('Main.Source', MIXER_EXT_SOURCE) + if settings.MIXER_EXT_SOURCE is not None: + while self._ask_device('Main.Source') != settings.MIXER_EXT_SOURCE: + logger.info(u'Selecting input source "%s"', + settings.MIXER_EXT_SOURCE) + self._command_device('Main.Source', settings.MIXER_EXT_SOURCE) def _unmute(self): while self._ask_device('Main.Mute') != 'Off': From 421e1f2ab139c060c81320d899b629201a88b777 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 22 Aug 2010 19:45:08 +0200 Subject: [PATCH 62/80] Rename libspotify_integrationtest => libspotify.backend_integrationtest --- tests/backends/libspotify/__init__.py | 0 .../backend_integrationtest.py} | 0 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 tests/backends/libspotify/__init__.py rename tests/backends/{libspotify_integrationtest.py => libspotify/backend_integrationtest.py} (100%) diff --git a/tests/backends/libspotify/__init__.py b/tests/backends/libspotify/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/backends/libspotify_integrationtest.py b/tests/backends/libspotify/backend_integrationtest.py similarity index 100% rename from tests/backends/libspotify_integrationtest.py rename to tests/backends/libspotify/backend_integrationtest.py From d4f8cdadd512759a1e25a5b4304ea762abbee123 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 22 Aug 2010 19:50:14 +0200 Subject: [PATCH 63/80] Rename tests.backend.{base => base.backend} --- tests/backends/base/__init__.py | 0 tests/backends/{base.py => base/backend.py} | 0 tests/backends/local/backend_test.py | 2 +- 3 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 tests/backends/base/__init__.py rename tests/backends/{base.py => base/backend.py} (100%) diff --git a/tests/backends/base/__init__.py b/tests/backends/base/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/backends/base.py b/tests/backends/base/backend.py similarity index 100% rename from tests/backends/base.py rename to tests/backends/base/backend.py diff --git a/tests/backends/local/backend_test.py b/tests/backends/local/backend_test.py index b95c6dde..a59c8420 100644 --- a/tests/backends/local/backend_test.py +++ b/tests/backends/local/backend_test.py @@ -13,7 +13,7 @@ from mopidy.mixers.dummy import DummyMixer from mopidy.models import Playlist, Track from mopidy.utils.path import path_to_uri -from tests.backends.base import * +from tests.backends.base.backend import * from tests import SkipTest, data_folder song = data_folder('song%s.wav') From 4722828c8f7997b0bfb55aee3cdc304a34ef14fd Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 22 Aug 2010 20:40:15 +0200 Subject: [PATCH 64/80] Split backend tests into multiple files --- tests/backends/base/__init__.py | 9 + tests/backends/base/current_playlist.py | 256 +++++++++ tests/backends/base/library.py | 158 +++++ .../backends/base/{backend.py => playback.py} | 540 +----------------- tests/backends/base/stored_playlists.py | 113 ++++ .../libspotify/backend_integrationtest.py | 22 +- tests/backends/local/__init__.py | 6 + tests/backends/local/current_playlist_test.py | 31 + tests/backends/local/library_test.py | 32 ++ tests/backends/local/playback_test.py | 58 ++ ...ckend_test.py => stored_playlists_test.py} | 96 +--- 11 files changed, 688 insertions(+), 633 deletions(-) create mode 100644 tests/backends/base/current_playlist.py create mode 100644 tests/backends/base/library.py rename tests/backends/base/{backend.py => playback.py} (60%) create mode 100644 tests/backends/base/stored_playlists.py create mode 100644 tests/backends/local/current_playlist_test.py create mode 100644 tests/backends/local/library_test.py create mode 100644 tests/backends/local/playback_test.py rename tests/backends/local/{backend_test.py => stored_playlists_test.py} (50%) diff --git a/tests/backends/base/__init__.py b/tests/backends/base/__init__.py index e69de29b..29f010e1 100644 --- a/tests/backends/base/__init__.py +++ b/tests/backends/base/__init__.py @@ -0,0 +1,9 @@ +def populate_playlist(func): + def wrapper(self): + for track in self.tracks: + self.backend.current_playlist.add(track) + return func(self) + + wrapper.__name__ = func.__name__ + wrapper.__doc__ = func.__doc__ + return wrapper diff --git a/tests/backends/base/current_playlist.py b/tests/backends/base/current_playlist.py new file mode 100644 index 00000000..1b312c2f --- /dev/null +++ b/tests/backends/base/current_playlist.py @@ -0,0 +1,256 @@ +import multiprocessing +import random + +from mopidy import settings +from mopidy.mixers.dummy import DummyMixer +from mopidy.models import Playlist, Track +from mopidy.utils import get_class + +from tests.backends.base import populate_playlist + +class BaseCurrentPlaylistControllerTest(object): + tracks = [] + + def setUp(self): + self.output_queue = multiprocessing.Queue() + self.core_queue = multiprocessing.Queue() + self.output = get_class(settings.OUTPUT)( + self.core_queue, self.output_queue) + self.backend = self.backend_class( + self.core_queue, self.output_queue, DummyMixer) + self.controller = self.backend.current_playlist + self.playback = self.backend.playback + + assert len(self.tracks) == 3, 'Need three tracks to run tests.' + + def tearDown(self): + self.backend.destroy() + self.output.destroy() + + def test_add(self): + for track in self.tracks: + 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]) + + 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]) + + @populate_playlist + def test_add_at_position_outside_of_playlist(self): + test = lambda: self.controller.add(self.tracks[0], len(self.tracks)+2) + self.assertRaises(AssertionError, test) + + @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])) + + @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)) + + @populate_playlist + def test_get_by_uri_raises_error_for_invalid_uri(self): + test = lambda: self.controller.get(uri='foobar') + self.assertRaises(LookupError, test) + + @populate_playlist + def test_clear(self): + self.controller.clear() + self.assertEqual(len(self.controller.tracks), 0) + + def test_clear_empty_playlist(self): + self.controller.clear() + self.assertEqual(len(self.controller.tracks), 0) + + @populate_playlist + def test_clear_when_playing(self): + self.playback.play() + self.assertEqual(self.playback.state, self.playback.PLAYING) + self.controller.clear() + self.assertEqual(self.playback.state, self.playback.STOPPED) + + def test_get_by_uri_returns_unique_match(self): + track = Track(uri='a') + self.controller.append([Track(uri='z'), track, Track(uri='y')]) + self.assertEqual(track, self.controller.get(uri='a')[1]) + + def test_get_by_uri_raises_error_if_multiple_matches(self): + track = Track(uri='a') + self.controller.append([Track(uri='z'), track, track]) + try: + self.controller.get(uri='a') + self.fail(u'Should raise LookupError if multiple matches') + except LookupError as e: + self.assertEqual(u'"uri=a" match multiple tracks', e[0]) + + def test_get_by_uri_raises_error_if_no_match(self): + self.controller.playlist = Playlist( + tracks=[Track(uri='z'), Track(uri='y')]) + try: + self.controller.get(uri='a') + self.fail(u'Should raise LookupError if no match') + except LookupError as e: + self.assertEqual(u'"uri=a" match no tracks', e[0]) + + def test_get_by_multiple_criteria_returns_elements_matching_all(self): + track1 = Track(uri='a', name='x') + track2 = Track(uri='b', name='x') + track3 = Track(uri='b', name='y') + self.controller.append([track1, track2, track3]) + self.assertEqual(track1, self.controller.get(uri='a', name='x')[1]) + self.assertEqual(track2, self.controller.get(uri='b', name='x')[1]) + self.assertEqual(track3, self.controller.get(uri='b', name='y')[1]) + + def test_get_by_criteria_that_is_not_present_in_all_elements(self): + track1 = Track() + track2 = Track(uri='b') + track3 = Track() + self.controller.append([track1, track2, track3]) + self.assertEqual(track2, self.controller.get(uri='b')[1]) + + def test_append_appends_to_the_current_playlist(self): + self.controller.append([Track(uri='a'), Track(uri='b')]) + self.assertEqual(len(self.controller.tracks), 2) + self.controller.append([Track(uri='c'), Track(uri='d')]) + self.assertEqual(len(self.controller.tracks), 4) + self.assertEqual(self.controller.tracks[0].uri, 'a') + self.assertEqual(self.controller.tracks[1].uri, 'b') + self.assertEqual(self.controller.tracks[2].uri, 'c') + self.assertEqual(self.controller.tracks[3].uri, 'd') + + def test_append_does_not_reset_version(self): + version = self.controller.version + self.controller.append([]) + self.assertEqual(self.controller.version, version + 1) + + @populate_playlist + def test_append_preserves_playing_state(self): + self.playback.play() + track = self.playback.current_track + self.controller.append(self.controller.tracks[1:2]) + self.assertEqual(self.playback.state, self.playback.PLAYING) + self.assertEqual(self.playback.current_track, track) + + @populate_playlist + def test_append_preserves_stopped_state(self): + self.controller.append(self.controller.tracks[1:2]) + self.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.current_track, None) + + @populate_playlist + def test_move_single(self): + self.controller.move(0, 0, 2) + + tracks = self.controller.tracks + self.assertEqual(tracks[2], self.tracks[0]) + + @populate_playlist + def test_move_group(self): + self.controller.move(0, 2, 1) + + tracks = self.controller.tracks + self.assertEqual(tracks[1], self.tracks[0]) + self.assertEqual(tracks[2], self.tracks[1]) + + @populate_playlist + def test_moving_track_outside_of_playlist(self): + tracks = len(self.controller.tracks) + test = lambda: self.controller.move(0, 0, tracks+5) + self.assertRaises(AssertionError, test) + + @populate_playlist + def test_move_group_outside_of_playlist(self): + tracks = len(self.controller.tracks) + test = lambda: self.controller.move(0, 2, tracks+5) + self.assertRaises(AssertionError, test) + + @populate_playlist + def test_move_group_out_of_range(self): + tracks = len(self.controller.tracks) + test = lambda: self.controller.move(tracks+2, tracks+3, 0) + self.assertRaises(AssertionError, test) + + @populate_playlist + def test_move_group_invalid_group(self): + test = lambda: self.controller.move(2, 1, 0) + self.assertRaises(AssertionError, test) + + def test_tracks_attribute_is_immutable(self): + tracks1 = self.controller.tracks + tracks2 = self.controller.tracks + self.assertNotEqual(id(tracks1), id(tracks2)) + + @populate_playlist + def test_remove(self): + track1 = self.controller.tracks[1] + track2 = self.controller.tracks[2] + version = self.controller.version + self.controller.remove(uri=track1.uri) + self.assert_(version < self.controller.version) + self.assert_(track1 not in self.controller.tracks) + self.assertEqual(track2, self.controller.tracks[1]) + + @populate_playlist + def test_removing_track_that_does_not_exist(self): + test = lambda: self.controller.remove(uri='/nonexistant') + self.assertRaises(LookupError, test) + + def test_removing_from_empty_playlist(self): + test = lambda: self.controller.remove(uri='/nonexistant') + self.assertRaises(LookupError, test) + + @populate_playlist + def test_shuffle(self): + random.seed(1) + self.controller.shuffle() + + shuffled_tracks = self.controller.tracks + + self.assertNotEqual(self.tracks, shuffled_tracks) + self.assertEqual(set(self.tracks), set(shuffled_tracks)) + + @populate_playlist + def test_shuffle_subset(self): + random.seed(1) + self.controller.shuffle(1, 3) + + shuffled_tracks = self.controller.tracks + + self.assertNotEqual(self.tracks, shuffled_tracks) + self.assertEqual(self.tracks[0], shuffled_tracks[0]) + self.assertEqual(set(self.tracks), set(shuffled_tracks)) + + @populate_playlist + def test_shuffle_invalid_subset(self): + test = lambda: self.controller.shuffle(3, 1) + self.assertRaises(AssertionError, test) + + @populate_playlist + def test_shuffle_superset(self): + tracks = len(self.controller.tracks) + test = lambda: self.controller.shuffle(1, tracks+5) + self.assertRaises(AssertionError, test) + + @populate_playlist + def test_shuffle_open_subset(self): + random.seed(1) + self.controller.shuffle(1) + + shuffled_tracks = self.controller.tracks + + self.assertNotEqual(self.tracks, shuffled_tracks) + self.assertEqual(self.tracks[0], shuffled_tracks[0]) + self.assertEqual(set(self.tracks), set(shuffled_tracks)) + + def test_version(self): + version = self.controller.version + self.controller.append([]) + self.assert_(version < self.controller.version) diff --git a/tests/backends/base/library.py b/tests/backends/base/library.py new file mode 100644 index 00000000..1239bd08 --- /dev/null +++ b/tests/backends/base/library.py @@ -0,0 +1,158 @@ +from mopidy.mixers.dummy import DummyMixer +from mopidy.models import Playlist, Track, Album, Artist + +from tests import SkipTest, data_folder + +class BaseLibraryControllerTest(object): + artists = [Artist(name='artist1'), Artist(name='artist2'), Artist()] + albums = [Album(name='album1', artists=artists[:1]), + Album(name='album2', artists=artists[1:2]), + Album()] + tracks = [Track(name='track1', length=4000, artists=artists[:1], + album=albums[0], uri='file://' + data_folder('uri1')), + Track(name='track2', length=4000, artists=artists[1:2], + album=albums[1], uri='file://' + data_folder('uri2')), + Track()] + + def setUp(self): + self.backend = self.backend_class(mixer_class=DummyMixer) + self.library = self.backend.library + + def tearDown(self): + self.backend.destroy() + + def test_refresh(self): + self.library.refresh() + + def test_refresh_uri(self): + raise SkipTest + + def test_refresh_missing_uri(self): + raise SkipTest + + def test_lookup(self): + track = self.library.lookup(self.tracks[0].uri) + self.assertEqual(track, self.tracks[0]) + + def test_lookup_unknown_track(self): + test = lambda: self.library.lookup('fake uri') + self.assertRaises(LookupError, test) + + def test_find_exact_no_hits(self): + result = self.library.find_exact(track=['unknown track']) + self.assertEqual(result, Playlist()) + + result = self.library.find_exact(artist=['unknown artist']) + self.assertEqual(result, Playlist()) + + result = self.library.find_exact(album=['unknown artist']) + self.assertEqual(result, Playlist()) + + def test_find_exact_artist(self): + result = self.library.find_exact(artist=['artist1']) + self.assertEqual(result, Playlist(tracks=self.tracks[:1])) + + result = self.library.find_exact(artist=['artist2']) + self.assertEqual(result, Playlist(tracks=self.tracks[1:2])) + + def test_find_exact_track(self): + result = self.library.find_exact(track=['track1']) + self.assertEqual(result, Playlist(tracks=self.tracks[:1])) + + result = self.library.find_exact(track=['track2']) + self.assertEqual(result, Playlist(tracks=self.tracks[1:2])) + + def test_find_exact_album(self): + result = self.library.find_exact(album=['album1']) + self.assertEqual(result, Playlist(tracks=self.tracks[:1])) + + result = self.library.find_exact(album=['album2']) + self.assertEqual(result, Playlist(tracks=self.tracks[1:2])) + + def test_find_exact_wrong_type(self): + test = lambda: self.library.find_exact(wrong=['test']) + self.assertRaises(LookupError, test) + + def test_find_exact_with_empty_query(self): + test = lambda: self.library.find_exact(artist=['']) + self.assertRaises(LookupError, test) + + test = lambda: self.library.find_exact(track=['']) + self.assertRaises(LookupError, test) + + test = lambda: self.library.find_exact(album=['']) + self.assertRaises(LookupError, test) + + def test_search_no_hits(self): + result = self.library.search(track=['unknown track']) + self.assertEqual(result, Playlist()) + + result = self.library.search(artist=['unknown artist']) + self.assertEqual(result, Playlist()) + + result = self.library.search(album=['unknown artist']) + self.assertEqual(result, Playlist()) + + result = self.library.search(uri=['unknown']) + self.assertEqual(result, Playlist()) + + result = self.library.search(any=['unknown']) + self.assertEqual(result, Playlist()) + + def test_search_artist(self): + result = self.library.search(artist=['Tist1']) + self.assertEqual(result, Playlist(tracks=self.tracks[:1])) + + result = self.library.search(artist=['Tist2']) + self.assertEqual(result, Playlist(tracks=self.tracks[1:2])) + + def test_search_track(self): + result = self.library.search(track=['Rack1']) + self.assertEqual(result, Playlist(tracks=self.tracks[:1])) + + result = self.library.search(track=['Rack2']) + self.assertEqual(result, Playlist(tracks=self.tracks[1:2])) + + def test_search_album(self): + result = self.library.search(album=['Bum1']) + self.assertEqual(result, Playlist(tracks=self.tracks[:1])) + + result = self.library.search(album=['Bum2']) + self.assertEqual(result, Playlist(tracks=self.tracks[1:2])) + + def test_search_uri(self): + result = self.library.search(uri=['RI1']) + self.assertEqual(result, Playlist(tracks=self.tracks[:1])) + + result = self.library.search(uri=['RI2']) + self.assertEqual(result, Playlist(tracks=self.tracks[1:2])) + + def test_search_any(self): + result = self.library.search(any=['Tist1']) + self.assertEqual(result, Playlist(tracks=self.tracks[:1])) + result = self.library.search(any=['Rack1']) + self.assertEqual(result, Playlist(tracks=self.tracks[:1])) + result = self.library.search(any=['Bum1']) + self.assertEqual(result, Playlist(tracks=self.tracks[:1])) + result = self.library.search(any=['RI1']) + self.assertEqual(result, Playlist(tracks=self.tracks[:1])) + + def test_search_wrong_type(self): + test = lambda: self.library.search(wrong=['test']) + self.assertRaises(LookupError, test) + + def test_search_with_empty_query(self): + test = lambda: self.library.search(artist=['']) + self.assertRaises(LookupError, test) + + test = lambda: self.library.search(track=['']) + self.assertRaises(LookupError, test) + + test = lambda: self.library.search(album=['']) + self.assertRaises(LookupError, test) + + test = lambda: self.library.search(uri=['']) + self.assertRaises(LookupError, test) + + test = lambda: self.library.search(any=['']) + self.assertRaises(LookupError, test) diff --git a/tests/backends/base/backend.py b/tests/backends/base/playback.py similarity index 60% rename from tests/backends/base/backend.py rename to tests/backends/base/playback.py index e9b78453..f8e9dd87 100644 --- a/tests/backends/base/backend.py +++ b/tests/backends/base/playback.py @@ -1,289 +1,25 @@ import multiprocessing -import os import random -import shutil -import tempfile import time from mopidy import settings from mopidy.mixers.dummy import DummyMixer -from mopidy.models import Playlist, Track, Album, Artist +from mopidy.models import Track from mopidy.utils import get_class -from tests import SkipTest, data_folder - -__all__ = ['BaseCurrentPlaylistControllerTest', - 'BasePlaybackControllerTest', - 'BaseStoredPlaylistsControllerTest', - 'BaseLibraryControllerTest'] - -def populate_playlist(func): - def wrapper(self): - for track in self.tracks: - self.backend.current_playlist.add(track) - return func(self) - - wrapper.__name__ = func.__name__ - wrapper.__doc__ = func.__doc__ - return wrapper - - -class BaseCurrentPlaylistControllerTest(object): - tracks = [] - backend_class = None - - def setUp(self): - self.output_queue = multiprocessing.Queue() - self.core_queue = multiprocessing.Queue() - self.output = get_class(settings.OUTPUT)(self.core_queue, self.output_queue) - self.backend = self.backend_class(self.core_queue, self.output_queue, DummyMixer) - self.controller = self.backend.current_playlist - self.playback = self.backend.playback - - assert len(self.tracks) == 3, 'Need three tracks to run tests.' - - def tearDown(self): - self.backend.destroy() - self.output.destroy() - - def test_add(self): - for track in self.tracks: - 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]) - - 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]) - - @populate_playlist - def test_add_at_position_outside_of_playlist(self): - test = lambda: self.controller.add(self.tracks[0], len(self.tracks)+2) - self.assertRaises(AssertionError, test) - - @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])) - - @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)) - - @populate_playlist - def test_get_by_uri_raises_error_for_invalid_uri(self): - test = lambda: self.controller.get(uri='foobar') - self.assertRaises(LookupError, test) - - @populate_playlist - def test_clear(self): - self.controller.clear() - self.assertEqual(len(self.controller.tracks), 0) - - def test_clear_empty_playlist(self): - self.controller.clear() - self.assertEqual(len(self.controller.tracks), 0) - - @populate_playlist - def test_clear_when_playing(self): - self.playback.play() - self.assertEqual(self.playback.state, self.playback.PLAYING) - self.controller.clear() - self.assertEqual(self.playback.state, self.playback.STOPPED) - - def test_get_by_uri_returns_unique_match(self): - track = Track(uri='a') - self.controller.append([Track(uri='z'), track, Track(uri='y')]) - self.assertEqual(track, self.controller.get(uri='a')[1]) - - def test_get_by_uri_raises_error_if_multiple_matches(self): - track = Track(uri='a') - self.controller.append([Track(uri='z'), track, track]) - try: - self.controller.get(uri='a') - self.fail(u'Should raise LookupError if multiple matches') - except LookupError as e: - self.assertEqual(u'"uri=a" match multiple tracks', e[0]) - - def test_get_by_uri_raises_error_if_no_match(self): - self.controller.playlist = Playlist( - tracks=[Track(uri='z'), Track(uri='y')]) - try: - self.controller.get(uri='a') - self.fail(u'Should raise LookupError if no match') - except LookupError as e: - self.assertEqual(u'"uri=a" match no tracks', e[0]) - - def test_get_by_multiple_criteria_returns_elements_matching_all(self): - track1 = Track(uri='a', name='x') - track2 = Track(uri='b', name='x') - track3 = Track(uri='b', name='y') - self.controller.append([track1, track2, track3]) - self.assertEqual(track1, self.controller.get(uri='a', name='x')[1]) - self.assertEqual(track2, self.controller.get(uri='b', name='x')[1]) - self.assertEqual(track3, self.controller.get(uri='b', name='y')[1]) - - def test_get_by_criteria_that_is_not_present_in_all_elements(self): - track1 = Track() - track2 = Track(uri='b') - track3 = Track() - self.controller.append([track1, track2, track3]) - self.assertEqual(track2, self.controller.get(uri='b')[1]) - - def test_append_appends_to_the_current_playlist(self): - self.controller.append([Track(uri='a'), Track(uri='b')]) - self.assertEqual(len(self.controller.tracks), 2) - self.controller.append([Track(uri='c'), Track(uri='d')]) - self.assertEqual(len(self.controller.tracks), 4) - self.assertEqual(self.controller.tracks[0].uri, 'a') - self.assertEqual(self.controller.tracks[1].uri, 'b') - self.assertEqual(self.controller.tracks[2].uri, 'c') - self.assertEqual(self.controller.tracks[3].uri, 'd') - - def test_append_does_not_reset_version(self): - version = self.controller.version - self.controller.append([]) - self.assertEqual(self.controller.version, version + 1) - - @populate_playlist - def test_append_preserves_playing_state(self): - self.playback.play() - track = self.playback.current_track - self.controller.append(self.controller.tracks[1:2]) - self.assertEqual(self.playback.state, self.playback.PLAYING) - self.assertEqual(self.playback.current_track, track) - - @populate_playlist - def test_append_preserves_stopped_state(self): - self.controller.append(self.controller.tracks[1:2]) - self.assertEqual(self.playback.state, self.playback.STOPPED) - self.assertEqual(self.playback.current_track, None) - - @populate_playlist - def test_move_single(self): - self.controller.move(0, 0, 2) - - tracks = self.controller.tracks - self.assertEqual(tracks[2], self.tracks[0]) - - @populate_playlist - def test_move_group(self): - self.controller.move(0, 2, 1) - - tracks = self.controller.tracks - self.assertEqual(tracks[1], self.tracks[0]) - self.assertEqual(tracks[2], self.tracks[1]) - - @populate_playlist - def test_moving_track_outside_of_playlist(self): - tracks = len(self.controller.tracks) - test = lambda: self.controller.move(0, 0, tracks+5) - self.assertRaises(AssertionError, test) - - @populate_playlist - def test_move_group_outside_of_playlist(self): - tracks = len(self.controller.tracks) - test = lambda: self.controller.move(0, 2, tracks+5) - self.assertRaises(AssertionError, test) - - @populate_playlist - def test_move_group_out_of_range(self): - tracks = len(self.controller.tracks) - test = lambda: self.controller.move(tracks+2, tracks+3, 0) - self.assertRaises(AssertionError, test) - - @populate_playlist - def test_move_group_invalid_group(self): - test = lambda: self.controller.move(2, 1, 0) - self.assertRaises(AssertionError, test) - - def test_tracks_attribute_is_immutable(self): - tracks1 = self.controller.tracks - tracks2 = self.controller.tracks - self.assertNotEqual(id(tracks1), id(tracks2)) - - @populate_playlist - def test_remove(self): - track1 = self.controller.tracks[1] - track2 = self.controller.tracks[2] - version = self.controller.version - self.controller.remove(uri=track1.uri) - self.assert_(version < self.controller.version) - self.assert_(track1 not in self.controller.tracks) - self.assertEqual(track2, self.controller.tracks[1]) - - @populate_playlist - def test_removing_track_that_does_not_exist(self): - test = lambda: self.controller.remove(uri='/nonexistant') - self.assertRaises(LookupError, test) - - def test_removing_from_empty_playlist(self): - test = lambda: self.controller.remove(uri='/nonexistant') - self.assertRaises(LookupError, test) - - @populate_playlist - def test_shuffle(self): - random.seed(1) - self.controller.shuffle() - - shuffled_tracks = self.controller.tracks - - self.assertNotEqual(self.tracks, shuffled_tracks) - self.assertEqual(set(self.tracks), set(shuffled_tracks)) - - @populate_playlist - def test_shuffle_subset(self): - random.seed(1) - self.controller.shuffle(1, 3) - - shuffled_tracks = self.controller.tracks - - self.assertNotEqual(self.tracks, shuffled_tracks) - self.assertEqual(self.tracks[0], shuffled_tracks[0]) - self.assertEqual(set(self.tracks), set(shuffled_tracks)) - - @populate_playlist - def test_shuffle_invalid_subset(self): - test = lambda: self.controller.shuffle(3, 1) - self.assertRaises(AssertionError, test) - - @populate_playlist - def test_shuffle_superset(self): - tracks = len(self.controller.tracks) - test = lambda: self.controller.shuffle(1, tracks+5) - self.assertRaises(AssertionError, test) - - @populate_playlist - def test_shuffle_open_subset(self): - random.seed(1) - self.controller.shuffle(1) - - shuffled_tracks = self.controller.tracks - - self.assertNotEqual(self.tracks, shuffled_tracks) - self.assertEqual(self.tracks[0], shuffled_tracks[0]) - self.assertEqual(set(self.tracks), set(shuffled_tracks)) - - def test_version(self): - version = self.controller.version - self.controller.append([]) - self.assert_(version < self.controller.version) - +from tests import SkipTest +from tests.backends.base import populate_playlist class BasePlaybackControllerTest(object): tracks = [] - backend_class = None def setUp(self): self.output_queue = multiprocessing.Queue() self.core_queue = multiprocessing.Queue() - self.output = get_class(settings.OUTPUT)(self.core_queue, self.output_queue) - self.backend = self.backend_class(self.core_queue, self.output_queue, DummyMixer) + self.output = get_class(settings.OUTPUT)( + self.core_queue, self.output_queue) + self.backend = self.backend_class( + self.core_queue, self.output_queue, DummyMixer) self.playback = self.backend.playback self.current_playlist = self.backend.current_playlist @@ -1123,265 +859,3 @@ class BasePlaybackControllerTest(object): def test_playing_track_that_isnt_in_playlist(self): test = lambda: self.playback.play((17, Track())) self.assertRaises(AssertionError, test) - - -class BaseStoredPlaylistsControllerTest(object): - backend_class = None - - def setUp(self): - self.original_playlist_folder = settings.LOCAL_PLAYLIST_FOLDER - self.original_tag_cache = settings.LOCAL_TAG_CACHE - self.original_music_folder = settings.LOCAL_MUSIC_FOLDER - - settings.LOCAL_PLAYLIST_FOLDER = tempfile.mkdtemp() - settings.LOCAL_TAG_CACHE = data_folder('library_tag_cache') - settings.LOCAL_MUSIC_FOLDER = data_folder('') - - self.backend = self.backend_class(mixer_class=DummyMixer) - self.stored = self.backend.stored_playlists - - def tearDown(self): - self.backend.destroy() - - if os.path.exists(settings.LOCAL_PLAYLIST_FOLDER): - shutil.rmtree(settings.LOCAL_PLAYLIST_FOLDER) - - settings.LOCAL_PLAYLIST_FOLDER = self.original_playlist_folder - settings.LOCAL_TAG_CACHE = self.original_tag_cache - settings.LOCAL_MUSIC_FOLDER = self.original_music_folder - - def test_create(self): - playlist = self.stored.create('test') - self.assertEqual(playlist.name, 'test') - - def test_create_in_playlists(self): - playlist = self.stored.create('test') - self.assert_(self.stored.playlists) - self.assert_(playlist in self.stored.playlists) - - def test_playlists_empty_to_start_with(self): - self.assert_(not self.stored.playlists) - - def test_delete_non_existant_playlist(self): - self.stored.delete(Playlist()) - - def test_delete_playlist(self): - playlist = self.stored.create('test') - self.stored.delete(playlist) - self.assert_(not self.stored.playlists) - - def test_get_without_criteria(self): - test = self.stored.get - self.assertRaises(LookupError, test) - - def test_get_with_wrong_cirteria(self): - test = lambda: self.stored.get(name='foo') - self.assertRaises(LookupError, test) - - def test_get_with_right_criteria(self): - playlist1 = self.stored.create('test') - playlist2 = self.stored.get(name='test') - self.assertEqual(playlist1, playlist2) - - def test_get_by_name_returns_unique_match(self): - playlist = Playlist(name='b') - self.stored.playlists = [Playlist(name='a'), playlist] - self.assertEqual(playlist, self.stored.get(name='b')) - - def test_get_by_name_returns_first_of_multiple_matches(self): - playlist = Playlist(name='b') - self.stored.playlists = [ - playlist, Playlist(name='a'), Playlist(name='b')] - try: - self.stored.get(name='b') - self.fail(u'Should raise LookupError if multiple matches') - except LookupError as e: - self.assertEqual(u'"name=b" match multiple playlists', e[0]) - - def test_get_by_name_raises_keyerror_if_no_match(self): - self.stored.playlists = [Playlist(name='a'), Playlist(name='b')] - try: - self.stored.get(name='c') - self.fail(u'Should raise LookupError if no match') - except LookupError as e: - self.assertEqual(u'"name=c" match no playlists', e[0]) - - def test_lookup(self): - raise SkipTest - - def test_refresh(self): - raise SkipTest - - def test_rename(self): - playlist = self.stored.create('test') - self.stored.rename(playlist, 'test2') - self.stored.get(name='test2') - - def test_rename_unknown_playlist(self): - self.stored.rename(Playlist(), 'test2') - test = lambda: self.stored.get(name='test2') - self.assertRaises(LookupError, test) - - def test_save(self): - # FIXME should we handle playlists without names? - playlist = Playlist(name='test') - self.stored.save(playlist) - self.assert_(playlist in self.stored.playlists) - - def test_playlist_with_unknown_track(self): - raise SkipTest - - -class BaseLibraryControllerTest(object): - artists = [Artist(name='artist1'), Artist(name='artist2'), Artist()] - albums = [Album(name='album1', artists=artists[:1]), - Album(name='album2', artists=artists[1:2]), - Album()] - tracks = [Track(name='track1', length=4000, artists=artists[:1], - album=albums[0], uri='file://' + data_folder('uri1')), - Track(name='track2', length=4000, artists=artists[1:2], - album=albums[1], uri='file://' + data_folder('uri2')), - Track()] - - def setUp(self): - self.backend = self.backend_class(mixer_class=DummyMixer) - self.library = self.backend.library - - def tearDown(self): - self.backend.destroy() - - def test_refresh(self): - self.library.refresh() - - def test_refresh_uri(self): - raise SkipTest - - def test_refresh_missing_uri(self): - raise SkipTest - - def test_lookup(self): - track = self.library.lookup(self.tracks[0].uri) - self.assertEqual(track, self.tracks[0]) - - def test_lookup_unknown_track(self): - test = lambda: self.library.lookup('fake uri') - self.assertRaises(LookupError, test) - - def test_find_exact_no_hits(self): - result = self.library.find_exact(track=['unknown track']) - self.assertEqual(result, Playlist()) - - result = self.library.find_exact(artist=['unknown artist']) - self.assertEqual(result, Playlist()) - - result = self.library.find_exact(album=['unknown artist']) - self.assertEqual(result, Playlist()) - - def test_find_exact_artist(self): - result = self.library.find_exact(artist=['artist1']) - self.assertEqual(result, Playlist(tracks=self.tracks[:1])) - - result = self.library.find_exact(artist=['artist2']) - self.assertEqual(result, Playlist(tracks=self.tracks[1:2])) - - def test_find_exact_track(self): - result = self.library.find_exact(track=['track1']) - self.assertEqual(result, Playlist(tracks=self.tracks[:1])) - - result = self.library.find_exact(track=['track2']) - self.assertEqual(result, Playlist(tracks=self.tracks[1:2])) - - def test_find_exact_album(self): - result = self.library.find_exact(album=['album1']) - self.assertEqual(result, Playlist(tracks=self.tracks[:1])) - - result = self.library.find_exact(album=['album2']) - self.assertEqual(result, Playlist(tracks=self.tracks[1:2])) - - def test_find_exact_wrong_type(self): - test = lambda: self.library.find_exact(wrong=['test']) - self.assertRaises(LookupError, test) - - def test_find_exact_with_empty_query(self): - test = lambda: self.library.find_exact(artist=['']) - self.assertRaises(LookupError, test) - - test = lambda: self.library.find_exact(track=['']) - self.assertRaises(LookupError, test) - - test = lambda: self.library.find_exact(album=['']) - self.assertRaises(LookupError, test) - - def test_search_no_hits(self): - result = self.library.search(track=['unknown track']) - self.assertEqual(result, Playlist()) - - result = self.library.search(artist=['unknown artist']) - self.assertEqual(result, Playlist()) - - result = self.library.search(album=['unknown artist']) - self.assertEqual(result, Playlist()) - - result = self.library.search(uri=['unknown']) - self.assertEqual(result, Playlist()) - - result = self.library.search(any=['unknown']) - self.assertEqual(result, Playlist()) - - def test_search_artist(self): - result = self.library.search(artist=['Tist1']) - self.assertEqual(result, Playlist(tracks=self.tracks[:1])) - - result = self.library.search(artist=['Tist2']) - self.assertEqual(result, Playlist(tracks=self.tracks[1:2])) - - def test_search_track(self): - result = self.library.search(track=['Rack1']) - self.assertEqual(result, Playlist(tracks=self.tracks[:1])) - - result = self.library.search(track=['Rack2']) - self.assertEqual(result, Playlist(tracks=self.tracks[1:2])) - - def test_search_album(self): - result = self.library.search(album=['Bum1']) - self.assertEqual(result, Playlist(tracks=self.tracks[:1])) - - result = self.library.search(album=['Bum2']) - self.assertEqual(result, Playlist(tracks=self.tracks[1:2])) - - def test_search_uri(self): - result = self.library.search(uri=['RI1']) - self.assertEqual(result, Playlist(tracks=self.tracks[:1])) - - result = self.library.search(uri=['RI2']) - self.assertEqual(result, Playlist(tracks=self.tracks[1:2])) - - def test_search_any(self): - result = self.library.search(any=['Tist1']) - self.assertEqual(result, Playlist(tracks=self.tracks[:1])) - result = self.library.search(any=['Rack1']) - self.assertEqual(result, Playlist(tracks=self.tracks[:1])) - result = self.library.search(any=['Bum1']) - self.assertEqual(result, Playlist(tracks=self.tracks[:1])) - result = self.library.search(any=['RI1']) - self.assertEqual(result, Playlist(tracks=self.tracks[:1])) - - def test_search_wrong_type(self): - test = lambda: self.library.search(wrong=['test']) - self.assertRaises(LookupError, test) - - def test_search_with_empty_query(self): - test = lambda: self.library.search(artist=['']) - self.assertRaises(LookupError, test) - - test = lambda: self.library.search(track=['']) - self.assertRaises(LookupError, test) - - test = lambda: self.library.search(album=['']) - self.assertRaises(LookupError, test) - - test = lambda: self.library.search(uri=['']) - self.assertRaises(LookupError, test) - - test = lambda: self.library.search(any=['']) - self.assertRaises(LookupError, test) diff --git a/tests/backends/base/stored_playlists.py b/tests/backends/base/stored_playlists.py new file mode 100644 index 00000000..630898de --- /dev/null +++ b/tests/backends/base/stored_playlists.py @@ -0,0 +1,113 @@ +import os +import shutil +import tempfile + +from mopidy import settings +from mopidy.mixers.dummy import DummyMixer +from mopidy.models import Playlist + +from tests import SkipTest, data_folder + +class BaseStoredPlaylistsControllerTest(object): + def setUp(self): + self.original_playlist_folder = settings.LOCAL_PLAYLIST_FOLDER + self.original_tag_cache = settings.LOCAL_TAG_CACHE + self.original_music_folder = settings.LOCAL_MUSIC_FOLDER + + settings.LOCAL_PLAYLIST_FOLDER = tempfile.mkdtemp() + settings.LOCAL_TAG_CACHE = data_folder('library_tag_cache') + settings.LOCAL_MUSIC_FOLDER = data_folder('') + + self.backend = self.backend_class(mixer_class=DummyMixer) + self.stored = self.backend.stored_playlists + + def tearDown(self): + self.backend.destroy() + + if os.path.exists(settings.LOCAL_PLAYLIST_FOLDER): + shutil.rmtree(settings.LOCAL_PLAYLIST_FOLDER) + + settings.LOCAL_PLAYLIST_FOLDER = self.original_playlist_folder + settings.LOCAL_TAG_CACHE = self.original_tag_cache + settings.LOCAL_MUSIC_FOLDER = self.original_music_folder + + def test_create(self): + playlist = self.stored.create('test') + self.assertEqual(playlist.name, 'test') + + def test_create_in_playlists(self): + playlist = self.stored.create('test') + self.assert_(self.stored.playlists) + self.assert_(playlist in self.stored.playlists) + + def test_playlists_empty_to_start_with(self): + self.assert_(not self.stored.playlists) + + def test_delete_non_existant_playlist(self): + self.stored.delete(Playlist()) + + def test_delete_playlist(self): + playlist = self.stored.create('test') + self.stored.delete(playlist) + self.assert_(not self.stored.playlists) + + def test_get_without_criteria(self): + test = self.stored.get + self.assertRaises(LookupError, test) + + def test_get_with_wrong_cirteria(self): + test = lambda: self.stored.get(name='foo') + self.assertRaises(LookupError, test) + + def test_get_with_right_criteria(self): + playlist1 = self.stored.create('test') + playlist2 = self.stored.get(name='test') + self.assertEqual(playlist1, playlist2) + + def test_get_by_name_returns_unique_match(self): + playlist = Playlist(name='b') + self.stored.playlists = [Playlist(name='a'), playlist] + self.assertEqual(playlist, self.stored.get(name='b')) + + def test_get_by_name_returns_first_of_multiple_matches(self): + playlist = Playlist(name='b') + self.stored.playlists = [ + playlist, Playlist(name='a'), Playlist(name='b')] + try: + self.stored.get(name='b') + self.fail(u'Should raise LookupError if multiple matches') + except LookupError as e: + self.assertEqual(u'"name=b" match multiple playlists', e[0]) + + def test_get_by_name_raises_keyerror_if_no_match(self): + self.stored.playlists = [Playlist(name='a'), Playlist(name='b')] + try: + self.stored.get(name='c') + self.fail(u'Should raise LookupError if no match') + except LookupError as e: + self.assertEqual(u'"name=c" match no playlists', e[0]) + + def test_lookup(self): + raise SkipTest + + def test_refresh(self): + raise SkipTest + + def test_rename(self): + playlist = self.stored.create('test') + self.stored.rename(playlist, 'test2') + self.stored.get(name='test2') + + def test_rename_unknown_playlist(self): + self.stored.rename(Playlist(), 'test2') + test = lambda: self.stored.get(name='test2') + self.assertRaises(LookupError, test) + + def test_save(self): + # FIXME should we handle playlists without names? + playlist = Playlist(name='test') + self.stored.save(playlist) + self.assert_(playlist in self.stored.playlists) + + def test_playlist_with_unknown_track(self): + raise SkipTest diff --git a/tests/backends/libspotify/backend_integrationtest.py b/tests/backends/libspotify/backend_integrationtest.py index 1e7e9b97..8d1f0b0e 100644 --- a/tests/backends/libspotify/backend_integrationtest.py +++ b/tests/backends/libspotify/backend_integrationtest.py @@ -5,7 +5,12 @@ import unittest from mopidy.backends.libspotify import LibspotifyBackend from mopidy.models import Track -from tests.backends.base import * +from tests.backends.base.current_playlist import \ + BaseCurrentPlaylistControllerTest +from tests.backends.base.library import BaseLibraryControllerTest +from tests.backends.base.playback import BasePlaybackControllerTest +from tests.backends.base.stored_playlists import \ + BaseStoredPlaylistsControllerTest uris = [ 'spotify:track:6vqcpVcbI3Zu6sH3ieLDNt', @@ -15,28 +20,25 @@ uris = [ class LibspotifyCurrentPlaylistControllerTest( BaseCurrentPlaylistControllerTest, unittest.TestCase): - tracks = [Track(uri=uri, length=4464) for i, uri in enumerate(uris)] + backend_class = LibspotifyBackend + tracks = [Track(uri=uri, length=4464) for i, uri in enumerate(uris)] class LibspotifyPlaybackControllerTest( BasePlaybackControllerTest, unittest.TestCase): - tracks = [Track(uri=uri, length=4464) for i, uri in enumerate(uris)] + backend_class = LibspotifyBackend + tracks = [Track(uri=uri, length=4464) for i, uri in enumerate(uris)] class LibspotifyStoredPlaylistsControllerTest( BaseStoredPlaylistsControllerTest, unittest.TestCase): + backend_class = LibspotifyBackend class LibspotifyLibraryControllerTest( BaseLibraryControllerTest, unittest.TestCase): + backend_class = LibspotifyBackend - - -# TODO Plug this into the backend under test to avoid music output during -# testing. -class DummyAudioController(object): - def music_delivery(self, *args, **kwargs): - pass diff --git a/tests/backends/local/__init__.py b/tests/backends/local/__init__.py index e69de29b..60a1bd4d 100644 --- a/tests/backends/local/__init__.py +++ b/tests/backends/local/__init__.py @@ -0,0 +1,6 @@ +from mopidy.utils.path import path_to_uri + +from tests import data_folder + +song = data_folder('song%s.wav') +generate_song = lambda i: path_to_uri(song % i) diff --git a/tests/backends/local/current_playlist_test.py b/tests/backends/local/current_playlist_test.py new file mode 100644 index 00000000..01354a06 --- /dev/null +++ b/tests/backends/local/current_playlist_test.py @@ -0,0 +1,31 @@ +import unittest + +# FIXME Our Windows build server does not support GStreamer yet +import sys +if sys.platform == 'win32': + from tests import SkipTest + raise SkipTest + +from mopidy import settings +from mopidy.backends.local import LocalBackend +from mopidy.models import Track + +from tests.backends.base.current_playlist import \ + BaseCurrentPlaylistControllerTest +from tests.backends.local import generate_song + +class LocalCurrentPlaylistControllerTest(BaseCurrentPlaylistControllerTest, + unittest.TestCase): + + backend_class = LocalBackend + tracks = [Track(uri=generate_song(i), length=4464) + for i in range(1, 4)] + + def setUp(self): + self.original_backends = settings.BACKENDS + settings.BACKENDS = ('mopidy.backends.local.LocalBackend',) + super(LocalCurrentPlaylistControllerTest, self).setUp() + + def tearDown(self): + super(LocalCurrentPlaylistControllerTest, self).tearDown() + settings.BACKENDS = settings.original_backends diff --git a/tests/backends/local/library_test.py b/tests/backends/local/library_test.py new file mode 100644 index 00000000..75751e3d --- /dev/null +++ b/tests/backends/local/library_test.py @@ -0,0 +1,32 @@ +import unittest + +# FIXME Our Windows build server does not support GStreamer yet +import sys +if sys.platform == 'win32': + from tests import SkipTest + raise SkipTest + +from mopidy import settings +from mopidy.backends.local import LocalBackend + +from tests import data_folder +from tests.backends.base.library import BaseLibraryControllerTest + +class LocalLibraryControllerTest(BaseLibraryControllerTest, unittest.TestCase): + + backend_class = LocalBackend + + def setUp(self): + self.original_tag_cache = settings.LOCAL_TAG_CACHE + self.original_music_folder = settings.LOCAL_MUSIC_FOLDER + + settings.LOCAL_TAG_CACHE = data_folder('library_tag_cache') + settings.LOCAL_MUSIC_FOLDER = data_folder('') + + super(LocalLibraryControllerTest, self).setUp() + + def tearDown(self): + settings.LOCAL_TAG_CACHE = self.original_tag_cache + settings.LOCAL_MUSIC_FOLDER = self.original_music_folder + + super(LocalLibraryControllerTest, self).tearDown() diff --git a/tests/backends/local/playback_test.py b/tests/backends/local/playback_test.py new file mode 100644 index 00000000..4a385a9d --- /dev/null +++ b/tests/backends/local/playback_test.py @@ -0,0 +1,58 @@ +import unittest + +# FIXME Our Windows build server does not support GStreamer yet +import sys +if sys.platform == 'win32': + from tests import SkipTest + raise SkipTest + +from mopidy import settings +from mopidy.backends.local import LocalBackend +from mopidy.models import Track +from mopidy.utils.path import path_to_uri + +from tests import data_folder +from tests.backends.base.playback import BasePlaybackControllerTest +from tests.backends.local import generate_song + +class LocalPlaybackControllerTest(BasePlaybackControllerTest, + unittest.TestCase): + + backend_class = LocalBackend + tracks = [Track(uri=generate_song(i), length=4464) + for i in range(1, 4)] + + def setUp(self): + self.original_backends = settings.BACKENDS + settings.BACKENDS = ('mopidy.backends.local.LocalBackend',) + + super(LocalPlaybackControllerTest, self).setUp() + # Two tests does not work at all when using the fake sink + #self.backend.playback.use_fake_sink() + + def tearDown(self): + super(LocalPlaybackControllerTest, self).tearDown() + settings.BACKENDS = settings.original_backends + + def add_track(self, path): + uri = path_to_uri(data_folder(path)) + track = Track(uri=uri, length=4464) + self.backend.current_playlist.add(track) + + def test_uri_handler(self): + self.assert_('file://' in self.backend.uri_handlers) + + def test_play_mp3(self): + self.add_track('blank.mp3') + self.playback.play() + self.assertEqual(self.playback.state, self.playback.PLAYING) + + def test_play_ogg(self): + self.add_track('blank.ogg') + self.playback.play() + self.assertEqual(self.playback.state, self.playback.PLAYING) + + def test_play_flac(self): + self.add_track('blank.flac') + self.playback.play() + self.assertEqual(self.playback.state, self.playback.PLAYING) diff --git a/tests/backends/local/backend_test.py b/tests/backends/local/stored_playlists_test.py similarity index 50% rename from tests/backends/local/backend_test.py rename to tests/backends/local/stored_playlists_test.py index a59c8420..bb03f997 100644 --- a/tests/backends/local/backend_test.py +++ b/tests/backends/local/stored_playlists_test.py @@ -1,10 +1,11 @@ import unittest import os +from tests import SkipTest + # FIXME Our Windows build server does not support GStreamer yet import sys if sys.platform == 'win32': - from tests import SkipTest raise SkipTest from mopidy import settings @@ -13,71 +14,10 @@ from mopidy.mixers.dummy import DummyMixer from mopidy.models import Playlist, Track from mopidy.utils.path import path_to_uri -from tests.backends.base.backend import * -from tests import SkipTest, data_folder - -song = data_folder('song%s.wav') -generate_song = lambda i: path_to_uri(song % i) - -# FIXME can be switched to generic test -class LocalCurrentPlaylistControllerTest(BaseCurrentPlaylistControllerTest, - unittest.TestCase): - tracks = [Track(uri=generate_song(i), length=4464) - for i in range(1, 4)] - - backend_class = LocalBackend - - def setUp(self): - self.original_backends = settings.BACKENDS - settings.BACKENDS = ('mopidy.backends.local.LocalBackend',) - super(LocalCurrentPlaylistControllerTest, self).setUp() - - def tearDown(self): - super(LocalCurrentPlaylistControllerTest, self).tearDown() - settings.BACKENDS = settings.original_backends - - -class LocalPlaybackControllerTest(BasePlaybackControllerTest, - unittest.TestCase): - tracks = [Track(uri=generate_song(i), length=4464) - for i in range(1, 4)] - backend_class = LocalBackend - - def setUp(self): - self.original_backends = settings.BACKENDS - settings.BACKENDS = ('mopidy.backends.local.LocalBackend',) - - super(LocalPlaybackControllerTest, self).setUp() - # Two tests does not work at all when using the fake sink - #self.backend.playback.use_fake_sink() - - def tearDown(self): - super(LocalPlaybackControllerTest, self).tearDown() - settings.BACKENDS = settings.original_backends - - def add_track(self, path): - uri = path_to_uri(data_folder(path)) - track = Track(uri=uri, length=4464) - self.backend.current_playlist.add(track) - - def test_uri_handler(self): - self.assert_('file://' in self.backend.uri_handlers) - - def test_play_mp3(self): - self.add_track('blank.mp3') - self.playback.play() - self.assertEqual(self.playback.state, self.playback.PLAYING) - - def test_play_ogg(self): - self.add_track('blank.ogg') - self.playback.play() - self.assertEqual(self.playback.state, self.playback.PLAYING) - - def test_play_flac(self): - self.add_track('blank.flac') - self.playback.play() - self.assertEqual(self.playback.state, self.playback.PLAYING) - +from tests import data_folder +from tests.backends.base.stored_playlists import \ + BaseStoredPlaylistsControllerTest +from tests.backends.local import generate_song class LocalStoredPlaylistsControllerTest(BaseStoredPlaylistsControllerTest, unittest.TestCase): @@ -149,27 +89,3 @@ class LocalStoredPlaylistsControllerTest(BaseStoredPlaylistsControllerTest, def test_save_sets_playlist_uri(self): raise SkipTest - - -class LocalLibraryControllerTest(BaseLibraryControllerTest, - unittest.TestCase): - - backend_class = LocalBackend - - def setUp(self): - self.original_tag_cache = settings.LOCAL_TAG_CACHE - self.original_music_folder = settings.LOCAL_MUSIC_FOLDER - - settings.LOCAL_TAG_CACHE = data_folder('library_tag_cache') - settings.LOCAL_MUSIC_FOLDER = data_folder('') - - super(LocalLibraryControllerTest, self).setUp() - - def tearDown(self): - settings.LOCAL_TAG_CACHE = self.original_tag_cache - settings.LOCAL_MUSIC_FOLDER = self.original_music_folder - - super(LocalLibraryControllerTest, self).tearDown() - -if __name__ == '__main__': - unittest.main() From 26e947c91980cf6ccb83a99cbb5c6fefb0837d4f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 22 Aug 2010 21:31:49 +0200 Subject: [PATCH 65/80] Add TODO on how to make a better libspotify lookup --- mopidy/backends/libspotify/library.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/mopidy/backends/libspotify/library.py b/mopidy/backends/libspotify/library.py index c2b70dca..ffb9ee57 100644 --- a/mopidy/backends/libspotify/library.py +++ b/mopidy/backends/libspotify/library.py @@ -15,6 +15,8 @@ class LibspotifyLibraryController(BaseLibraryController): def lookup(self, uri): spotify_track = Link.from_string(uri).as_track() + # TODO Block until metadata_updated callback is called. Before that the + # track will be unloaded, unless it's already in the stored playlists. return LibspotifyTranslator.to_mopidy_track(spotify_track) def refresh(self, uri=None): From f18e56bef98a228b803e74f40dcfc391928f2cc8 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 22 Aug 2010 21:39:06 +0200 Subject: [PATCH 66/80] Create empty settings.py if it does not exist --- mopidy/core.py | 3 ++- mopidy/utils/path.py | 9 ++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/mopidy/core.py b/mopidy/core.py index 396a2091..3296fa6b 100644 --- a/mopidy/core.py +++ b/mopidy/core.py @@ -5,7 +5,7 @@ import optparse from mopidy import get_version, settings from mopidy.utils import get_class from mopidy.utils.log import setup_logging -from mopidy.utils.path import get_or_create_folder +from mopidy.utils.path import get_or_create_folder, get_or_create_file from mopidy.utils.process import BaseProcess, unpickle_connection from mopidy.utils.settings import list_settings_optparse_callback @@ -55,6 +55,7 @@ class CoreProcess(BaseProcess): def setup_settings(self): get_or_create_folder('~/.mopidy/') + get_or_create_file('~/.mopidy/settings.py') settings.validate() def setup_output(self, core_queue): diff --git a/mopidy/utils/path.py b/mopidy/utils/path.py index 002b54c8..0dd163ec 100644 --- a/mopidy/utils/path.py +++ b/mopidy/utils/path.py @@ -8,10 +8,17 @@ logger = logging.getLogger('mopidy.utils.path') def get_or_create_folder(folder): folder = os.path.expanduser(folder) if not os.path.isdir(folder): - logger.info(u'Creating %s', folder) + logger.info(u'Creating dir %s', folder) os.mkdir(folder, 0755) return folder +def get_or_create_file(filename): + filename = os.path.expanduser(filename) + if not os.path.isfile(filename): + logger.info(u'Creating file %s', filename) + open(filename, 'w') + return filename + def path_to_uri(*paths): path = os.path.join(*paths) #path = os.path.expanduser(path) # FIXME Waiting for test case? From 2f19a9f22af07bc57ecf0399d8c9efc8b4c87652 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 22 Aug 2010 22:05:25 +0200 Subject: [PATCH 67/80] Bump version number to 0.1.0 --- mopidy/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/__init__.py b/mopidy/__init__.py index 7cdcad6a..15b7b1ad 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -3,7 +3,7 @@ if not (2, 6) <= sys.version_info < (3,): sys.exit(u'Mopidy requires Python >= 2.6, < 3') def get_version(): - return u'0.1.0a4' + return u'0.1.0' class MopidyException(Exception): def __init__(self, message, *args, **kwargs): From eb11a44fe716987b86903dbdd8b9bf5e30aa7392 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 22 Aug 2010 22:05:42 +0200 Subject: [PATCH 68/80] Update changelog for 0.1.0 and add tentative release date --- docs/changes.rst | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 4fdc8c1f..cdd0e570 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -5,12 +5,18 @@ Changes This change log is used to track all major changes to Mopidy. -0.1.0a4 (in development) -======================== +0.1.0 (2010-08-23) +================== -The greatest release ever! We present to you important improvements in search -functionality, working track position seeking, no known stability issues, and -greatly improved MPD client support. +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. + +Mopidy 0.1.0 got important improvements in search functionality, working track +position seeking, no known stability issues, and greatly improved MPD client +support. + +There are lots of changes since 0.1.0a3, and we urge you to at least read the +*important changes* below. **Important changes** From 9e7723f8cabcfe28a8d1a2178f1deb321c6580cc Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 22 Aug 2010 22:08:40 +0200 Subject: [PATCH 69/80] Update version tests for 0.1.0 --- tests/version_test.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/version_test.py b/tests/version_test.py index 6ab3ee2f..a44e4e89 100644 --- a/tests/version_test.py +++ b/tests/version_test.py @@ -9,10 +9,9 @@ class VersionTest(unittest.TestCase): def test_versions_can_be_strictly_ordered(self): self.assert_(SV('0.1.0a0') < SV('0.1.0a1')) - self.assert_(SV('0.1.0a2') < SV(get_version())) + self.assert_(SV('0.1.0a1') < SV('0.1.0a2')) + self.assert_(SV('0.1.0a2') < SV('0.1.0a3')) self.assert_(SV('0.1.0a3') < SV(get_version())) - self.assert_(SV(get_version()) < SV('0.1.0a5')) - self.assert_(SV('0.1.0a0') < SV('0.1.0')) - self.assert_(SV('0.1.0') < SV('0.1.1')) + self.assert_(SV(get_version()) < SV('0.1.1')) self.assert_(SV('0.1.1') < SV('0.2.0')) self.assert_(SV('0.2.0') < SV('1.0.0')) From 93fb00d0d26ebcb8d45c8c924ae44b39b56993f4 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 22 Aug 2010 22:19:36 +0200 Subject: [PATCH 70/80] Add note on missing OS X support --- docs/changes.rst | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index cdd0e570..38aa24b6 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -13,10 +13,12 @@ the state of Mopidy to remove the alpha label, and do a regular release. Mopidy 0.1.0 got important improvements in search functionality, working track position seeking, no known stability issues, and greatly improved MPD client -support. +support. There are lots of changes since 0.1.0a3, and we urge you to at least +read the *important changes* below. -There are lots of changes since 0.1.0a3, and we urge you to at least read the -*important changes* below. +This release does not support OS X. We're sorry about that, and are working on +fixing the OS X issues for a future release. You can track the progress at +:issue:`14`. **Important changes** From 892bc3296cf7be7d2593d1a6e17d7c2291809974 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 22 Aug 2010 22:19:58 +0200 Subject: [PATCH 71/80] Link from important changes to GStreamer install docs --- docs/changes.rst | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/changes.rst b/docs/changes.rst index 38aa24b6..fadddc1a 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -23,7 +23,8 @@ fixing the OS X issues for a future release. You can track the progress at **Important changes** - License changed from GPLv2 to Apache License, version 2.0. -- GStreamer is now a required dependency. +- GStreamer is now a required dependency. See our :doc:`GStreamer installation + docs `. - :mod:`mopidy.backends.libspotify` is now the default backend. :mod:`mopidy.backends.despotify` is no longer available. This means that you need to install the :doc:`dependencies for libspotify From 1d720022ca9341c814e0b6d63f5af77495a4990a Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 22 Aug 2010 22:23:01 +0200 Subject: [PATCH 72/80] Add note on backend's use of mopidy.outputs.gstreamer --- docs/changes.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/changes.rst b/docs/changes.rst index fadddc1a..e84d7aa9 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -81,6 +81,9 @@ fixing the OS X issues for a future release. You can track the progress at - A Spotify application key is now bundled with the source. :attr:`mopidy.settings.SPOTIFY_LIB_APPKEY` is thus removed. - If failing to play a track, playback will skip to the next track. + - Both :mod:`mopidy.backends.libspotify` and :mod:`mopidy.backends.local` + have been rewritten to use the new common GStreamer audio output module, + :mod:`mopidy.outputs.gstreamer`. - Mixers: From 05aa3fd01421422bb6eb7cc8e589fe0310401d2b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 22 Aug 2010 22:25:42 +0200 Subject: [PATCH 73/80] Another link to the GStreamer install docs --- docs/installation/index.rst | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/installation/index.rst b/docs/installation/index.rst index abd185f1..b5fced8d 100644 --- a/docs/installation/index.rst +++ b/docs/installation/index.rst @@ -2,10 +2,10 @@ Installation ************ -To get a basic version of Mopidy running, you need Python and the GStreamer -library. To use Spotify with Mopidy, you also need :doc:`libspotify and -pyspotify `. Mopidy itself can either be installed from the Python -package index, PyPI, or from git. +To get a basic version of Mopidy running, you need Python and the +:doc:`GStreamer library `. To use Spotify with Mopidy, you also need +:doc:`libspotify and pyspotify `. Mopidy itself can either be +installed from the Python package index, PyPI, or from git. Install dependencies From c28d3b709595df0099c92a8b38869127a4bde733 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 22 Aug 2010 22:48:26 +0200 Subject: [PATCH 74/80] Rewrite the settings docs --- docs/installation/index.rst | 43 +++++++++++++++++++++++++++---------- 1 file changed, 32 insertions(+), 11 deletions(-) diff --git a/docs/installation/index.rst b/docs/installation/index.rst index b5fced8d..d406cdbb 100644 --- a/docs/installation/index.rst +++ b/docs/installation/index.rst @@ -91,20 +91,42 @@ For an introduction to ``git``, please visit `git-scm.com Settings ======== -Create a file named ``settings.py`` in the directory ``~/.mopidy/``. +Mopidy reads settings from the file ``~/.mopidy/settings.py``, where ``~`` +means your *home directory*. If your username is ``alice`` and you are running +Linux, the settings file should probably be at +``/home/alice/.mopidy/settings.py``. -If you are using a Spotify backend, enter your Spotify Premium account's -username and password into the file, like this:: +You can either create this file yourself, or run the ``mopidy`` command, and it +will create an empty settings file for you. + +Music from Spotify +------------------ + +If you are using the Spotify backend, which is the default, enter your Spotify +Premium account's username and password into the file, like this:: SPOTIFY_USERNAME = u'myusername' SPOTIFY_PASSWORD = u'mysecret' -Currently :mod:`mopidy.backends.libspotify` is the default backend. If you want -to use :mod:`mopidy.backends.local`, add the following setting:: +Music from local storage +------------------------ + +If you want use Mopidy to play music you have locally at your machine instead +of using Spotify, you need to change the backend from the default to +:mod:`mopidy.backends.local` by adding the following line to your settings +file:: BACKENDS = (u'mopidy.backends.local.LocalBackend',) -For a full list of available settings, see :mod:`mopidy.settings`. +You may also want to change some of the ``LOCAL_*`` settings. See +:mod:`mopidy.settings`, for a full list of available settings. + +Connecting from other machines on the network +--------------------------------------------- + +As a secure default, Mopidy only accepts connections from ``localhost``. If you +want to open it for connections from other machines on your network, see +the documentation for :attr:`mopidy.settings.MPD_SERVER_HOSTNAME`. Running Mopidy @@ -114,10 +136,9 @@ To start Mopidy, simply open a terminal and run:: mopidy -When Mopidy says ``MPD server running at [localhost]:6600`` it's ready to -accept connections by any MPD client. You can find a list of tons of MPD -clients at http://mpd.wikia.com/wiki/Clients. We use GMPC and -ncmpcpp during development. The first is a GUI client, and the second is a -terminal client. +When Mopidy says ``MPD server running at [127.0.0.1]:6600`` it's ready to +accept connections by any MPD client. You can find tons of MPD clients at +http://mpd.wikia.com/wiki/Clients. We use GMPC and ncmpcpp during development. +The first is a GUI client, and the second is a terminal client. To stop Mopidy, press ``CTRL+C``. From 99a73f3a19134d96bd5fcc2dd7d3d3dbd9b01190 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 22 Aug 2010 22:52:16 +0200 Subject: [PATCH 75/80] Update dependencies list --- docs/installation/index.rst | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/installation/index.rst b/docs/installation/index.rst index d406cdbb..26b864d2 100644 --- a/docs/installation/index.rst +++ b/docs/installation/index.rst @@ -31,6 +31,11 @@ Make sure you got the required dependencies installed. - pyserial (Debian/Ubuntu package: python-serial) + - *Default:* :mod:`mopidy.mixers.gstreamer_software` (Linux, OS X, and + Windows) + + - No additional dependencies. + - :mod:`mopidy.mixers.nad` (Linux, OS X, and Windows) - pyserial (Debian/Ubuntu package: python-serial) @@ -41,7 +46,7 @@ Make sure you got the required dependencies installed. - Dependencies for at least one Mopidy backend: - - :mod:`mopidy.backends.libspotify` (Linux, OS X, and Windows) + - *Default:* :mod:`mopidy.backends.libspotify` (Linux, OS X, and Windows) - :doc:`libspotify and pyspotify ` From ce50e7ed47817881e21a5d0db370673800580a35 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 22 Aug 2010 23:08:43 +0200 Subject: [PATCH 76/80] Fix missing word --- docs/development/internals.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/development/internals.rst b/docs/development/internals.rst index 0af13aa8..4b4d3b14 100644 --- a/docs/development/internals.rst +++ b/docs/development/internals.rst @@ -20,7 +20,7 @@ the Spotify service, and the speakers. - 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 processing messages on the core queue. + 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``. From 820e4339294dc755a42067cd5c65ee5eccb7c26d Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 22 Aug 2010 23:17:06 +0200 Subject: [PATCH 77/80] Add API docs for mopidy.outputs --- docs/api/outputs.rst | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 docs/api/outputs.rst diff --git a/docs/api/outputs.rst b/docs/api/outputs.rst new file mode 100644 index 00000000..8f4e33c0 --- /dev/null +++ b/docs/api/outputs.rst @@ -0,0 +1,22 @@ +********************* +:mod:`mopidy.outputs` +********************* + +Outputs are responsible for playing audio. + + +Output API +========== + +A stable output API is not available yet, as we've only implemented a single +output module. + + +: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 c7c08f8457d9d7b45e309f92fab112637b6b34bd Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 22 Aug 2010 23:18:42 +0200 Subject: [PATCH 78/80] Move mixer inheritance diagrams --- docs/api/mixers.rst | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/docs/api/mixers.rst b/docs/api/mixers.rst index 91c2e7aa..edaea306 100644 --- a/docs/api/mixers.rst +++ b/docs/api/mixers.rst @@ -40,58 +40,58 @@ methods as described below. :mod:`mopidy.mixers.alsa` -- ALSA mixer for Linux ================================================= +.. inheritance-diagram:: mopidy.mixers.alsa + .. automodule:: mopidy.mixers.alsa :synopsis: ALSA mixer for Linux :members: -.. inheritance-diagram:: mopidy.mixers.alsa.AlsaMixer - :mod:`mopidy.mixers.denon` -- Hardware mixer for Denon amplifiers ================================================================= +.. inheritance-diagram:: mopidy.mixers.denon + .. automodule:: mopidy.mixers.denon :synopsis: Hardware mixer for Denon amplifiers :members: -.. inheritance-diagram:: mopidy.mixers.denon - :mod:`mopidy.mixers.dummy` -- Dummy mixer for testing ===================================================== +.. inheritance-diagram:: mopidy.mixers.dummy + .. automodule:: mopidy.mixers.dummy :synopsis: Dummy mixer for testing :members: -.. inheritance-diagram:: mopidy.mixers.dummy - :mod:`mopidy.mixers.gstreamer_software` -- Software mixer for all platforms =========================================================================== +.. inheritance-diagram:: mopidy.mixers.gstreamer_software + .. automodule:: mopidy.mixers.gstreamer_software :synopsis: Software mixer for all platforms :members: -.. inheritance-diagram:: mopidy.mixers.gstreamer_software - :mod:`mopidy.mixers.osa` -- Osa mixer for OS X ============================================== +.. inheritance-diagram:: mopidy.mixers.osa + .. automodule:: mopidy.mixers.osa :synopsis: Osa mixer for OS X :members: -.. inheritance-diagram:: mopidy.mixers.osa - :mod:`mopidy.mixers.nad` -- Hardware mixer for NAD amplifiers ============================================================= +.. inheritance-diagram:: mopidy.mixers.nad + .. automodule:: mopidy.mixers.nad :synopsis: Hardware mixer for NAD amplifiers :members: - -.. inheritance-diagram:: mopidy.mixers.nad From c34dcd583be8fe58fdff235f183ca4f5655025c7 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 22 Aug 2010 23:25:31 +0200 Subject: [PATCH 79/80] Reference relevant settings from all backends, frontends, mixers and outputs --- mopidy/backends/libspotify/__init__.py | 6 ++++++ mopidy/backends/local/__init__.py | 6 ++++++ mopidy/frontends/mpd/__init__.py | 5 +++++ mopidy/mixers/__init__.py | 4 ++++ mopidy/mixers/alsa.py | 4 ++++ mopidy/outputs/gstreamer.py | 4 ++++ 6 files changed, 29 insertions(+) diff --git a/mopidy/backends/libspotify/__init__.py b/mopidy/backends/libspotify/__init__.py index f00ec1f0..07f3e2f7 100644 --- a/mopidy/backends/libspotify/__init__.py +++ b/mopidy/backends/libspotify/__init__.py @@ -16,6 +16,12 @@ class LibspotifyBackend(BaseBackend): **Issues:** http://github.com/jodal/mopidy/issues/labels/backend-libspotify + **Settings:** + + - :attr:`mopidy.settings.SPOTIFY_LIB_CACHE` + - :attr:`mopidy.settings.SPOTIFY_USERNAME` + - :attr:`mopidy.settings.SPOTIFY_PASSWORD` + .. note:: This product uses SPOTIFY(R) CORE but is not endorsed, certified or diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index e9e86f34..50b3d84d 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -18,6 +18,12 @@ class LocalBackend(BaseBackend): A backend for playing music from a local music archive. **Issues:** http://github.com/jodal/mopidy/issues/labels/backend-local + + **Settings:** + + - :attr:`mopidy.settings.LOCAL_MUSIC_FOLDER` + - :attr:`mopidy.settings.LOCAL_PLAYLIST_FOLDER` + - :attr:`mopidy.settings.LOCAL_TAG_CACHE` """ def __init__(self, *args, **kwargs): diff --git a/mopidy/frontends/mpd/__init__.py b/mopidy/frontends/mpd/__init__.py index 02e3ab5f..6c06279f 100644 --- a/mopidy/frontends/mpd/__init__.py +++ b/mopidy/frontends/mpd/__init__.py @@ -4,6 +4,11 @@ from mopidy.frontends.mpd.process import MpdProcess class MpdFrontend(object): """ The MPD frontend. + + **Settings:** + + - :attr:`mopidy.settings.MPD_SERVER_HOSTNAME` + - :attr:`mopidy.settings.MPD_SERVER_PORT` """ def __init__(self): diff --git a/mopidy/mixers/__init__.py b/mopidy/mixers/__init__.py index c9543863..332718a6 100644 --- a/mopidy/mixers/__init__.py +++ b/mopidy/mixers/__init__.py @@ -4,6 +4,10 @@ class BaseMixer(object): """ :param backend: a backend instance :type mixer: :class:`mopidy.backends.base.BaseBackend` + + **Settings:** + + - :attr:`mopidy.settings.MIXER_MAX_VOLUME` """ def __init__(self, backend, *args, **kwargs): diff --git a/mopidy/mixers/alsa.py b/mopidy/mixers/alsa.py index efcb1e98..6eef6da4 100644 --- a/mopidy/mixers/alsa.py +++ b/mopidy/mixers/alsa.py @@ -10,6 +10,10 @@ class AlsaMixer(BaseMixer): """ Mixer which uses the Advanced Linux Sound Architecture (ALSA) to control volume. + + **Settings:** + + - :attr:`mopidy.settings.MIXER_ALSA_CONTROL` """ def __init__(self, *args, **kwargs): diff --git a/mopidy/outputs/gstreamer.py b/mopidy/outputs/gstreamer.py index 453747d6..554e986e 100644 --- a/mopidy/outputs/gstreamer.py +++ b/mopidy/outputs/gstreamer.py @@ -18,6 +18,10 @@ class GStreamerOutput(object): Audio output through GStreamer. Starts the :class:`GStreamerProcess`. + + **Settings:** + + - :attr:`mopidy.settings.GSTREAMER_AUDIO_SINK` """ def __init__(self, core_queue, output_queue): From b24961799c1e0910c7f5c179e4cf9f962f4bede3 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 22 Aug 2010 23:36:23 +0200 Subject: [PATCH 80/80] Add docs for mopidy.frontends --- docs/api/frontends/index.rst | 18 ++++++++++++++++++ docs/api/{ => frontends}/mpd.rst | 0 2 files changed, 18 insertions(+) create mode 100644 docs/api/frontends/index.rst rename docs/api/{ => frontends}/mpd.rst (100%) diff --git a/docs/api/frontends/index.rst b/docs/api/frontends/index.rst new file mode 100644 index 00000000..052c7781 --- /dev/null +++ b/docs/api/frontends/index.rst @@ -0,0 +1,18 @@ +*********************** +:mod:`mopidy.frontends` +*********************** + +A frontend is responsible for exposing Mopidy for a type of clients. + + +Frontend API +============ + +A stable frontend API is not available yet, as we've only implemented a single +frontend module. + + +Frontends +========= + +* :mod:`mopidy.frontends.mpd` diff --git a/docs/api/mpd.rst b/docs/api/frontends/mpd.rst similarity index 100% rename from docs/api/mpd.rst rename to docs/api/frontends/mpd.rst